feat: P1批次3 生产域(车间管理/扫码跟踪/双通道优化/订单利润/条码打印)
This commit is contained in:
@@ -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="优化完成")
|
||||
|
||||
@@ -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,
|
||||
}})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)})
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user