diff --git a/CHANGELOG b/CHANGELOG index 6580bd2..df6eea8 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ # CHANGELOG +- [2026-09-25 21:50] FEAT: v2.31 P1 批次3 生产域 4 项。①车间管理: Staff/ProcessAssignment 模型+/api/workshop(staff CRUD/assign 派工/assignments 派工历史/scan 扫码跟踪/board 跟单中心看板), 新页 /workshop 三 Tab(看板按环节列/环节扫码 BarcodeDetector 摄像头+扫码枪输入框/员工名册+派工), 路由菜单; ②双通道优化: optimize_1d_fine(BFD 最佳适应递减+有界余料均衡, 修复 while 死循环卡死事件循环)+optimize1d method=fast|fine, 优化页通道选择; ③订单利润: GET /reports/order-profit(订单金额 vs BOM 材料成本估算, 价格本成本均价口径)+利润汇总卡+明细表, 报表页嵌入; ④条码打印: 打印模板加 barcode 区块(JsBarcode Code128 订单号)+模板管理可加条码块; vue-tsc 0 错+构建通过; API 实测双通道/看板/利润/员工 (Files: src/huamenchuang/models/extend.py, src/huamenchuang/api/workshop.py, src/huamenchuang/api/optimize.py, src/huamenchuang/services/optimize_service.py, src/huamenchuang/api/reports.py, src/huamenchuang/api/router.py, frontend/src/views/WorkshopView.vue, frontend/src/views/OptimizeView.vue, frontend/src/views/ReportView.vue, frontend/src/views/PrinterView.vue, frontend/src/router/index.ts, frontend/src/views/LayoutView.vue) + - [2026-09-25 21:00] FEAT: v2.30 P1 批次2 报价/产品域 3 项。①型材系列管理: /api/product-catelogs 树 CRUD(系列下有产品禁删)+产品挂系列(catelog_id)+按系列筛选, 产品页左树右表布局+编辑表单系列选择+补回丢失的产品编辑对话框; ②副梃计价收尾: 产品类别加 subbar(按尺寸/米计价, 随逐格编辑 assigns.subBar 算料); ③成本报价: 报价保存/更新按产品名匹配价格本成本写行 _cost(quote_by_cost 语义)+响应加 cost_total/profit, 报价页加成本/毛利列+分享链接(quote_link 走 shares type=quote); 实测: 成本匹配 6×38=228 毛利 84; vue-tsc 0 错+构建通过 (Files: src/huamenchuang/api/business.py, frontend/src/views/ProductsView.vue, frontend/src/views/QuotationsView.vue, doc/API.md) - [2026-09-25 20:10] FEAT: v2.29 P1 批次1 设计域 5 项。①AI 推荐窗型: api/recommend.py 规则引擎(洞口宽高比/玻璃面积比/开扇数/门类/纱扇打分)+需求标签规则(通风/采光/隔音/安全/景观/通行)+可解释理由 TOP5, 样板库页"AI 推荐"Tab; ②通用分享体系: ShareItem 模型+POST/GET /api/shares(render/combine/quote/sunny 四类)+公开页 /share/s/:token(大图/报价明细/组合只读画布), 组合页/AI 炫图/报价单三处分享按钮; ③阳光房设计: SunroomDesign 模型+CRUD+SunroomCanvas 参数化绘图(flat 平顶/gable 人字顶 正视+俯视+尺寸标注)+AI 效果图(scene=sunroom-*, 提示词含 bays/footprint)+设计分享; ④量尺拍照标注: 照片上传+Canvas 拖拽画尺寸线(颜色/标签/撤销)+合成图存洞口行 photo; ⑤扇/玻璃逐格指定: SashAssign(decs 同型自研扩展)+画布"扇/玻璃"对话框(五类开启类型/内外开/铰链侧/副梃/玻璃配置)+2D 开向三角渲染+takeoff 计指定扇框料五金副梃(subbar 25 元/米); vue-tsc 0 错+构建通过, 推荐/阳光房/分享 API 实测通过 (Files: src/huamenchuang/models/extend.py, src/huamenchuang/api/shares.py, src/huamenchuang/api/recommend.py, src/huamenchuang/api/sunroom.py, src/huamenchuang/services/ai_prompt.py, src/huamenchuang/services/quote_service.py, frontend/src/views/ShareView.vue, frontend/src/views/SunnyHouseView.vue, frontend/src/views/TemplatesView.vue, frontend/src/views/MeasureView.vue, frontend/src/views/CombineView.vue, frontend/src/views/AIRenderView.vue, frontend/src/views/QuotationsView.vue, frontend/src/components/SunroomCanvas.vue, frontend/src/components/WindoorCanvas.vue, frontend/src/types/drawing.ts, frontend/src/router/index.ts, doc/API.md) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1e90c59..69c8653 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,12 +12,14 @@ "@types/three": "^0.186.0", "axios": "^1.7.0", "element-plus": "^2.9.0", + "jsbarcode": "^3.12.3", "pinia": "^2.2.0", "three": "^0.186.0", "vue": "^3.5.0", "vue-router": "^4.4.0" }, "devDependencies": { + "@types/jsbarcode": "^3.11.4", "@vitejs/plugin-vue": "^5.1.0", "puppeteer-core": "^25.11.0", "typescript": "^5.5.0", @@ -979,6 +981,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsbarcode": { + "version": "3.11.4", + "resolved": "https://registry.npmmirror.com/@types/jsbarcode/-/jsbarcode-3.11.4.tgz", + "integrity": "sha512-VBcpTAnEMH0Gbh8JpV14CgOtJjCYjsvR2FoDRyoYPE0gUxtApf8N4c+HKEOyz/iiIZkMzqrzBA3XX7+KgKxxsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/lodash": { "version": "4.17.25", "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.25.tgz", @@ -994,6 +1006,16 @@ "@types/lodash": "*" } }, + "node_modules/@types/node": { + "version": "26.6.2", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-26.6.2.tgz", + "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, "node_modules/@types/stats.js": { "version": "0.17.4", "resolved": "https://registry.npmmirror.com/@types/stats.js/-/stats.js-0.17.4.tgz", @@ -1774,6 +1796,12 @@ "node": ">= 6" } }, + "node_modules/jsbarcode": { + "version": "3.12.3", + "resolved": "https://registry.npmmirror.com/jsbarcode/-/jsbarcode-3.12.3.tgz", + "integrity": "sha512-CuHU9hC6dPsHF5oVFMo8NW76uQVjH4L22CsP4hW+dNnGywJHC/B0ThA1CTDVLnxKLrrpYdicBLnd2xsgTfRnvg==", + "license": "MIT" + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", @@ -2120,6 +2148,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", diff --git a/frontend/package.json b/frontend/package.json index adfd931..7b804a8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,12 +13,14 @@ "@types/three": "^0.186.0", "axios": "^1.7.0", "element-plus": "^2.9.0", + "jsbarcode": "^3.12.3", "pinia": "^2.2.0", "three": "^0.186.0", "vue": "^3.5.0", "vue-router": "^4.4.0" }, "devDependencies": { + "@types/jsbarcode": "^3.11.4", "@vitejs/plugin-vue": "^5.1.0", "puppeteer-core": "^25.11.0", "typescript": "^5.5.0", diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index ddbb429..052967a 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -30,6 +30,7 @@ const router = createRouter({ { path: 'ai_render', component: () => import('../views/AIRenderView.vue'), meta: { title: 'AI 炫图' } }, { path: 'threed', component: () => import('../views/RenderView.vue'), meta: { title: '3D 展示' } }, { path: 'printer', component: () => import('../views/PrinterView.vue'), meta: { title: '打印中心' } }, + { path: 'workshop', component: () => import('../views/WorkshopView.vue'), meta: { title: '车间管理' } }, { path: 'cut_solution', component: () => import('../views/CutSolutionView.vue'), meta: { title: '下料方案' } }, { path: 'devices', component: () => import('../views/DevicesView.vue'), meta: { title: '设备管理' } }, { path: 'market', component: () => import('../views/MarketView.vue'), meta: { title: '商城' } }, diff --git a/frontend/src/views/LayoutView.vue b/frontend/src/views/LayoutView.vue index d9c140f..8fb87d8 100644 --- a/frontend/src/views/LayoutView.vue +++ b/frontend/src/views/LayoutView.vue @@ -30,6 +30,7 @@ 量尺管理 优化下料 下料方案 + 车间管理 打印中心 仓库库存 产品管理 diff --git a/frontend/src/views/OptimizeView.vue b/frontend/src/views/OptimizeView.vue index 284e626..8affb97 100644 --- a/frontend/src/views/OptimizeView.vue +++ b/frontend/src/views/OptimizeView.vue @@ -1,5 +1,14 @@ + 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 @@ + + + + + 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, + }, + }