2026-07-21 11:05:57 +08:00
|
|
|
|
# ============================================================
|
2026-07-28 02:12:46 +08:00
|
|
|
|
# 订单分解 MRP(moduleId: domain-mrp, 可重生 ✅)
|
|
|
|
|
|
# 对标西门子 Opcenter:销售订单 → 沿工艺路线 + BOM 多层展开 →
|
|
|
|
|
|
# 自制(MAKE,进排产)/ 采购(BUY,原料毛坯)/ 委外(OUTSOURCE,WZ*/外协工序)。
|
|
|
|
|
|
# 权力:order.decompose = P1,只写 DRAFT 建议表;下达另走 P2。
|
2026-07-21 11:05:57 +08:00
|
|
|
|
# ============================================================
|
2026-07-28 02:12:46 +08:00
|
|
|
|
from __future__ import annotations
|
2026-07-21 11:05:57 +08:00
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
import math
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from typing import Any
|
2026-07-21 11:05:57 +08:00
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
from server.aps_domain.sourcing import (
|
|
|
|
|
|
annotate_world_sourcing,
|
|
|
|
|
|
infer_material_sourcing,
|
|
|
|
|
|
is_outsource_step,
|
|
|
|
|
|
)
|
|
|
|
|
|
from server.engines.queries import find_bom_items, find_routing_steps
|
|
|
|
|
|
from server.timeutil import add_minutes, fmt_date, fmt_dt, parse_dt
|
2026-07-21 11:05:57 +08:00
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
World = dict[str, Any]
|
2026-07-21 11:05:57 +08:00
|
|
|
|
|
|
|
|
|
|
_PURCHASE_BUFFER_DAYS = 1
|
2026-07-28 02:12:46 +08:00
|
|
|
|
_MAX_BOM_DEPTH = 10
|
2026-07-21 11:05:57 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _now() -> str:
|
|
|
|
|
|
return fmt_dt(datetime.now())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ensure_tables(world: World) -> None:
|
2026-08-20 11:39:21 +08:00
|
|
|
|
world.setdefault("makeSuggestions", [])
|
2026-07-21 11:05:57 +08:00
|
|
|
|
world.setdefault("purchaseOrders", [])
|
|
|
|
|
|
world.setdefault("outsourceOrders", [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def list_mrp(world: World) -> dict[str, Any]:
|
2026-08-20 11:39:21 +08:00
|
|
|
|
"""MRP 自制/采购/委外建议与已排生产订单投影(P0 只读)。"""
|
2026-07-21 11:05:57 +08:00
|
|
|
|
_ensure_tables(world)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
try:
|
|
|
|
|
|
from server.aps_domain.orders import sync_flex_orders_to_sales
|
|
|
|
|
|
if world.get("flexOrders"):
|
|
|
|
|
|
sync_flex_orders_to_sales(world)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
annotate_world_sourcing(world)
|
2026-08-20 11:39:21 +08:00
|
|
|
|
make = list(world["makeSuggestions"])
|
2026-07-28 02:12:46 +08:00
|
|
|
|
purchases = list(world["purchaseOrders"])
|
|
|
|
|
|
outs = list(world["outsourceOrders"])
|
2026-08-20 11:39:21 +08:00
|
|
|
|
if not make and (world.get("flexBom") or world.get("flexOrders")):
|
|
|
|
|
|
preview_make, preview_purchases, preview_outs = _flex_gap_preview(world)
|
|
|
|
|
|
make = preview_make
|
|
|
|
|
|
if not purchases and not outs:
|
|
|
|
|
|
purchases, outs = preview_purchases, preview_outs
|
2026-07-21 11:05:57 +08:00
|
|
|
|
return {
|
2026-08-20 11:39:21 +08:00
|
|
|
|
"make": make,
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"purchaseOrders": purchases,
|
|
|
|
|
|
"outsourceOrders": outs,
|
|
|
|
|
|
"productionOrders": _list_flex_make(world),
|
2026-07-21 11:05:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
def _list_flex_make(world: World) -> list[dict[str, Any]]:
|
|
|
|
|
|
try:
|
|
|
|
|
|
from server.aps_domain.orders import list_production_orders
|
|
|
|
|
|
return list_production_orders(world)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _flex_gap_preview(world: World) -> tuple[list, list, list]:
|
2026-08-20 11:39:21 +08:00
|
|
|
|
"""柔性 BOM/工艺只读预览(含多层自制、路线质量与委外推断)。"""
|
2026-07-28 02:12:46 +08:00
|
|
|
|
_ensure_tables(world)
|
|
|
|
|
|
annotate_world_sourcing(world)
|
|
|
|
|
|
make_rows: list[dict[str, Any]] = []
|
2026-08-20 11:39:21 +08:00
|
|
|
|
make_agg: dict[tuple[str, str], dict[str, Any]] = {}
|
2026-07-28 02:12:46 +08:00
|
|
|
|
purchase_rows: list[dict[str, Any]] = []
|
|
|
|
|
|
outsource_rows: list[dict[str, Any]] = []
|
2026-08-20 11:39:21 +08:00
|
|
|
|
all_materials = (world.get("flexMaterials") or []) + (world.get("materials") or [])
|
|
|
|
|
|
mats = {str(m.get("code") or ""): m for m in all_materials if m.get("code")}
|
2026-07-28 02:12:46 +08:00
|
|
|
|
demand: dict[str, float] = {}
|
2026-08-20 11:39:21 +08:00
|
|
|
|
classic_by_id = {
|
|
|
|
|
|
m.get("id"): str(m.get("code") or "") for m in (world.get("materials") or [])
|
|
|
|
|
|
if isinstance(m.get("id"), int) and m.get("code")
|
|
|
|
|
|
}
|
|
|
|
|
|
flex_routes_by_code: dict[str, list[dict[str, Any]]] = {}
|
|
|
|
|
|
for row in world.get("flexRoutings") or []:
|
|
|
|
|
|
code = str(row.get("productCode") or "")
|
|
|
|
|
|
if code:
|
|
|
|
|
|
flex_routes_by_code.setdefault(code, []).append(row)
|
|
|
|
|
|
pcs_with_route = set(flex_routes_by_code)
|
|
|
|
|
|
pcs_with_route.update(
|
|
|
|
|
|
classic_by_id.get(r.get("productId"), "")
|
|
|
|
|
|
for r in (world.get("routings") or []) if r.get("isDefault")
|
|
|
|
|
|
)
|
|
|
|
|
|
pcs_with_route.discard("")
|
|
|
|
|
|
template_route_codes = {
|
|
|
|
|
|
code for code, rows in flex_routes_by_code.items()
|
|
|
|
|
|
if rows and all(
|
|
|
|
|
|
str(row.get("stdTimeSource") or "").strip().upper()
|
|
|
|
|
|
in ("模板", "TEMPLATE", "DEFAULT")
|
|
|
|
|
|
for row in rows
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
bom_parent_codes = {
|
|
|
|
|
|
str(b.get("productCode") or "") for b in (world.get("flexBom") or [])
|
|
|
|
|
|
if b.get("productCode")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def _routing_quality(product_code: str) -> str:
|
|
|
|
|
|
if product_code not in pcs_with_route:
|
|
|
|
|
|
return "MISSING"
|
|
|
|
|
|
if product_code in template_route_codes:
|
|
|
|
|
|
return "TEMPLATE"
|
|
|
|
|
|
return "READY"
|
|
|
|
|
|
|
|
|
|
|
|
def _record_make(
|
|
|
|
|
|
*, order_no: str, product_code: str, product_name: str,
|
|
|
|
|
|
quantity: float, unit: str, depth: int, routing_quality: str,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
key = (order_no, product_code)
|
|
|
|
|
|
existing = make_agg.get(key)
|
|
|
|
|
|
if existing:
|
|
|
|
|
|
existing["quantity"] += quantity
|
|
|
|
|
|
existing["bomDepth"] = min(int(existing.get("bomDepth") or depth), depth)
|
|
|
|
|
|
return
|
|
|
|
|
|
has_routing = routing_quality != "MISSING"
|
|
|
|
|
|
missing_routing = routing_quality == "MISSING"
|
|
|
|
|
|
template_routing = routing_quality == "TEMPLATE"
|
|
|
|
|
|
row = {
|
|
|
|
|
|
"id": f"MAKE-PREV:{order_no}:{product_code}",
|
|
|
|
|
|
"salesOrderNo": order_no,
|
|
|
|
|
|
"productCode": product_code,
|
|
|
|
|
|
"productName": product_name or product_code,
|
|
|
|
|
|
"quantity": quantity,
|
|
|
|
|
|
"unit": unit or "件",
|
|
|
|
|
|
"status": "DRAFT",
|
|
|
|
|
|
"sourcingType": "MAKE",
|
|
|
|
|
|
"hasRouting": has_routing,
|
|
|
|
|
|
"missingRouting": missing_routing,
|
|
|
|
|
|
"templateRouting": template_routing,
|
|
|
|
|
|
"routingStatus": routing_quality,
|
|
|
|
|
|
"bomDepth": depth,
|
|
|
|
|
|
"note": (
|
|
|
|
|
|
"半成品有 BOM,按自制继续分解;缺工艺路线,补齐后才能排产"
|
|
|
|
|
|
if missing_routing else (
|
|
|
|
|
|
"当前仅有模板工艺,允许试排但节拍/工序必须确认"
|
|
|
|
|
|
if template_routing else "自制项 · 由排产引擎生成生产订单与工单"
|
|
|
|
|
|
)
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
make_rows.append(row)
|
|
|
|
|
|
make_agg[key] = row
|
2026-07-28 02:12:46 +08:00
|
|
|
|
|
|
|
|
|
|
for fo in world.get("flexOrders") or []:
|
|
|
|
|
|
if (fo.get("status") or "") in ("DONE", "CANCELLED", "COMPLETED"):
|
|
|
|
|
|
continue
|
2026-08-20 11:39:21 +08:00
|
|
|
|
pc = str(fo.get("productCode") or "")
|
2026-07-28 02:12:46 +08:00
|
|
|
|
qty = float(fo.get("quantity") or 0)
|
|
|
|
|
|
if not pc or qty <= 0:
|
|
|
|
|
|
continue
|
2026-08-20 11:39:21 +08:00
|
|
|
|
order_no = str(fo.get("orderNo") or "")
|
|
|
|
|
|
_record_make(
|
|
|
|
|
|
order_no=order_no,
|
|
|
|
|
|
product_code=pc,
|
|
|
|
|
|
product_name=str(fo.get("productName") or pc),
|
|
|
|
|
|
quantity=qty,
|
|
|
|
|
|
unit=str(fo.get("unit") or "件"),
|
|
|
|
|
|
depth=0,
|
|
|
|
|
|
routing_quality=_routing_quality(pc),
|
|
|
|
|
|
)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
for r in world.get("flexRoutings") or []:
|
|
|
|
|
|
if r.get("productCode") != pc or not r.get("isExternal"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
outsource_rows.append({
|
|
|
|
|
|
"id": -(len(outsource_rows) + 1),
|
|
|
|
|
|
"orderNo": f"OUT-PREV-{len(outsource_rows)+1:04d}",
|
|
|
|
|
|
"status": "DRAFT",
|
2026-08-20 11:39:21 +08:00
|
|
|
|
"salesOrderNo": order_no,
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"productName": fo.get("productName") or pc,
|
|
|
|
|
|
"operationName": r.get("operationName") or r.get("operationCode"),
|
|
|
|
|
|
"sequenceNo": r.get("seq"),
|
|
|
|
|
|
"quantity": qty, "unit": "件",
|
|
|
|
|
|
"requiredDate": fo.get("dueDate") or "",
|
|
|
|
|
|
"note": "WZ*/外协工序预览,点「重新分解」落正式建议",
|
|
|
|
|
|
})
|
|
|
|
|
|
stack = [(pc, qty, 0)]
|
|
|
|
|
|
seen_path: set[str] = set()
|
|
|
|
|
|
while stack:
|
|
|
|
|
|
cur, need, depth = stack.pop()
|
2026-08-20 11:39:21 +08:00
|
|
|
|
if depth >= _MAX_BOM_DEPTH or cur in seen_path:
|
2026-07-28 02:12:46 +08:00
|
|
|
|
continue
|
|
|
|
|
|
seen_path.add(cur)
|
|
|
|
|
|
for b in world.get("flexBom") or []:
|
|
|
|
|
|
if b.get("productCode") != cur:
|
|
|
|
|
|
continue
|
2026-08-20 11:39:21 +08:00
|
|
|
|
mc = str(b.get("materialCode") or "")
|
2026-07-28 02:12:46 +08:00
|
|
|
|
if not mc:
|
|
|
|
|
|
continue
|
|
|
|
|
|
child_need = float(b.get("quantity") or 0) * need
|
2026-08-20 11:39:21 +08:00
|
|
|
|
if child_need <= 0:
|
|
|
|
|
|
continue
|
2026-07-28 02:12:46 +08:00
|
|
|
|
mat = mats.get(mc) or {}
|
2026-08-20 11:39:21 +08:00
|
|
|
|
routing_quality = _routing_quality(mc)
|
|
|
|
|
|
has_rt = routing_quality != "MISSING"
|
|
|
|
|
|
has_bom = mc in bom_parent_codes
|
|
|
|
|
|
sourcing = infer_material_sourcing(
|
|
|
|
|
|
mat, has_routing=has_rt, has_bom=has_bom,
|
|
|
|
|
|
)
|
|
|
|
|
|
if sourcing == "MAKE":
|
|
|
|
|
|
_record_make(
|
|
|
|
|
|
order_no=order_no,
|
|
|
|
|
|
product_code=mc,
|
|
|
|
|
|
product_name=str(mat.get("name") or mc),
|
|
|
|
|
|
quantity=child_need,
|
|
|
|
|
|
unit=str(mat.get("unit") or "件"),
|
|
|
|
|
|
depth=depth + 1,
|
|
|
|
|
|
routing_quality=routing_quality,
|
|
|
|
|
|
)
|
|
|
|
|
|
if has_bom:
|
|
|
|
|
|
stack.append((mc, child_need, depth + 1))
|
|
|
|
|
|
continue
|
|
|
|
|
|
demand[mc] = demand.get(mc, 0) + child_need
|
2026-07-28 02:12:46 +08:00
|
|
|
|
seen_path.discard(cur)
|
|
|
|
|
|
|
|
|
|
|
|
nid = -1
|
|
|
|
|
|
for mc, need in sorted(demand.items()):
|
|
|
|
|
|
mat = mats.get(mc) or {}
|
|
|
|
|
|
have = float(mat.get("stock") or 0) + float(mat.get("inTransit") or 0)
|
|
|
|
|
|
gap = need - have
|
|
|
|
|
|
if gap <= 0:
|
|
|
|
|
|
continue
|
|
|
|
|
|
lead = float(mat.get("procurementLeadTime") or 0)
|
|
|
|
|
|
purchase_rows.append({
|
|
|
|
|
|
"id": nid, "orderNo": f"PR-PREV-{abs(nid):04d}",
|
|
|
|
|
|
"status": "DRAFT", "materialCode": mc,
|
|
|
|
|
|
"materialName": mat.get("name") or mc,
|
|
|
|
|
|
"quantity": math.ceil(gap), "unit": mat.get("unit") or "件",
|
|
|
|
|
|
"salesOrderNo": "(多单汇总)",
|
|
|
|
|
|
"suggestedOrderDate": "", "requiredDate": "",
|
|
|
|
|
|
"leadTimeDays": lead,
|
2026-08-20 11:39:21 +08:00
|
|
|
|
"note": "多层 BOM 原材料缺口预览,点「重新分解」可落正式建议",
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"sourcingType": "BUY",
|
|
|
|
|
|
})
|
|
|
|
|
|
nid -= 1
|
|
|
|
|
|
return make_rows, purchase_rows, outsource_rows
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 11:05:57 +08:00
|
|
|
|
def _target_orders(world: World, order_no: str | None) -> list[dict[str, Any]]:
|
2026-07-28 02:12:46 +08:00
|
|
|
|
from server.aps_domain.orders import SCHEDULABLE_STATUSES, sync_flex_orders_to_sales
|
|
|
|
|
|
if world.get("flexOrders"):
|
|
|
|
|
|
sync_flex_orders_to_sales(world)
|
|
|
|
|
|
orders = [so for so in world.get("salesOrders") or [] if so["status"] in SCHEDULABLE_STATUSES]
|
2026-07-21 11:05:57 +08:00
|
|
|
|
if order_no:
|
2026-07-28 02:12:46 +08:00
|
|
|
|
orders = [so for so in world.get("salesOrders") or []
|
2026-07-23 13:38:43 +08:00
|
|
|
|
if so["orderNo"].lower() == order_no.lower()
|
|
|
|
|
|
and so["status"] not in ("CANCELLED", "COMPLETED")]
|
2026-07-21 11:05:57 +08:00
|
|
|
|
if not orders:
|
|
|
|
|
|
raise ValueError(f"没找到可分解的订单:{order_no}")
|
|
|
|
|
|
return orders
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
def _mat_by_id(world: World) -> dict[int, dict]:
|
|
|
|
|
|
return {m["id"]: m for m in world.get("materials") or [] if isinstance(m.get("id"), int)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ops_by_id(world: World) -> dict[int, dict]:
|
|
|
|
|
|
return {o["id"]: o for o in world.get("operations") or [] if isinstance(o.get("id"), int)}
|
|
|
|
|
|
|
2026-07-21 11:05:57 +08:00
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
def _product_has_routing(world: World, product_id: int) -> bool:
|
|
|
|
|
|
return any(r.get("productId") == product_id and r.get("isDefault")
|
|
|
|
|
|
for r in world.get("routings") or [])
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-20 11:39:21 +08:00
|
|
|
|
def _product_routing_quality(world: World, product_id: int, product_code: str = "") -> str:
|
|
|
|
|
|
"""返回 READY | TEMPLATE | MISSING;模板路线可试排但必须向用户标明待确认。"""
|
|
|
|
|
|
has_routing = _product_has_routing(world, product_id)
|
|
|
|
|
|
code = str(product_code or "")
|
|
|
|
|
|
flex_rows = [
|
|
|
|
|
|
row for row in (world.get("flexRoutings") or [])
|
|
|
|
|
|
if code and str(row.get("productCode") or "") == code
|
|
|
|
|
|
]
|
|
|
|
|
|
if not has_routing and not flex_rows:
|
|
|
|
|
|
return "MISSING"
|
|
|
|
|
|
if flex_rows and all(
|
|
|
|
|
|
str(row.get("stdTimeSource") or "").strip().upper() in ("模板", "TEMPLATE", "DEFAULT")
|
|
|
|
|
|
for row in flex_rows
|
|
|
|
|
|
):
|
|
|
|
|
|
return "TEMPLATE"
|
|
|
|
|
|
return "READY"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
def _append_outsource_for_product(
|
|
|
|
|
|
world: World,
|
|
|
|
|
|
next_id,
|
|
|
|
|
|
so: dict,
|
|
|
|
|
|
product_id: int,
|
|
|
|
|
|
product_name: str,
|
|
|
|
|
|
qty: float,
|
|
|
|
|
|
unit: str,
|
|
|
|
|
|
outsource_rows: list,
|
|
|
|
|
|
seen_keys: set[tuple],
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
ops = _ops_by_id(world)
|
|
|
|
|
|
for step in find_routing_steps(world, product_id):
|
|
|
|
|
|
op = ops.get(step.get("operationId")) or {}
|
|
|
|
|
|
if not is_outsource_step(step, op):
|
|
|
|
|
|
continue
|
|
|
|
|
|
key = (so["id"], product_id, step.get("operationId"), step.get("sequenceNo"))
|
|
|
|
|
|
if key in seen_keys:
|
|
|
|
|
|
continue
|
|
|
|
|
|
seen_keys.add(key)
|
|
|
|
|
|
oo_id = next_id("outsourceOrder")
|
|
|
|
|
|
row = {
|
|
|
|
|
|
"id": oo_id,
|
|
|
|
|
|
"orderNo": f"OUT{oo_id:05d}",
|
|
|
|
|
|
"salesOrderId": so["id"], "salesOrderNo": so["orderNo"],
|
|
|
|
|
|
"productId": product_id, "productName": product_name,
|
|
|
|
|
|
"operationId": step.get("operationId"),
|
|
|
|
|
|
"operationName": op.get("name") or f"OP#{step.get('operationId')}",
|
|
|
|
|
|
"operationCode": op.get("code") or "",
|
|
|
|
|
|
"sequenceNo": step.get("sequenceNo"),
|
|
|
|
|
|
"quantity": qty, "unit": unit,
|
|
|
|
|
|
"requiredDate": so.get("deliveryDate"),
|
|
|
|
|
|
"status": "DRAFT",
|
|
|
|
|
|
"sourcingType": "OUTSOURCE",
|
|
|
|
|
|
"createdAt": _now(),
|
|
|
|
|
|
}
|
|
|
|
|
|
world["outsourceOrders"].append(row)
|
|
|
|
|
|
outsource_rows.append(row)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _append_purchase(
|
|
|
|
|
|
world: World,
|
|
|
|
|
|
next_id,
|
|
|
|
|
|
so: dict,
|
|
|
|
|
|
mat: dict,
|
|
|
|
|
|
shortage: float,
|
|
|
|
|
|
is_key: bool,
|
|
|
|
|
|
purchase_rows: list,
|
|
|
|
|
|
purchase_agg: dict[tuple, dict],
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
key = (so["id"], mat["id"])
|
|
|
|
|
|
if key in purchase_agg:
|
|
|
|
|
|
purchase_agg[key]["quantity"] = math.ceil(
|
|
|
|
|
|
purchase_agg[key]["quantity"] + shortage
|
|
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
lead = mat.get("procurementLeadTime", 0) or 0
|
|
|
|
|
|
due = parse_dt(str(so.get("deliveryDate") or today_fallback()) + " 08:00")
|
|
|
|
|
|
suggest_date = fmt_date(add_minutes(due, -(float(lead) + _PURCHASE_BUFFER_DAYS) * 24 * 60))
|
|
|
|
|
|
po_id = next_id("purchaseOrder")
|
|
|
|
|
|
row = {
|
|
|
|
|
|
"id": po_id,
|
|
|
|
|
|
"orderNo": f"PUR{po_id:05d}",
|
|
|
|
|
|
"salesOrderId": so["id"], "salesOrderNo": so["orderNo"],
|
|
|
|
|
|
"materialId": mat["id"], "materialCode": mat.get("code"), "materialName": mat.get("name"),
|
|
|
|
|
|
"quantity": math.ceil(shortage), "unit": mat.get("unit") or "件",
|
|
|
|
|
|
"leadTimeDays": lead,
|
|
|
|
|
|
"suggestedOrderDate": suggest_date,
|
|
|
|
|
|
"requiredDate": so.get("deliveryDate"),
|
|
|
|
|
|
"isKeyMaterial": is_key,
|
|
|
|
|
|
"status": "DRAFT",
|
|
|
|
|
|
"sourcingType": "BUY",
|
|
|
|
|
|
"createdAt": _now(),
|
|
|
|
|
|
}
|
|
|
|
|
|
world["purchaseOrders"].append(row)
|
|
|
|
|
|
purchase_rows.append(row)
|
|
|
|
|
|
purchase_agg[key] = row
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def today_fallback() -> str:
|
|
|
|
|
|
return fmt_date(datetime.now())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def decompose_orders(world: World, next_id, order_no: str | None = None) -> dict[str, Any]:
|
2026-08-20 11:39:21 +08:00
|
|
|
|
"""按供应类型 + 多层 BOM 分解订单(P1:DRAFT 自制/采购/委外建议)。"""
|
2026-07-21 11:05:57 +08:00
|
|
|
|
_ensure_tables(world)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
annotate_world_sourcing(world)
|
2026-07-21 11:05:57 +08:00
|
|
|
|
targets = _target_orders(world, order_no)
|
|
|
|
|
|
target_ids = {so["id"] for so in targets}
|
|
|
|
|
|
|
2026-08-20 11:39:21 +08:00
|
|
|
|
world["makeSuggestions"] = [m for m in world["makeSuggestions"]
|
|
|
|
|
|
if not (m.get("salesOrderId") in target_ids and m.get("status") == "DRAFT")]
|
2026-07-21 11:05:57 +08:00
|
|
|
|
world["purchaseOrders"] = [p for p in world["purchaseOrders"]
|
2026-07-28 02:12:46 +08:00
|
|
|
|
if not (p.get("salesOrderId") in target_ids and p.get("status") == "DRAFT")]
|
2026-07-21 11:05:57 +08:00
|
|
|
|
world["outsourceOrders"] = [o for o in world["outsourceOrders"]
|
2026-07-28 02:12:46 +08:00
|
|
|
|
if not (o.get("salesOrderId") in target_ids and o.get("status") == "DRAFT")]
|
2026-07-21 11:05:57 +08:00
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
make_rows: list[dict[str, Any]] = []
|
|
|
|
|
|
purchase_rows: list[dict[str, Any]] = []
|
|
|
|
|
|
outsource_rows: list[dict[str, Any]] = []
|
|
|
|
|
|
mats = _mat_by_id(world)
|
2026-08-20 11:39:21 +08:00
|
|
|
|
bom_parent_ids = {
|
|
|
|
|
|
b.get("productId") for b in (world.get("boms") or []) if b.get("isDefault", True)
|
|
|
|
|
|
}
|
2026-07-28 02:12:46 +08:00
|
|
|
|
out_seen: set[tuple] = set()
|
2026-08-20 11:39:21 +08:00
|
|
|
|
make_agg: dict[tuple[int, int], dict[str, Any]] = {}
|
2026-07-28 02:12:46 +08:00
|
|
|
|
purchase_agg: dict[tuple, dict] = {}
|
2026-08-20 11:39:21 +08:00
|
|
|
|
purchase_demand: dict[tuple[int, int], dict[str, Any]] = {}
|
|
|
|
|
|
|
|
|
|
|
|
def _record_make(
|
|
|
|
|
|
so: dict[str, Any], *, product_id: int, product_code: str,
|
|
|
|
|
|
product_name: str, quantity: float, unit: str,
|
|
|
|
|
|
depth: int, routing_quality: str, note: str,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
key = (so["id"], product_id)
|
|
|
|
|
|
existing = make_agg.get(key)
|
|
|
|
|
|
if existing:
|
|
|
|
|
|
existing["quantity"] += quantity
|
|
|
|
|
|
existing["bomDepth"] = min(int(existing.get("bomDepth") or depth), depth)
|
|
|
|
|
|
return
|
|
|
|
|
|
has_routing = routing_quality != "MISSING"
|
|
|
|
|
|
missing_routing = routing_quality == "MISSING"
|
|
|
|
|
|
template_routing = routing_quality == "TEMPLATE"
|
|
|
|
|
|
row = {
|
|
|
|
|
|
"id": f"MAKE:{so['id']}:{product_id}",
|
|
|
|
|
|
"salesOrderId": so["id"],
|
|
|
|
|
|
"salesOrderNo": so["orderNo"],
|
|
|
|
|
|
"productId": product_id,
|
|
|
|
|
|
"productCode": product_code,
|
|
|
|
|
|
"productName": product_name or product_code or f"#{product_id}",
|
|
|
|
|
|
"quantity": quantity,
|
|
|
|
|
|
"unit": unit or "件",
|
|
|
|
|
|
"status": "DRAFT",
|
|
|
|
|
|
"sourcingType": "MAKE",
|
|
|
|
|
|
"hasRouting": has_routing,
|
|
|
|
|
|
"missingRouting": missing_routing,
|
|
|
|
|
|
"templateRouting": template_routing,
|
|
|
|
|
|
"routingStatus": routing_quality,
|
|
|
|
|
|
"bomDepth": depth,
|
|
|
|
|
|
"note": (
|
|
|
|
|
|
f"{note};缺工艺路线,补齐后才能排产"
|
|
|
|
|
|
if missing_routing else note
|
|
|
|
|
|
),
|
|
|
|
|
|
"createdAt": _now(),
|
|
|
|
|
|
}
|
|
|
|
|
|
make_rows.append(row)
|
|
|
|
|
|
make_agg[key] = row
|
2026-07-21 11:05:57 +08:00
|
|
|
|
|
|
|
|
|
|
for so in targets:
|
|
|
|
|
|
for item in so.get("items", []):
|
2026-07-28 02:12:46 +08:00
|
|
|
|
if item.get("status") in ("CANCELLED", "COMPLETED"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
qty = float(item.get("quantity") or 0)
|
|
|
|
|
|
if qty <= 0:
|
|
|
|
|
|
continue
|
|
|
|
|
|
pid = item.get("productId")
|
2026-08-20 11:39:21 +08:00
|
|
|
|
pname = str(item.get("productName") or "")
|
|
|
|
|
|
unit = str(item.get("unit") or "件")
|
2026-07-28 02:12:46 +08:00
|
|
|
|
if not isinstance(pid, int):
|
2026-07-21 11:05:57 +08:00
|
|
|
|
continue
|
2026-08-20 11:39:21 +08:00
|
|
|
|
product = mats.get(pid) or {}
|
|
|
|
|
|
product_code = str(item.get("productCode") or product.get("code") or "")
|
|
|
|
|
|
root_routing_quality = _product_routing_quality(world, pid, product_code)
|
|
|
|
|
|
has_root_routing = root_routing_quality != "MISSING"
|
|
|
|
|
|
|
|
|
|
|
|
# 根成品:自制建议;实际生产订单仍由排产引擎生成。
|
|
|
|
|
|
_record_make(
|
|
|
|
|
|
so,
|
|
|
|
|
|
product_id=pid,
|
|
|
|
|
|
product_code=product_code,
|
|
|
|
|
|
product_name=pname or str(product.get("name") or product_code),
|
|
|
|
|
|
quantity=qty,
|
|
|
|
|
|
unit=unit,
|
|
|
|
|
|
depth=0,
|
|
|
|
|
|
routing_quality=root_routing_quality,
|
|
|
|
|
|
note="成品自制 · 排产后生成生产订单与工单",
|
2026-07-28 02:12:46 +08:00
|
|
|
|
)
|
2026-08-20 11:39:21 +08:00
|
|
|
|
if has_root_routing:
|
|
|
|
|
|
_append_outsource_for_product(
|
|
|
|
|
|
world, next_id, so, pid, pname, qty, unit, outsource_rows, out_seen,
|
|
|
|
|
|
)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
|
2026-08-20 11:39:21 +08:00
|
|
|
|
# 多层 BOM:半成品即使暂缺工艺也继续下钻;只有 BUY 叶子生成采购建议。
|
2026-07-28 02:12:46 +08:00
|
|
|
|
stack: list[tuple[int, float, int]] = [(pid, qty, 0)]
|
|
|
|
|
|
path: set[int] = set()
|
|
|
|
|
|
while stack:
|
|
|
|
|
|
cur_id, cur_qty, depth = stack.pop()
|
|
|
|
|
|
if depth >= _MAX_BOM_DEPTH or cur_id in path:
|
2026-07-21 11:05:57 +08:00
|
|
|
|
continue
|
2026-07-28 02:12:46 +08:00
|
|
|
|
path.add(cur_id)
|
2026-08-20 11:39:21 +08:00
|
|
|
|
parent = mats.get(cur_id) or {}
|
|
|
|
|
|
parent_name = str(parent.get("name") or parent.get("code") or cur_id)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
for bi in find_bom_items(world, cur_id):
|
|
|
|
|
|
mid = bi.get("materialId")
|
|
|
|
|
|
mat = mats.get(mid) if isinstance(mid, int) else None
|
|
|
|
|
|
if not mat:
|
|
|
|
|
|
continue
|
|
|
|
|
|
child_qty = float(bi.get("quantity") or 0) * cur_qty
|
|
|
|
|
|
if child_qty <= 0:
|
|
|
|
|
|
continue
|
2026-08-20 11:39:21 +08:00
|
|
|
|
product_code = str(mat.get("code") or "")
|
|
|
|
|
|
routing_quality = _product_routing_quality(world, mid, product_code)
|
|
|
|
|
|
has_rt = routing_quality != "MISSING"
|
|
|
|
|
|
has_bom = mid in bom_parent_ids
|
|
|
|
|
|
sourcing = infer_material_sourcing(
|
|
|
|
|
|
mat, has_routing=has_rt, has_bom=has_bom,
|
|
|
|
|
|
)
|
|
|
|
|
|
if sourcing == "MAKE":
|
|
|
|
|
|
_record_make(
|
|
|
|
|
|
so,
|
|
|
|
|
|
product_id=mid,
|
|
|
|
|
|
product_code=product_code,
|
|
|
|
|
|
product_name=str(mat.get("name") or product_code),
|
|
|
|
|
|
quantity=child_qty,
|
|
|
|
|
|
unit=str(mat.get("unit") or "件"),
|
|
|
|
|
|
depth=depth + 1,
|
|
|
|
|
|
routing_quality=routing_quality,
|
|
|
|
|
|
note=f"半成品自制(来自 {parent_name} BOM)",
|
2026-07-28 02:12:46 +08:00
|
|
|
|
)
|
2026-08-20 11:39:21 +08:00
|
|
|
|
if has_rt:
|
|
|
|
|
|
_append_outsource_for_product(
|
|
|
|
|
|
world, next_id, so, mid,
|
|
|
|
|
|
str(mat.get("name") or product_code),
|
|
|
|
|
|
child_qty, str(mat.get("unit") or "件"),
|
|
|
|
|
|
outsource_rows, out_seen,
|
|
|
|
|
|
)
|
|
|
|
|
|
if has_bom:
|
|
|
|
|
|
stack.append((mid, child_qty, depth + 1))
|
2026-07-28 02:12:46 +08:00
|
|
|
|
continue
|
2026-08-20 11:39:21 +08:00
|
|
|
|
|
|
|
|
|
|
key = (so["id"], mid)
|
|
|
|
|
|
demand = purchase_demand.get(key)
|
|
|
|
|
|
if demand:
|
|
|
|
|
|
demand["quantity"] += child_qty
|
|
|
|
|
|
demand["isKeyMaterial"] = bool(demand["isKeyMaterial"] or bi.get("isKeyMaterial"))
|
|
|
|
|
|
else:
|
|
|
|
|
|
purchase_demand[key] = {
|
|
|
|
|
|
"so": so,
|
|
|
|
|
|
"mat": mat,
|
|
|
|
|
|
"quantity": child_qty,
|
|
|
|
|
|
"isKeyMaterial": bool(bi.get("isKeyMaterial")),
|
|
|
|
|
|
}
|
2026-07-28 02:12:46 +08:00
|
|
|
|
path.discard(cur_id)
|
2026-07-21 11:05:57 +08:00
|
|
|
|
|
2026-08-20 11:39:21 +08:00
|
|
|
|
# 同一销售单/物料先汇总毛需求,再只扣一次库存与在途。
|
|
|
|
|
|
# 已下达(非草稿)的采购单视作已覆盖供应:重跑分解/排产前置净算时
|
|
|
|
|
|
# 不再为同一需求重复生成 DRAFT 建议,避免闭环把重复草稿判为
|
|
|
|
|
|
# UNTRUSTED_DRAFT_SUPPLY 硬阻断。
|
|
|
|
|
|
covered_supply: dict[tuple, float] = {}
|
|
|
|
|
|
for row in world["purchaseOrders"]:
|
|
|
|
|
|
if not isinstance(row, dict) or row.get("status") == "DRAFT":
|
|
|
|
|
|
continue
|
|
|
|
|
|
ckey = (row.get("salesOrderId"), row.get("materialId"))
|
|
|
|
|
|
covered_supply[ckey] = covered_supply.get(ckey, 0.0) + float(row.get("quantity") or 0)
|
|
|
|
|
|
for demand in purchase_demand.values():
|
|
|
|
|
|
mat = demand["mat"]
|
|
|
|
|
|
ckey = (demand["so"]["id"], mat.get("id"))
|
|
|
|
|
|
shortage = (
|
|
|
|
|
|
float(demand["quantity"])
|
|
|
|
|
|
- float(mat.get("stock") or 0)
|
|
|
|
|
|
- float(mat.get("inTransit") or 0)
|
|
|
|
|
|
- covered_supply.get(ckey, 0.0)
|
|
|
|
|
|
)
|
|
|
|
|
|
if shortage <= 0:
|
|
|
|
|
|
continue
|
|
|
|
|
|
_append_purchase(
|
|
|
|
|
|
world, next_id, demand["so"], mat, shortage,
|
|
|
|
|
|
bool(demand["isKeyMaterial"]), purchase_rows, purchase_agg,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
world["makeSuggestions"].extend(make_rows)
|
2026-07-21 11:05:57 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"orders": [so["orderNo"] for so in targets],
|
|
|
|
|
|
"make": make_rows,
|
|
|
|
|
|
"purchase": purchase_rows,
|
|
|
|
|
|
"outsource": outsource_rows,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 13:38:43 +08:00
|
|
|
|
def _release_targets(world: World, order_no: str | None, kind: str) -> tuple[list[dict], list[dict]]:
|
|
|
|
|
|
_ensure_tables(world)
|
|
|
|
|
|
so_ids = None
|
|
|
|
|
|
if order_no:
|
|
|
|
|
|
so = next((s for s in world["salesOrders"] if s["orderNo"].lower() == order_no.lower()), None)
|
|
|
|
|
|
if so is None:
|
|
|
|
|
|
raise ValueError(f"没找到订单:{order_no}")
|
|
|
|
|
|
so_ids = {so["id"]}
|
|
|
|
|
|
|
|
|
|
|
|
def _match(row: dict) -> bool:
|
|
|
|
|
|
return row["status"] == "DRAFT" and (so_ids is None or row["salesOrderId"] in so_ids)
|
|
|
|
|
|
|
|
|
|
|
|
pur = [p for p in world["purchaseOrders"] if _match(p)] if kind in ("all", "purchase") else []
|
|
|
|
|
|
out = [o for o in world["outsourceOrders"] if _match(o)] if kind in ("all", "outsource") else []
|
|
|
|
|
|
return pur, out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def confirmation_for_mrp_release(world: World, order_no: str | None, kind: str = "all") -> tuple[str, list[str]]:
|
|
|
|
|
|
pur, out = _release_targets(world, order_no, kind)
|
|
|
|
|
|
if not pur and not out:
|
|
|
|
|
|
raise ValueError("没有可下达的草稿建议单(请先执行订单分解)")
|
|
|
|
|
|
scope = f"订单 {order_no}" if order_no else "全部订单"
|
|
|
|
|
|
title = f"下达 MRP 建议单({scope})"
|
|
|
|
|
|
lines = []
|
|
|
|
|
|
if pur:
|
|
|
|
|
|
lines.append(f"采购建议 {len(pur)} 条 → 转为正式采购单(RELEASED)")
|
|
|
|
|
|
if out:
|
|
|
|
|
|
lines.append(f"委外建议 {len(out)} 条 → 转为正式委外单(RELEASED)")
|
|
|
|
|
|
lines.append("下达后建议单状态置为 RELEASED,进入采购/委外执行(P2 写主干,执行前自动建档)")
|
|
|
|
|
|
return title, lines
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def apply_mrp_release(world: World, order_no: str | None = None, kind: str = "all") -> dict[str, Any]:
|
|
|
|
|
|
pur, out = _release_targets(world, order_no, kind)
|
|
|
|
|
|
released_at = _now()
|
2026-08-20 11:39:21 +08:00
|
|
|
|
release_day = released_at[:10]
|
2026-07-23 13:38:43 +08:00
|
|
|
|
for row in pur + out:
|
|
|
|
|
|
row["status"] = "RELEASED"
|
|
|
|
|
|
row["releasedAt"] = released_at
|
2026-08-20 11:39:21 +08:00
|
|
|
|
# 闭环净算按预计到料日判断可信供应;草稿只有 requiredDate(=订单交期),
|
|
|
|
|
|
# 不补到料日会被判为 MATERIAL_NOT_READY 硬违规。到料 = 下达日 + 采购前置期。
|
|
|
|
|
|
if not row.get("expectedArrivalDate"):
|
|
|
|
|
|
lead = float(row.get("leadTimeDays") or 0)
|
|
|
|
|
|
row["expectedArrivalDate"] = fmt_date(
|
|
|
|
|
|
add_minutes(parse_dt(release_day + " 08:00"), lead * 24 * 60)
|
|
|
|
|
|
)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
return {"purchase": pur, "outsource": out,
|
|
|
|
|
|
"purchaseCount": len(pur), "outsourceCount": len(out)}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 11:05:57 +08:00
|
|
|
|
def summarize_decomposition(result: dict[str, Any]) -> str:
|
|
|
|
|
|
lines = [f"已分解 {len(result['orders'])} 张订单({ '、'.join(result['orders'][:5]) }"
|
|
|
|
|
|
+ ("…" if len(result["orders"]) > 5 else "") + "):"]
|
2026-08-20 11:39:21 +08:00
|
|
|
|
ready_make = sum(1 for row in result["make"] if row.get("routingStatus") == "READY")
|
|
|
|
|
|
template_routing = sum(1 for row in result["make"] if row.get("templateRouting"))
|
|
|
|
|
|
missing_routing = sum(1 for row in result["make"] if row.get("missingRouting"))
|
|
|
|
|
|
details: list[str] = []
|
|
|
|
|
|
if ready_make:
|
|
|
|
|
|
details.append(f"{ready_make} 项正式工艺")
|
|
|
|
|
|
if template_routing:
|
|
|
|
|
|
details.append(f"{template_routing} 项模板工艺待确认")
|
|
|
|
|
|
if missing_routing:
|
|
|
|
|
|
details.append(f"{missing_routing} 项缺工艺路线但仍按半成品自制")
|
|
|
|
|
|
lines.append(
|
|
|
|
|
|
f"· 自制建议:{len(result['make'])} 项({';'.join(details) or '待补工艺'})→ 排产后生成真实生产订单"
|
|
|
|
|
|
)
|
2026-07-21 11:05:57 +08:00
|
|
|
|
if result["purchase"]:
|
|
|
|
|
|
top = "、".join(f"{p['materialName']}×{p['quantity']}" for p in result["purchase"][:4])
|
2026-08-20 11:39:21 +08:00
|
|
|
|
lines.append(f"· 采购建议:{len(result['purchase'])} 条原材料({top}"
|
2026-07-28 02:12:46 +08:00
|
|
|
|
+ ("…" if len(result['purchase']) > 4 else "") + "),按前置期倒排")
|
2026-07-21 11:05:57 +08:00
|
|
|
|
else:
|
2026-08-20 11:39:21 +08:00
|
|
|
|
lines.append("· 采购建议:0 条(库存与在途可覆盖,或 BOM 未挂采购原料)")
|
2026-07-21 11:05:57 +08:00
|
|
|
|
if result["outsource"]:
|
|
|
|
|
|
top = "、".join(f"{o['productName']}·{o['operationName']}" for o in result["outsource"][:3])
|
|
|
|
|
|
lines.append(f"· 委外建议:{len(result['outsource'])} 条({top}"
|
2026-07-28 02:12:46 +08:00
|
|
|
|
+ ("…" if len(result['outsource']) > 3 else "") + "),来自 WZ*/外协工序")
|
2026-07-21 11:05:57 +08:00
|
|
|
|
else:
|
2026-07-28 02:12:46 +08:00
|
|
|
|
lines.append("· 委外建议:0 条(工艺无 WZ*/外协步骤)")
|
2026-08-20 11:39:21 +08:00
|
|
|
|
lines.append("订单面板分别展示自制建议、采购建议、委外建议;完成排产后再查看已排生产订单。")
|
2026-07-21 11:05:57 +08:00
|
|
|
|
return "\n".join(lines)
|