aps-agent/server/aps_domain/mrp.py

663 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 订单分解 MRP(moduleId: domain-mrp, 可重生 ✅)
# 对标西门子 Opcenter:销售订单 → 沿工艺路线 + BOM 多层展开 →
# 自制(MAKE,进排产)/ 采购(BUY,原料毛坯)/ 委外(OUTSOURCE,WZ*/外协工序)。
# 权力:order.decompose = P1,只写 DRAFT 建议表;下达另走 P2。
# ============================================================
from __future__ import annotations
import math
from datetime import datetime
from typing import Any
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
World = dict[str, Any]
_PURCHASE_BUFFER_DAYS = 1
_MAX_BOM_DEPTH = 10
def _now() -> str:
return fmt_dt(datetime.now())
def _ensure_tables(world: World) -> None:
world.setdefault("makeSuggestions", [])
world.setdefault("purchaseOrders", [])
world.setdefault("outsourceOrders", [])
def list_mrp(world: World) -> dict[str, Any]:
"""MRP 自制/采购/委外建议与已排生产订单投影(P0 只读)。"""
_ensure_tables(world)
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)
make = list(world["makeSuggestions"])
purchase_suggestions = world.get("purchaseSuggestions")
outsource_suggestions = world.get("outsourceSuggestions")
purchases = (
list(purchase_suggestions)
if isinstance(purchase_suggestions, list)
else list(world["purchaseOrders"])
)
outs = (
list(outsource_suggestions)
if isinstance(outsource_suggestions, list)
else list(world["outsourceOrders"])
)
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
return {
"make": make,
"purchaseOrders": purchases,
"outsourceOrders": outs,
"productionOrders": _list_flex_make(world),
}
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]:
"""柔性 BOM/工艺只读预览(含多层自制、路线质量与委外推断)。"""
_ensure_tables(world)
annotate_world_sourcing(world)
make_rows: list[dict[str, Any]] = []
make_agg: dict[tuple[str, str], dict[str, Any]] = {}
purchase_rows: list[dict[str, Any]] = []
outsource_rows: list[dict[str, Any]] = []
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")}
demand: dict[str, float] = {}
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
for fo in world.get("flexOrders") or []:
if (fo.get("status") or "") in ("DONE", "CANCELLED", "COMPLETED"):
continue
pc = str(fo.get("productCode") or "")
qty = float(fo.get("quantity") or 0)
if not pc or qty <= 0:
continue
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),
)
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",
"salesOrderNo": order_no,
"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()
if depth >= _MAX_BOM_DEPTH or cur in seen_path:
continue
seen_path.add(cur)
for b in world.get("flexBom") or []:
if b.get("productCode") != cur:
continue
mc = str(b.get("materialCode") or "")
if not mc:
continue
child_need = float(b.get("quantity") or 0) * need
if child_need <= 0:
continue
mat = mats.get(mc) or {}
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
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,
"note": "多层 BOM 原材料缺口预览,点「重新分解」可落正式建议",
"sourcingType": "BUY",
})
nid -= 1
return make_rows, purchase_rows, outsource_rows
def _target_orders(world: World, order_no: str | None) -> list[dict[str, Any]]:
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]
if order_no:
orders = [so for so in world.get("salesOrders") or []
if so["orderNo"].lower() == order_no.lower()
and so["status"] not in ("CANCELLED", "COMPLETED")]
if not orders:
raise ValueError(f"没找到可分解的订单:{order_no}")
return orders
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)}
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 [])
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"
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]:
"""按供应类型 + 多层 BOM 分解订单(P1:DRAFT 自制/采购/委外建议)。"""
_ensure_tables(world)
annotate_world_sourcing(world)
targets = _target_orders(world, order_no)
target_ids = {so["id"] for so in targets}
world["makeSuggestions"] = [m for m in world["makeSuggestions"]
if not (m.get("salesOrderId") in target_ids and m.get("status") == "DRAFT")]
world["purchaseOrders"] = [p for p in world["purchaseOrders"]
if not (p.get("salesOrderId") in target_ids and p.get("status") == "DRAFT")]
world["outsourceOrders"] = [o for o in world["outsourceOrders"]
if not (o.get("salesOrderId") in target_ids and o.get("status") == "DRAFT")]
make_rows: list[dict[str, Any]] = []
purchase_rows: list[dict[str, Any]] = []
outsource_rows: list[dict[str, Any]] = []
mats = _mat_by_id(world)
bom_parent_ids = {
b.get("productId") for b in (world.get("boms") or []) if b.get("isDefault", True)
}
out_seen: set[tuple] = set()
make_agg: dict[tuple[int, int], dict[str, Any]] = {}
purchase_agg: dict[tuple, dict] = {}
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
for so in targets:
for item in so.get("items", []):
if item.get("status") in ("CANCELLED", "COMPLETED"):
continue
qty = float(item.get("quantity") or 0)
if qty <= 0:
continue
pid = item.get("productId")
pname = str(item.get("productName") or "")
unit = str(item.get("unit") or "件")
if not isinstance(pid, int):
continue
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="成品自制 · 排产后生成生产订单与工单",
)
if has_root_routing:
_append_outsource_for_product(
world, next_id, so, pid, pname, qty, unit, outsource_rows, out_seen,
)
# 多层 BOM:半成品即使暂缺工艺也继续下钻;只有 BUY 叶子生成采购建议。
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:
continue
path.add(cur_id)
parent = mats.get(cur_id) or {}
parent_name = str(parent.get("name") or parent.get("code") or cur_id)
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
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)",
)
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))
continue
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")),
}
path.discard(cur_id)
# 同一销售单/物料先汇总毛需求,再只扣一次库存与在途。
# 已下达(非草稿)的采购单视作已覆盖供应:重跑分解/排产前置净算时
# 不再为同一需求重复生成 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)
return {
"orders": [so["orderNo"] for so in targets],
"make": make_rows,
"purchase": purchase_rows,
"outsource": outsource_rows,
}
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()
release_day = released_at[:10]
for row in pur + out:
row["status"] = "RELEASED"
row["releasedAt"] = released_at
# 闭环净算按预计到料日判断可信供应;草稿只有 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)
)
return {"purchase": pur, "outsource": out,
"purchaseCount": len(pur), "outsourceCount": len(out)}
def summarize_decomposition(result: dict[str, Any]) -> str:
lines = [f"已分解 {len(result['orders'])} 张订单({ '、'.join(result['orders'][:5]) }"
+ ("…" if len(result["orders"]) > 5 else "") + "):"]
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 '待补工艺'})→ 排产后生成真实生产订单"
)
if result["purchase"]:
top = "、".join(f"{p['materialName']}×{p['quantity']}" for p in result["purchase"][:4])
lines.append(f"· 采购建议:{len(result['purchase'])} 条原材料({top}"
+ ("…" if len(result['purchase']) > 4 else "") + "),按前置期倒排")
else:
lines.append("· 采购建议:0 条(库存与在途可覆盖,或 BOM 未挂采购原料)")
if result["outsource"]:
top = "、".join(f"{o['productName']}·{o['operationName']}" for o in result["outsource"][:3])
lines.append(f"· 委外建议:{len(result['outsource'])} 条({top}"
+ ("…" if len(result['outsource']) > 3 else "") + "),来自 WZ*/外协工序")
else:
lines.append("· 委外建议:0 条(工艺无 WZ*/外协步骤)")
lines.append("订单面板分别展示自制建议、采购建议、委外建议;完成排产后再查看已排生产订单。")
return "\n".join(lines)