@@ -59,6 +68,7 @@
+
diff --git a/frontend/src/views/WorkshopView.vue b/frontend/src/views/WorkshopView.vue
new file mode 100644
index 0000000..1beef72
--- /dev/null
+++ b/frontend/src/views/WorkshopView.vue
@@ -0,0 +1,248 @@
+
+
+
+
+
+
+
+
{{ name }}
+
+ {{ o.order_no }}
+ {{ o.customer_name || '-' }}
+ ¥{{ o.money }}
+
+
-
+
+
+
+
+
+
+
+
+ 摄像头
+
+ 查询进度
+
+
+
+
+
+
+ {{ scanResult.order_no }} · {{ scanResult.customer_name || '-' }}({{ scanResult.status_label }})
+ ¥{{ scanResult.money }}
+
+
+
+
+
+
+
+
+
+
+
+
+ 更新环节
+ 扫码枪可直接对订单条码扫入输入框
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 删
+
+
+
+
+
+
+
+
+
+ 添加
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 派工
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/huamenchuang/api/optimize.py b/src/huamenchuang/api/optimize.py
index 433a5c0..bc04737 100644
--- a/src/huamenchuang/api/optimize.py
+++ b/src/huamenchuang/api/optimize.py
@@ -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="优化完成")
diff --git a/src/huamenchuang/api/reports.py b/src/huamenchuang/api/reports.py
index a382112..7cf860d 100644
--- a/src/huamenchuang/api/reports.py
+++ b/src/huamenchuang/api/reports.py
@@ -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,
+ }})
diff --git a/src/huamenchuang/api/router.py b/src/huamenchuang/api/router.py
index f6a07b0..fbf4240 100644
--- a/src/huamenchuang/api/router.py
+++ b/src/huamenchuang/api/router.py
@@ -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)
diff --git a/src/huamenchuang/api/workshop.py b/src/huamenchuang/api/workshop.py
new file mode 100644
index 0000000..8c5f484
--- /dev/null
+++ b/src/huamenchuang/api/workshop.py
@@ -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)})
diff --git a/src/huamenchuang/models/__init__.py b/src/huamenchuang/models/__init__.py
index 13f295b..cab31c8 100644
--- a/src/huamenchuang/models/__init__.py
+++ b/src/huamenchuang/models/__init__.py
@@ -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",
diff --git a/src/huamenchuang/models/extend.py b/src/huamenchuang/models/extend.py
index c48d437..2ea80b2 100644
--- a/src/huamenchuang/models/extend.py
+++ b/src/huamenchuang/models/extend.py
@@ -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 通道)。"""
diff --git a/src/huamenchuang/services/optimize_service.py b/src/huamenchuang/services/optimize_service.py
index 8f9e1e4..4e44284 100644
--- a/src/huamenchuang/services/optimize_service.py
+++ b/src/huamenchuang/services/optimize_service.py
@@ -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,
+ },
+ }