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)})