feat: P1批次3 生产域(车间管理/扫码跟踪/双通道优化/订单利润/条码打印)

This commit is contained in:
HHY
2026-09-25 06:39:07 -07:00
parent d05a91b7bc
commit fbe21c17b5
16 changed files with 683 additions and 7 deletions
+7 -3
View File
@@ -4,7 +4,7 @@ from pydantic import BaseModel, Field
from huamenchuang.core.deps import CurrentUser
from huamenchuang.core.response import fail, ok
from huamenchuang.services.optimize_service import BarDemand, StockBar, optimize_1d
from huamenchuang.services.optimize_service import BarDemand, StockBar, optimize_1d, optimize_1d_fine
router = APIRouter(prefix="/optimize1d", tags=["优化下料"])
@@ -13,16 +13,20 @@ class OptimizeRequest(BaseModel):
demands: list[BarDemand] = Field(description="型材需求 [{length,qty,label}]")
stocks: list[StockBar] = Field(description="库存原料 [{length,qty,label}]")
kerf: int = Field(default=5, ge=0, le=50, description="锯口损耗(mm)")
method: str = Field(default="fast", pattern="^(fast|fine)$",
description="fast=快速优化(FFD) fine=详细优化(BFD+余料均衡)")
@router.post("")
async def optimize(body: OptimizeRequest, user: CurrentUser):
"""一维优化下料(FFD 首递减算法)。"""
"""一维优化下料(双通道:快速 FFD / 详细 BFD,对齐源站双通道语义)。"""
if not body.demands or not body.stocks:
return fail("需求和库存均不能为空")
total_demand = sum(d.length * d.qty for d in body.demands)
total_stock = sum(s.length * s.qty for s in body.stocks)
if total_demand > total_stock:
return fail(f"库存不足: 需求 {total_demand}mm > 库存 {total_stock}mm")
result = optimize_1d(body.demands, body.stocks, body.kerf)
result = (optimize_1d_fine if body.method == "fine" else optimize_1d)(
body.demands, body.stocks, body.kerf)
result["method"] = body.method
return ok(data=result, message="优化完成")
+48
View File
@@ -100,3 +100,51 @@ async def excel_statistics(user: CurrentUser, db: DbSession):
content = statistics_workbook(summary, receipts)
return Response(content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=statistics.xlsx"})
@router.get("/order-profit")
async def order_profit(user: CurrentUser, db: DbSession, days: int = 90):
"""订单利润报表(对齐目标站 order-profit):订单金额 vs 材料成本估算(价格本成本口径)。"""
from huamenchuang.core.drawing import parse_drawing
from huamenchuang.services.quote_service import takeoff
from huamenchuang.models.business import Product
cid = user.company_id
since = datetime.utcnow() - timedelta(days=days)
orders = list((await db.execute(
select(Order).where(Order.company_id == cid, Order.created_at >= since)
.order_by(Order.id.desc()).limit(100))).scalars())
# 成本均价(价格本成本)
async def avg_cost(material: str) -> float:
v = (await db.execute(select(func.coalesce(func.avg(Product.cost), 0)).where(
Product.company_id == cid, Product.material == material, Product.enabled == 1))).scalar()
return float(v or 0)
cost_profile = await avg_cost("profile")
cost_glass = await avg_cost("glass")
cost_hw = await avg_cost("hardware")
rows, total_money, total_cost = [], 0.0, 0.0
for o in orders:
cost = 0.0
if o.drawing_data:
try:
t = takeoff(parse_drawing(o.drawing_data))
meters = sum(b.length * b.qty for b in t.bars) / 1000
cost = meters * cost_profile + t.glass_area_m2 * cost_glass + t.sash_count * cost_hw
except Exception:
cost = 0.0
money = float(o.money or 0)
total_money += money
total_cost += cost
rows.append({"id": o.id, "order_no": o.order_no, "customer_name": o.customer_name,
"money": money, "cost": round(cost, 2), "profit": round(money - cost, 2),
"margin": round((money - cost) / money * 100, 1) if money else 0,
"created_at": str(o.created_at)[:10]})
return ok(data={"rows": rows, "summary": {
"order_count": len(rows),
"total_money": round(total_money, 2),
"total_cost": round(total_cost, 2),
"total_profit": round(total_money - total_cost, 2),
"avg_margin": round((total_money - total_cost) / total_money * 100, 1) if total_money else 0,
"cost_profile": cost_profile, "cost_glass": cost_glass, "cost_hardware": cost_hw,
}})
+2
View File
@@ -20,6 +20,7 @@ from huamenchuang.api import (
sunroom,
warehouse,
windoor,
workshop,
)
api_router = APIRouter(prefix="/api")
@@ -37,6 +38,7 @@ api_router.include_router(ai_render.router)
api_router.include_router(shares.router)
api_router.include_router(recommend.router)
api_router.include_router(sunroom.router)
api_router.include_router(workshop.router)
api_router.include_router(warehouse.router)
api_router.include_router(business.router)
api_router.include_router(platform.router)
+141
View File
@@ -0,0 +1,141 @@
"""车间管理路由:员工名册、派工、扫码跟踪、车间看板(对齐目标站 manage/workshop)。"""
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok
from huamenchuang.models.extend import ProcessAssignment, Staff
from huamenchuang.models.order import DEFAULT_PROCESSES, Order, OrderProcess
router = APIRouter(prefix="/workshop", tags=["车间管理"])
# ---------------- 员工名册 ----------------
class StaffBody(BaseModel):
name: str = Field(min_length=1, max_length=64)
phone: str = ""
role: str = Field(default="下料")
status: int = Field(default=1, ge=0, le=1)
@router.get("/staff")
async def list_staff(user: CurrentUser, db: DbSession):
rows = list((await db.execute(select(Staff).where(
Staff.company_id == user.company_id).order_by(Staff.id))).scalars())
return ok(data=[{"id": s.id, "name": s.name, "phone": s.phone, "role": s.role,
"status": s.status} for s in rows])
@router.post("/staff")
async def create_staff(body: StaffBody, user: CurrentUser, db: DbSession):
s = Staff(company_id=user.company_id, **body.model_dump())
db.add(s)
await db.flush()
return ok(data={"id": s.id}, message="员工已添加")
@router.put("/staff/{sid}")
async def update_staff(sid: int, body: StaffBody, user: CurrentUser, db: DbSession):
s = await db.get(Staff, sid)
if s is None or s.company_id != user.company_id:
return fail("员工不存在")
for k, v in body.model_dump().items():
setattr(s, k, v)
return ok(message="已更新")
@router.delete("/staff/{sid}")
async def delete_staff(sid: int, user: CurrentUser, db: DbSession):
s = await db.get(Staff, sid)
if s is None or s.company_id != user.company_id:
return fail("员工不存在")
await db.delete(s)
return ok(message="已删除")
# ---------------- 派工 ----------------
class AssignBody(BaseModel):
order_id: int
process_name: str = Field(min_length=1, max_length=16)
staff_id: int
note: str = ""
@router.post("/assign")
async def assign(body: AssignBody, user: CurrentUser, db: DbSession):
"""派工:进度节点指派员工(记录 staffHistory)。"""
order = await db.get(Order, body.order_id)
if order is None or order.company_id != user.company_id:
return fail("订单不存在")
staff = await db.get(Staff, body.staff_id)
if staff is None or staff.company_id != user.company_id:
return fail("员工不存在")
rec = ProcessAssignment(
company_id=user.company_id, order_id=order.id, order_no=order.order_no,
process_name=body.process_name, staff_id=staff.id, staff_name=staff.name,
note=body.note, user_id=user.id)
db.add(rec)
await db.flush()
return ok(data={"id": rec.id}, message=f"已派工:{body.process_name} → {staff.name}")
@router.get("/assignments")
async def list_assignments(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 30):
"""派工历史(staffHistory 语义)。"""
where = (ProcessAssignment.company_id == user.company_id,)
total = (await db.execute(select(func.count()).select_from(ProcessAssignment).where(*where))).scalar_one()
rows = (await db.execute(select(ProcessAssignment).where(*where)
.order_by(ProcessAssignment.id.desc())
.offset((page - 1) * page_size).limit(page_size))).scalars()
return ok(data={"current_page": page, "per_page": page_size, "total": total,
"data": [{"id": r.id, "order_no": r.order_no, "process_name": r.process_name,
"staff_name": r.staff_name, "note": r.note,
"created_at": str(r.created_at)[:19]} for r in rows]})
# ---------------- 扫码跟踪 ----------------
@router.get("/scan/{order_no}")
async def scan_order(order_no: str, user: CurrentUser, db: DbSession):
"""扫码跟踪(scan_tracking):按订单号取订单与进度(对齐环节扫码)。"""
order = (await db.execute(select(Order).where(
Order.company_id == user.company_id, Order.order_no == order_no))).scalar_one_or_none()
if order is None:
return fail(f"未找到订单 {order_no}")
from huamenchuang.api.orders import _to_out
return ok(data={**_to_out(order),
"processes": [{"id": p.id, "name": p.name, "status": p.status,
"finish_time": str(p.finish_time) if p.finish_time else None}
for p in order.processes]})
# ---------------- 车间看板 ----------------
@router.get("/board")
async def board(user: CurrentUser, db: DbSession):
"""跟单中心看板:在产订单按进度节点分组。"""
orders = list((await db.execute(select(Order).where(
Order.company_id == user.company_id,
Order.status.in_([Order.STATUS_FORMAL, Order.STATUS_PRODUCING]))
.order_by(Order.id.desc()).limit(50))).scalars())
# 预加载全部相关进度
oids = [o.id for o in orders]
procs: dict[int, list[OrderProcess]] = {oid: [] for oid in oids}
if oids:
from sqlalchemy import or_
rows = (await db.execute(select(OrderProcess).where(or_(
*[OrderProcess.order_id == oid for oid in oids])))).scalars()
for p in rows:
procs.setdefault(p.order_id, []).append(p)
columns: dict[str, list] = {name: [] for name in DEFAULT_PROCESSES}
for o in orders:
pl = sorted(procs.get(o.id, []), key=lambda x: x.sort)
# 当前环节 = 第一个未完成节点
current = next((p.name for p in pl if p.status != 2), "完工")
columns.setdefault(current, []).append(
{"id": o.id, "order_no": o.order_no, "customer_name": o.customer_name,
"money": float(o.money), "status_label": o.status_label})
return ok(data={"columns": columns, "order_count": len(orders)})
+4
View File
@@ -22,8 +22,10 @@ from huamenchuang.models.extend import (
Device,
OrderFormat,
PayOrder,
ProcessAssignment,
ShareItem,
SmsCode,
Staff,
SunroomDesign,
)
from huamenchuang.models.order import DEFAULT_PROCESSES, Order, OrderLog, OrderProcess
@@ -47,12 +49,14 @@ __all__ = [
"OrderProcess",
"PayOrder",
"PriceScript",
"ProcessAssignment",
"Product",
"ProductCatelog",
"Quotation",
"ReceiptAccount",
"ShareItem",
"SmsCode",
"Staff",
"SunroomDesign",
"StockMove",
"User",
+32
View File
@@ -119,6 +119,38 @@ class SunroomDesign(Base):
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
class Staff(Base):
"""车间员工(对齐目标站 manage/workshop/staff:名册与派工)。"""
__tablename__ = "staff"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(default=1, index=True)
name: Mapped[str] = mapped_column(String(64))
phone: Mapped[str] = mapped_column(String(20), default="")
# 角色: 下料/组装/玻璃/打胶/五金/质检/完工(与订单进度节点同名)
role: Mapped[str] = mapped_column(String(16), default="下料", index=True)
status: Mapped[int] = mapped_column(default=1) # 1在职 0离职
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
class ProcessAssignment(Base):
"""派工记录(订单进度节点 × 员工,staffHistory 语义)。"""
__tablename__ = "process_assignments"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(default=1, index=True)
order_id: Mapped[int] = mapped_column(index=True)
order_no: Mapped[str] = mapped_column(String(32), default="")
process_name: Mapped[str] = mapped_column(String(16))
staff_id: Mapped[int] = mapped_column(default=0)
staff_name: Mapped[str] = mapped_column(String(64), default="")
note: Mapped[str] = mapped_column(String(255), default="")
user_id: Mapped[int] = mapped_column(default=0)
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
class SmsCode(Base):
"""短信验证码(provider 适配层:未配置短信签名时走 mock 通道)。"""
@@ -104,3 +104,91 @@ def optimize_1d(demands: list[BarDemand], stocks: list[StockBar],
"kerf_mm": kerf,
},
}
def optimize_1d_fine(demands: list[BarDemand], stocks: list[StockBar],
kerf: int = KERF_DEFAULT) -> dict:
"""详细优化(对齐源站 optimize1d-ai「详细优化」语义):BFD 最佳适应递减 + 余料均衡二次调整。
相比快速优化(FFD):每件放入"装下后剩余最小"的原料,显著减少大余料浪费;
再做一轮跨原料的余料均衡交换(把某根的最后一件换到余料更贴合的原料)。
"""
pieces: list[CutPiece] = []
for d in demands:
pieces.extend(CutPiece(length=d.length, label=d.label) for _ in range(max(0, d.qty)))
pieces.sort(key=lambda p: p.length, reverse=True)
avail: list[dict] = []
for s in stocks:
for _ in range(max(0, s.qty)):
avail.append({"length": s.length, "label": s.label, "pieces": [], "used": 0})
unplaced: list[CutPiece] = []
for piece in pieces:
# BFD: 选装下后剩余最小的原料
best, best_remain = None, None
for bar in avail:
remain = bar["length"] - bar["used"]
cost = piece.length + (kerf if bar["pieces"] else 0)
if remain >= cost:
r = remain - cost
if best is None or r < best_remain:
best, best_remain = bar, r
if best is not None:
best["pieces"].append(piece)
best["used"] += piece.length + (kerf if len(best["pieces"]) > 1 else 0)
else:
unplaced.append(piece)
# 余料均衡(有界 2 轮):把"仅一件原料"上的件移到另一根更贴合的原料,减少总浪费
for _round in range(2):
moved = False
for src in avail:
if len(src["pieces"]) != 1:
continue
last = src["pieces"][0]
src_used = last.length # 单件无锯口
for dst in avail:
if dst is src or not dst["pieces"]:
continue
cost = last.length + kerf
if dst["length"] - dst["used"] >= cost:
new_dst_remain = dst["length"] - dst["used"] - cost
if new_dst_remain < src["length"] - src_used:
dst["pieces"].append(last)
dst["used"] += cost
src["pieces"].pop()
src["used"] = 0
moved = True
break
if moved:
break
if not moved:
break
plans = [
CutPlan(stock_length=b["length"], stock_label=b["label"],
pieces=b["pieces"], waste=b["length"] - b["used"])
for b in avail if b["pieces"]
]
total_demand = sum(p.length for p in pieces)
placed_demand = total_demand - sum(p.length for p in unplaced)
total_stock = sum(p.stock_length for p in plans)
return {
"plans": [
{"stock_length": p.stock_length, "stock_label": p.stock_label,
"pieces": [{"length": c.length, "label": c.label} for c in p.pieces],
"waste": p.waste}
for p in plans
],
"unplaced": [{"length": c.length, "label": c.label} for c in unplaced],
"stats": {
"total_pieces": len(pieces),
"placed_pieces": len(pieces) - len(unplaced),
"stock_bars_used": len(plans),
"total_demand_mm": total_demand,
"total_stock_mm": total_stock,
"utilization": round(placed_demand / total_stock, 4) if total_stock else 0,
"kerf_mm": kerf,
},
}