610 lines
39 KiB
Python
610 lines
39 KiB
Python
"""Closed-loop Scheduling Kernel v1: pure order-to-requirement/supply builder.
|
|
|
|
``build_closed_loop_problem`` reads a world mapping and emits a versioned
|
|
manufacturing requirement graph. It never creates or mutates PO/WO records.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from collections import Counter, defaultdict
|
|
from dataclasses import asdict, dataclass, field
|
|
from hashlib import sha256
|
|
import json
|
|
from typing import Any, Literal, Mapping, MutableMapping, Sequence
|
|
|
|
from server.aps_domain.supply_netting import (
|
|
DemandBalance, NettingRequirement, SupplyAllocation, SupplyCandidate,
|
|
SupplyEvent, SourcingType, net_supply_requirements, normalize_date,
|
|
normalize_quantity,
|
|
)
|
|
|
|
RoutingStatus = Literal["READY", "TEMPLATE", "MISSING", "NOT_APPLICABLE"]
|
|
ResourceStatus = Literal["READY", "MISSING", "NOT_APPLICABLE"]
|
|
Severity = Literal["HARD", "WARNING"]
|
|
_SCHEMA_VERSION = "closed-loop-scheduling/1.0"
|
|
_INACTIVE = {"CANCELLED", "CANCELED", "COMPLETED", "CLOSED", "REJECTED", "VOID"}
|
|
_TRUSTED_SUPPLY = {"APPROVED", "CONFIRMED", "RELEASED", "IN_PROGRESS", "PARTIAL", "OPEN", "RECEIVED", "COMPLETED"}
|
|
_DRAFT_SUPPLY = {"DRAFT", "SUGGESTED", "PROPOSED", "PENDING_APPROVAL"}
|
|
_TEMPLATE = {"模板", "TEMPLATE", "DEFAULT", "INFERRED", "RAG_CANDIDATE"}
|
|
_EXTERNAL = {"OUTSOURCE", "SUBCONTRACT", "EXTERNAL", "外协", "委外"}
|
|
_MAKE = {"MAKE", "PRODUCTION", "INTERNAL", "自制"}
|
|
_BUY = {"BUY", "PURCHASE", "采购"}
|
|
_RESOURCE_INACTIVE = {"DOWN", "FAULT", "MAINTENANCE", "INACTIVE", "DISABLED", "SCRAPPED"}
|
|
_RELEVANT_KEYS = (
|
|
"salesOrders", "materials", "flexMaterials", "boms", "bomItems", "flexBom",
|
|
"routings", "routingSteps", "routingOperations", "operations", "flexRoutings",
|
|
"workstations", "workstationOperations", "equipment", "flexEquipment",
|
|
"inventory", "inventories", "inventoryBalances", "inventoryItems", "stock",
|
|
"inTransit", "inTransitItems", "inventoryInTransit", "purchaseOrders", "outsourceOrders",
|
|
)
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class PeggingRef:
|
|
parent_requirement_id: str
|
|
quantity: float
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Blocker:
|
|
blocker_id: str
|
|
code: str
|
|
severity: Severity
|
|
entity_type: str
|
|
entity_id: str
|
|
message: str
|
|
evidence: Mapping[str, Any] = field(default_factory=dict)
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Requirement:
|
|
requirement_id: str
|
|
sales_order_id: str
|
|
sales_order_no: str
|
|
sales_order_line_id: str
|
|
material_id: str | int | None
|
|
material_key: str
|
|
material_code: str
|
|
material_name: str
|
|
quantity: float
|
|
unit: str
|
|
required_at: str
|
|
sourcing_type: SourcingType
|
|
bom_depth: int
|
|
parent_requirement_ids: tuple[str, ...]
|
|
child_requirement_ids: tuple[str, ...]
|
|
pegging_refs: tuple[PeggingRef, ...]
|
|
operation_code: str | None
|
|
routing_status: RoutingStatus
|
|
resource_status: ResourceStatus
|
|
blocker_codes: tuple[str, ...]
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ManufacturingDemand:
|
|
demand_id: str
|
|
requirement_id: str
|
|
sales_order_id: str
|
|
sales_order_no: str
|
|
sales_order_line_id: str
|
|
product_id: str | int | None
|
|
product_code: str
|
|
product_name: str
|
|
quantity: float
|
|
unit: str
|
|
required_at: str
|
|
bom_depth: int
|
|
parent_demand_ids: tuple[str, ...]
|
|
child_requirement_ids: tuple[str, ...]
|
|
routing_status: RoutingStatus
|
|
resource_status: ResourceStatus
|
|
material_status: str
|
|
release_status: str
|
|
blocker_codes: tuple[str, ...]
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ClosedLoopProblem:
|
|
schema_version: str
|
|
problem_id: str
|
|
source_revision: str
|
|
business_date: str
|
|
requirements: tuple[Requirement, ...]
|
|
manufacturing_demands: tuple[ManufacturingDemand, ...]
|
|
supply_events: tuple[SupplyEvent, ...]
|
|
demand_balances: tuple[DemandBalance, ...]
|
|
blockers: tuple[Blocker, ...]
|
|
stats: Mapping[str, Any]
|
|
|
|
@property
|
|
def problemId(self) -> str:
|
|
return self.problem_id
|
|
|
|
@property
|
|
def sourceHash(self) -> str:
|
|
return self.source_revision
|
|
|
|
@property
|
|
def manufacturingDemands(self) -> tuple[ManufacturingDemand, ...]:
|
|
return self.manufacturing_demands
|
|
|
|
@property
|
|
def supplyEvents(self) -> tuple[SupplyEvent, ...]:
|
|
return self.supply_events
|
|
|
|
@property
|
|
def summary(self) -> Mapping[str, Any]:
|
|
return self.stats
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"schemaVersion": self.schema_version,
|
|
"problemId": self.problem_id,
|
|
"sourceHash": self.source_revision,
|
|
"businessDate": self.business_date,
|
|
"requirements": [asdict(row) for row in self.requirements],
|
|
"manufacturingDemands": [asdict(row) for row in self.manufacturing_demands],
|
|
"supplyEvents": [asdict(row) for row in self.supply_events],
|
|
"demandBalances": [asdict(row) for row in self.demand_balances],
|
|
"blockers": [asdict(row) for row in self.blockers],
|
|
"summary": dict(self.stats),
|
|
}
|
|
|
|
@dataclass(slots=True)
|
|
class _RequirementDraft:
|
|
requirement_id: str
|
|
sales_order_id: str
|
|
sales_order_no: str
|
|
sales_order_line_id: str
|
|
material_id: str | int | None
|
|
material_key: str
|
|
material_code: str
|
|
material_name: str
|
|
quantity: float
|
|
unit: str
|
|
required_at: str
|
|
sourcing_type: SourcingType
|
|
bom_depth: int
|
|
operation_code: str | None = None
|
|
routing_status: RoutingStatus = "NOT_APPLICABLE"
|
|
resource_status: ResourceStatus = "NOT_APPLICABLE"
|
|
parent_quantities: MutableMapping[str, float] = field(default_factory=dict)
|
|
child_ids: set[str] = field(default_factory=set)
|
|
blocker_codes: set[str] = field(default_factory=set)
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _RouteAnalysis:
|
|
status: RoutingStatus
|
|
resource_status: ResourceStatus
|
|
missing_resource_codes: tuple[str, ...]
|
|
external_steps: tuple[Mapping[str, Any], ...]
|
|
|
|
def _stable_id(prefix: str, *parts: Any) -> str:
|
|
payload = json.dumps(parts, ensure_ascii=False, sort_keys=True, default=str, separators=(",", ":"))
|
|
return f"{prefix}-{sha256(payload.encode('utf-8')).hexdigest()[:20]}"
|
|
|
|
def _status(value: Any) -> str:
|
|
return str(value or "").strip().upper()
|
|
|
|
def _material_key(material: Mapping[str, Any] | None, fallback: Any = "") -> str:
|
|
material = material or {}
|
|
code = str(material.get("code") or material.get("materialCode") or fallback or "").strip()
|
|
if code:
|
|
return f"CODE:{code.upper()}"
|
|
mid = material.get("id") or material.get("materialId")
|
|
return f"ID:{mid}" if mid not in (None, "") else "UNKNOWN"
|
|
|
|
def _catalog(world: Mapping[str, Any]) -> tuple[dict[Any, dict[str, Any]], dict[str, dict[str, Any]]]:
|
|
by_id: dict[Any, dict[str, Any]] = {}
|
|
by_code: dict[str, dict[str, Any]] = {}
|
|
for table in ("flexMaterials", "materials"):
|
|
rows = world.get(table) or ()
|
|
if not isinstance(rows, Sequence) or isinstance(rows, (str, bytes, bytearray)):
|
|
continue
|
|
for source in rows:
|
|
if not isinstance(source, Mapping):
|
|
continue
|
|
row = dict(source)
|
|
code = str(row.get("code") or row.get("materialCode") or "").strip().upper()
|
|
if code and code in by_code:
|
|
by_code[code].update(row)
|
|
row = by_code[code]
|
|
elif code:
|
|
by_code[code] = row
|
|
mid = row.get("id") or row.get("materialId")
|
|
if mid not in (None, ""):
|
|
by_id[mid] = row
|
|
return by_id, by_code
|
|
|
|
def _resolve_material(by_id: Mapping[Any, Mapping[str, Any]], by_code: Mapping[str, Mapping[str, Any]], *, material_id: Any = None, material_code: Any = None, fallback: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
row = by_id.get(material_id) if material_id not in (None, "") else None
|
|
if row is None and material_code not in (None, ""):
|
|
row = by_code.get(str(material_code).strip().upper())
|
|
merged = dict(row or {})
|
|
if fallback:
|
|
for key, value in fallback.items():
|
|
if value not in (None, "") and merged.get(key) in (None, ""):
|
|
merged[key] = value
|
|
if material_id not in (None, "") and merged.get("id") in (None, ""):
|
|
merged["id"] = material_id
|
|
if material_code not in (None, "") and merged.get("code") in (None, ""):
|
|
merged["code"] = material_code
|
|
return merged
|
|
|
|
def _has_bom(world: Mapping[str, Any], material: Mapping[str, Any]) -> bool:
|
|
pid = material.get("id") or material.get("productId")
|
|
code = str(material.get("code") or material.get("productCode") or "").strip().upper()
|
|
return any(isinstance(row, Mapping) and row.get("productId") == pid and bool(row.get("isDefault", True)) for row in (world.get("boms") or ())) or bool(code and any(isinstance(row, Mapping) and str(row.get("productCode") or "").strip().upper() == code for row in (world.get("flexBom") or ())))
|
|
|
|
def _infer_sourcing(world: Mapping[str, Any], material: Mapping[str, Any]) -> SourcingType:
|
|
procurement = _status(material.get("procurementType"))
|
|
sourcing = _status(material.get("sourcingType"))
|
|
source = _status(material.get("sourcingTypeSource"))
|
|
explicit = procurement or (sourcing if source in {"EXPLICIT", "MANUAL", "IMPORT"} else "")
|
|
if explicit in _EXTERNAL: return "SUBCONTRACT"
|
|
if explicit in _BUY: return "BUY"
|
|
if explicit in _MAKE: return "MAKE"
|
|
mtype = _status(material.get("type"))
|
|
if _route_rows(world, material)[0] or _route_rows(world, material)[1] or _has_bom(world, material) or mtype in {"FINISHED_PRODUCT", "SEMI_FINISHED"}: return "MAKE"
|
|
if sourcing in _EXTERNAL or str(material.get("code") or "").upper().startswith("WZ"): return "SUBCONTRACT"
|
|
if sourcing in _MAKE: return "MAKE"
|
|
return "BUY"
|
|
def _bom_children(world: Mapping[str, Any], material: Mapping[str, Any], by_id: Mapping[Any, Mapping[str, Any]], by_code: Mapping[str, Mapping[str, Any]]) -> list[tuple[dict[str, Any], float, str]]:
|
|
pid = material.get("id") or material.get("productId")
|
|
boms = [row for row in (world.get("boms") or ()) if isinstance(row, Mapping) and pid not in (None, "") and row.get("productId") == pid and bool(row.get("isDefault", True))]
|
|
if boms:
|
|
boms.sort(key=lambda row: (0 if _status(row.get("status")) in {"ACTIVE", "APPROVED"} else 1, str(row.get("id"))))
|
|
bom_id = boms[0].get("id")
|
|
result = []
|
|
for item in world.get("bomItems") or ():
|
|
if not isinstance(item, Mapping) or item.get("bomId") != bom_id: continue
|
|
child = _resolve_material(by_id, by_code, material_id=item.get("materialId"), material_code=item.get("materialCode"))
|
|
result.append((child, normalize_quantity(item.get("quantity")), str(item.get("id") or "")))
|
|
return result
|
|
code = str(material.get("code") or material.get("productCode") or "").strip().upper()
|
|
result = []
|
|
for item in world.get("flexBom") or ():
|
|
if not isinstance(item, Mapping) or str(item.get("productCode") or "").strip().upper() != code: continue
|
|
child = _resolve_material(by_id, by_code, material_id=item.get("materialId"), material_code=item.get("materialCode"))
|
|
result.append((child, normalize_quantity(item.get("quantity")), str(item.get("id") or "")))
|
|
return result
|
|
|
|
def _route_rows(world: Mapping[str, Any], material: Mapping[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
pid = material.get("id") or material.get("productId")
|
|
code = str(material.get("code") or material.get("productCode") or "").strip().upper()
|
|
operations = {row.get("id"): row for row in (world.get("operations") or ()) if isinstance(row, Mapping)}
|
|
routes = [row for row in (world.get("routings") or ()) if isinstance(row, Mapping) and pid not in (None, "") and row.get("productId") == pid and bool(row.get("isDefault", True))]
|
|
fixed: list[dict[str, Any]] = []
|
|
if routes:
|
|
routes.sort(key=lambda row: (0 if _status(row.get("status")) in {"ACTIVE", "APPROVED"} else 1, str(row.get("id"))))
|
|
routing_id = routes[0].get("id")
|
|
for step in list(world.get("routingSteps") or ()) + list(world.get("routingOperations") or ()):
|
|
if not isinstance(step, Mapping) or step.get("routingId") != routing_id: continue
|
|
op = operations.get(step.get("operationId")) or {}
|
|
normalized = dict(step)
|
|
normalized.setdefault("operationCode", op.get("code"))
|
|
normalized.setdefault("operationName", op.get("name"))
|
|
normalized.setdefault("operationType", op.get("type"))
|
|
normalized.setdefault("sourcingType", op.get("sourcingType"))
|
|
fixed.append(normalized)
|
|
fixed.sort(key=lambda row: (int(row.get("sequenceNo") or row.get("seq") or 0), str(row.get("id") or "")))
|
|
flex = [dict(row) for row in (world.get("flexRoutings") or ()) if isinstance(row, Mapping) and code and str(row.get("productCode") or "").strip().upper() == code]
|
|
flex.sort(key=lambda row: (int(row.get("seq") or row.get("sequenceNo") or 0), str(row.get("id") or "")))
|
|
return fixed, flex
|
|
|
|
def _is_external_step(step: Mapping[str, Any]) -> bool:
|
|
if "isExternal" in step: return bool(step.get("isExternal"))
|
|
marker = _status(step.get("sourcingType") or step.get("operationType") or step.get("opType"))
|
|
code = str(step.get("operationCode") or "").strip().upper()
|
|
name = str(step.get("operationName") or "")
|
|
return marker in _EXTERNAL or code.startswith("WZ") or "委外" in name or "外协" in name
|
|
|
|
def _available_capabilities(world: Mapping[str, Any]) -> set[str]:
|
|
capabilities: set[str] = set()
|
|
for row in list(world.get("flexEquipment") or ()) + list(world.get("equipment") or ()):
|
|
if not isinstance(row, Mapping) or _status(row.get("status")) in _RESOURCE_INACTIVE: continue
|
|
raw = row.get("capabilities") or row.get("capabilityCodes") or ()
|
|
if isinstance(raw, str): raw = [part.strip() for part in raw.replace(";", ",").split(",") if part.strip()]
|
|
capabilities.update(str(value).strip().upper() for value in raw)
|
|
active_ws = {row.get("id") for row in (world.get("workstations") or ()) if isinstance(row, Mapping) and _status(row.get("status")) not in _RESOURCE_INACTIVE}
|
|
op_codes = {row.get("id"): str(row.get("code") or "").strip().upper() for row in (world.get("operations") or ()) if isinstance(row, Mapping)}
|
|
for link in world.get("workstationOperations") or ():
|
|
if isinstance(link, Mapping) and link.get("workstationId") in active_ws:
|
|
code = op_codes.get(link.get("operationId")) or str(link.get("operationCode") or "").strip().upper()
|
|
if code: capabilities.add(code)
|
|
return capabilities
|
|
|
|
def _analyze_route(world: Mapping[str, Any], material: Mapping[str, Any]) -> _RouteAnalysis:
|
|
fixed, flex = _route_rows(world, material)
|
|
steps = fixed or flex
|
|
if not steps: return _RouteAnalysis("MISSING", "MISSING", (), ())
|
|
route_status: RoutingStatus = "TEMPLATE" if flex and all(_status(step.get("stdTimeSource")) in _TEMPLATE for step in flex) else "READY"
|
|
internal_codes, external = [], []
|
|
for step in steps:
|
|
if _is_external_step(step): external.append(dict(step))
|
|
else:
|
|
code = str(step.get("operationCode") or "").strip().upper()
|
|
if code: internal_codes.append(code)
|
|
capabilities = _available_capabilities(world)
|
|
missing = tuple(sorted({code for code in internal_codes if code not in capabilities}))
|
|
return _RouteAnalysis(route_status, "MISSING" if missing else "READY", missing, tuple(external))
|
|
|
|
_DRAFT_SUPPLY_TABLES = frozenset({"purchaseOrders", "outsourceOrders"})
|
|
_DRAFT_SUPPLY_TRANSIENT_FIELDS = frozenset({"id", "orderNo", "createdAt", "updatedAt"})
|
|
|
|
|
|
def _canonical_source_value(value: Any, *, collection: str | None = None) -> Any:
|
|
"""Return a deterministic planning-source representation.
|
|
|
|
Generated DRAFT supply rows are recreated by every MRP decomposition. Their
|
|
persistence identifiers, display numbers, and audit timestamps are technical
|
|
provenance only; material, quantity, dates, sourcing, and status remain in
|
|
the digest. Lists are sorted by their canonical JSON so record ordering does
|
|
not make the same business snapshot look different.
|
|
"""
|
|
if isinstance(value, Mapping):
|
|
is_generated_draft_supply = (
|
|
collection in _DRAFT_SUPPLY_TABLES
|
|
and _status(value.get("status")) in _DRAFT_SUPPLY
|
|
)
|
|
normalized_mapping: dict[str, Any] = {}
|
|
for raw_key in sorted(value, key=lambda item: str(item)):
|
|
key = str(raw_key)
|
|
if is_generated_draft_supply and key in _DRAFT_SUPPLY_TRANSIENT_FIELDS:
|
|
continue
|
|
normalized_mapping[key] = _canonical_source_value(value[raw_key])
|
|
return normalized_mapping
|
|
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
normalized_sequence = [
|
|
_canonical_source_value(item, collection=collection)
|
|
for item in value
|
|
]
|
|
return sorted(
|
|
normalized_sequence,
|
|
key=lambda item: json.dumps(
|
|
item,
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
default=str,
|
|
separators=(",", ":"),
|
|
),
|
|
)
|
|
if isinstance(value, (set, frozenset)):
|
|
return sorted(
|
|
(_canonical_source_value(item) for item in value),
|
|
key=lambda item: json.dumps(
|
|
item,
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
default=str,
|
|
separators=(",", ":"),
|
|
),
|
|
)
|
|
return value
|
|
|
|
def _source_revision(world: Mapping[str, Any], business_date: str) -> str:
|
|
snapshot = {
|
|
key: _canonical_source_value(world.get(key), collection=key)
|
|
for key in _RELEVANT_KEYS
|
|
if key in world
|
|
}
|
|
payload = json.dumps(
|
|
{"businessDate": business_date, "world": snapshot},
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
default=str,
|
|
separators=(",", ":"),
|
|
)
|
|
return sha256(payload.encode("utf-8")).hexdigest()
|
|
|
|
def _add_blocker(blockers: dict[str, Blocker], codes: MutableMapping[str, set[str]], *, code: str, entity_id: str, message: str, evidence: Mapping[str, Any] | None = None, entity_type: str = "REQUIREMENT", severity: Severity = "HARD") -> None:
|
|
evidence_dict = dict(evidence or {})
|
|
blocker_id = _stable_id("BLK", code, entity_type, entity_id, evidence_dict)
|
|
blockers.setdefault(blocker_id, Blocker(blocker_id, code, severity, entity_type, entity_id, message, evidence_dict))
|
|
if entity_type == "REQUIREMENT": codes.setdefault(entity_id, set()).add(code)
|
|
|
|
def _supply_date(row: Mapping[str, Any]) -> str | None:
|
|
for name in ("availableAt", "expectedDate", "arrivalDate", "promisedDate", "expectedArrivalDate", "requiredDate", "dueDate"):
|
|
value = normalize_date(row.get(name))
|
|
if value: return value
|
|
return None
|
|
|
|
def _row_quantity(row: Mapping[str, Any]) -> float:
|
|
for name in ("availableQuantity", "quantity", "qty", "onHand", "stock", "balance"):
|
|
if row.get(name) not in (None, ""): return normalize_quantity(row.get(name))
|
|
return 0.0
|
|
|
|
def _row_material_key(row: Mapping[str, Any], by_id: Mapping[Any, Mapping[str, Any]], by_code: Mapping[str, Mapping[str, Any]]) -> str:
|
|
material = _resolve_material(by_id, by_code, material_id=row.get("materialId") or row.get("productId"), material_code=row.get("materialCode") or row.get("productCode") or row.get("code"))
|
|
return _material_key(material, row.get("materialCode") or row.get("productCode") or row.get("code"))
|
|
def _collect_supply_candidates(world: Mapping[str, Any], business_date: str, requirements: Mapping[str, _RequirementDraft], by_id: Mapping[Any, Mapping[str, Any]], by_code: Mapping[str, Mapping[str, Any]]) -> list[SupplyCandidate]:
|
|
external_keys = {req.material_key for req in requirements.values() if req.sourcing_type in {"BUY", "SUBCONTRACT"}}
|
|
buy_keys = {req.material_key for req in requirements.values() if req.sourcing_type == "BUY"}
|
|
candidates: list[SupplyCandidate] = []
|
|
inventory_tables = [world.get(name) for name in ("inventoryBalances", "inventories", "inventory", "inventoryItems", "stock") if isinstance(world.get(name), list)]
|
|
explicit_stock: set[str] = set()
|
|
for table in inventory_tables:
|
|
for row in table or ():
|
|
if not isinstance(row, Mapping): continue
|
|
key, quantity = _row_material_key(row, by_id, by_code), _row_quantity(row)
|
|
if key not in buy_keys or quantity <= 0: continue
|
|
explicit_stock.add(key)
|
|
candidates.append(SupplyCandidate(_stable_id("STOCK", key, row.get("id") or row.get("locationId") or len(candidates)), key, quantity, business_date, "STOCK", "AVAILABLE", True, str(row.get("id") or row.get("locationId") or key), {"sourceTable": "inventory"}))
|
|
for material in by_code.values():
|
|
key = _material_key(material)
|
|
if key not in buy_keys or key in explicit_stock: continue
|
|
gross, safety = normalize_quantity(material.get("stock")), normalize_quantity(material.get("safetyStock"))
|
|
available = normalize_quantity(max(gross - safety, 0.0))
|
|
if available > 0:
|
|
candidates.append(SupplyCandidate(f"STOCK:{key}", key, available, business_date, "STOCK", "AVAILABLE", True, key, {"grossStock": gross, "safetyStock": safety, "sourceTable": "materials"}))
|
|
transit_tables = [world.get(name) for name in ("inTransit", "inTransitItems", "inventoryInTransit") if isinstance(world.get(name), list)]
|
|
explicit_transit: set[str] = set()
|
|
for table in transit_tables:
|
|
for row in table or ():
|
|
if not isinstance(row, Mapping): continue
|
|
key, quantity = _row_material_key(row, by_id, by_code), _row_quantity(row)
|
|
if key not in buy_keys or quantity <= 0: continue
|
|
explicit_transit.add(key)
|
|
available_at, status = _supply_date(row), _status(row.get("status")) or "IN_TRANSIT"
|
|
candidates.append(SupplyCandidate(_stable_id("INTRANSIT", key, row.get("id") or len(candidates)), key, quantity, available_at, "IN_TRANSIT", status, available_at is not None and status not in _DRAFT_SUPPLY, str(row.get("id") or ""), {"sourceTable": "inTransit"}))
|
|
for material in by_code.values():
|
|
key = _material_key(material)
|
|
if key not in buy_keys or key in explicit_transit: continue
|
|
quantity = normalize_quantity(material.get("inTransit"))
|
|
if quantity <= 0: continue
|
|
available_at = _supply_date(material)
|
|
candidates.append(SupplyCandidate(f"INTRANSIT:{key}", key, quantity, available_at, "IN_TRANSIT", "IN_TRANSIT", available_at is not None, key, {"sourceTable": "materials"}))
|
|
for row in world.get("purchaseOrders") or ():
|
|
if not isinstance(row, Mapping): continue
|
|
key, quantity = _row_material_key(row, by_id, by_code), _row_quantity(row)
|
|
if key not in external_keys or quantity <= 0: continue
|
|
status, available_at = _status(row.get("status")) or "UNKNOWN", _supply_date(row)
|
|
candidates.append(SupplyCandidate(f"PO:{row.get('id') or row.get('orderNo') or _stable_id('REF', key, quantity)}", key, quantity, available_at, "PURCHASE", status, status in _TRUSTED_SUPPLY and available_at is not None, str(row.get("id") or row.get("orderNo") or ""), {"orderNo": row.get("orderNo"), "salesOrderId": row.get("salesOrderId"), "sourceTable": "purchaseOrders"}))
|
|
for row in world.get("outsourceOrders") or ():
|
|
if not isinstance(row, Mapping): continue
|
|
product_code = str(row.get("productCode") or "").strip().upper()
|
|
operation_code = str(row.get("operationCode") or "").strip().upper()
|
|
key = f"OP:{product_code}:{operation_code}:SEQ:{int(row.get('sequenceNo') or 0)}" if product_code and operation_code else _row_material_key(row, by_id, by_code)
|
|
quantity = _row_quantity(row)
|
|
if key not in external_keys or quantity <= 0: continue
|
|
status, available_at = _status(row.get("status")) or "UNKNOWN", _supply_date(row)
|
|
candidates.append(SupplyCandidate(f"OUT:{row.get('id') or row.get('orderNo') or _stable_id('REF', key, quantity)}", key, quantity, available_at, "SUBCONTRACT", status, status in _TRUSTED_SUPPLY and available_at is not None, str(row.get("id") or row.get("orderNo") or ""), {"orderNo": row.get("orderNo"), "salesOrderId": row.get("salesOrderId"), "sourceTable": "outsourceOrders"}))
|
|
return candidates
|
|
|
|
def build_closed_loop_problem(world: Mapping[str, Any], *, business_date: str = "2026-08-02", order_nos: Sequence[str] | None = None, strict: bool = True) -> ClosedLoopProblem:
|
|
"""Build the v1 closed-loop problem without mutating ``world``."""
|
|
business_day = normalize_date(business_date)
|
|
if business_day is None: raise ValueError(f"invalid business_date: {business_date!r}")
|
|
selected_orders = {str(value) for value in order_nos} if order_nos else None
|
|
by_id, by_code = _catalog(world)
|
|
requirements: dict[str, _RequirementDraft] = {}
|
|
blockers: dict[str, Blocker] = {}
|
|
blocker_codes: dict[str, set[str]] = defaultdict(set)
|
|
route_cache: dict[str, _RouteAnalysis] = {}
|
|
order_priorities: dict[str, int] = {}
|
|
order_count = order_line_count = 0
|
|
|
|
def upsert(*, root_token: str, sales_order_id: str, sales_order_no: str, sales_order_line_id: str, material: Mapping[str, Any], quantity: float, unit: str, required_at: str, sourcing_type: SourcingType, bom_depth: int, parent_id: str | None, operation_code: str | None = None, synthetic_key: str | None = None) -> _RequirementDraft:
|
|
key = synthetic_key or _material_key(material)
|
|
req_id = _stable_id("REQ", root_token, sourcing_type, key, operation_code or "")
|
|
qty = normalize_quantity(quantity)
|
|
req = requirements.get(req_id)
|
|
if req is None:
|
|
req = _RequirementDraft(req_id, sales_order_id, sales_order_no, sales_order_line_id, material.get("id") or material.get("materialId"), key, str(material.get("code") or material.get("materialCode") or ""), str(material.get("name") or material.get("materialName") or material.get("code") or key), 0.0, str(unit or material.get("unit") or "PCS"), required_at, sourcing_type, bom_depth, operation_code)
|
|
requirements[req_id] = req
|
|
req.quantity = normalize_quantity(req.quantity + qty)
|
|
req.bom_depth = min(req.bom_depth, bom_depth)
|
|
if parent_id:
|
|
req.parent_quantities[parent_id] = normalize_quantity(req.parent_quantities.get(parent_id, 0.0) + qty)
|
|
requirements[parent_id].child_ids.add(req_id)
|
|
return req
|
|
|
|
def analyze_make(req: _RequirementDraft, material: Mapping[str, Any]) -> _RouteAnalysis:
|
|
analysis = route_cache.setdefault(req.material_key, _analyze_route(world, material))
|
|
req.routing_status, req.resource_status = analysis.status, analysis.resource_status
|
|
if analysis.status == "MISSING": _add_blocker(blockers, blocker_codes, code="MISSING_ROUTING", entity_id=req.requirement_id, message=f"{req.material_code or req.material_name} 缺少正式工艺路线", evidence={"materialKey": req.material_key})
|
|
elif analysis.status == "TEMPLATE": _add_blocker(blockers, blocker_codes, code="TEMPLATE_ROUTING_UNCONFIRMED", entity_id=req.requirement_id, message=f"{req.material_code or req.material_name} 仅有模板工艺,确认前不可正式排产", evidence={"materialKey": req.material_key}, severity="HARD" if strict else "WARNING")
|
|
if analysis.resource_status == "MISSING" and analysis.missing_resource_codes: _add_blocker(blockers, blocker_codes, code="MISSING_RESOURCE_CAPABILITY", entity_id=req.requirement_id, message=f"{req.material_code or req.material_name} 缺少工序能力资源", evidence={"operationCodes": list(analysis.missing_resource_codes)})
|
|
return analysis
|
|
def walk(*, root_token: str, sales_order_id: str, sales_order_no: str, sales_order_line_id: str, material: Mapping[str, Any], quantity: float, unit: str, required_at: str, bom_depth: int, parent_id: str | None, ancestors: tuple[str, ...]) -> None:
|
|
key = _material_key(material)
|
|
sourcing = _infer_sourcing(world, material)
|
|
req = upsert(root_token=root_token, sales_order_id=sales_order_id, sales_order_no=sales_order_no, sales_order_line_id=sales_order_line_id, material=material, quantity=quantity, unit=unit, required_at=required_at, sourcing_type=sourcing, bom_depth=bom_depth, parent_id=parent_id)
|
|
if key == "UNKNOWN": _add_blocker(blockers, blocker_codes, code="MISSING_MATERIAL_MASTER", entity_id=req.requirement_id, message="BOM/订单需求无法解析到物料主数据", evidence={"depth": bom_depth})
|
|
if sourcing != "MAKE": return
|
|
analysis = analyze_make(req, material)
|
|
for step in analysis.external_steps:
|
|
product_code = str(material.get("code") or material.get("productCode") or "").strip().upper()
|
|
operation_code = str(step.get("operationCode") or "").strip().upper()
|
|
sequence = int(step.get("sequenceNo") or step.get("seq") or 0)
|
|
op_key = f"OP:{product_code}:{operation_code}:SEQ:{sequence}"
|
|
op_material = {"code": op_key, "name": str(step.get("operationName") or operation_code or "委外工序"), "unit": unit, "sourcingType": "SUBCONTRACT"}
|
|
upsert(root_token=root_token, sales_order_id=sales_order_id, sales_order_no=sales_order_no, sales_order_line_id=sales_order_line_id, material=op_material, quantity=quantity, unit=unit, required_at=required_at, sourcing_type="SUBCONTRACT", bom_depth=bom_depth + 1, parent_id=req.requirement_id, operation_code=operation_code, synthetic_key=op_key)
|
|
next_ancestors = ancestors + (key,)
|
|
for child, per_qty, edge_id in _bom_children(world, material, by_id, by_code):
|
|
if per_qty <= 0:
|
|
_add_blocker(blockers, blocker_codes, code="INVALID_BOM_QUANTITY", entity_id=req.requirement_id, message="BOM 用量必须大于 0", evidence={"bomItemId": edge_id, "quantity": per_qty})
|
|
continue
|
|
child_key = _material_key(child)
|
|
if child_key in next_ancestors:
|
|
_add_blocker(blockers, blocker_codes, code="BOM_CYCLE", entity_id=req.requirement_id, message="检测到 BOM 环,已停止继续展开", evidence={"path": list(next_ancestors + (child_key,)), "bomItemId": edge_id})
|
|
continue
|
|
walk(root_token=root_token, sales_order_id=sales_order_id, sales_order_no=sales_order_no, sales_order_line_id=sales_order_line_id, material=child, quantity=normalize_quantity(quantity * per_qty), unit=str(child.get("unit") or unit), required_at=required_at, bom_depth=bom_depth + 1, parent_id=req.requirement_id, ancestors=next_ancestors)
|
|
|
|
for order in world.get("salesOrders") or ():
|
|
if not isinstance(order, Mapping) or _status(order.get("status")) in _INACTIVE: continue
|
|
order_id = str(order.get("id") or order.get("orderNo") or "")
|
|
order_no = str(order.get("orderNo") or order_id)
|
|
if selected_orders is not None and order_no not in selected_orders: continue
|
|
priority = int(order.get("priority") or 0) + (100 if bool(order.get("isRush")) else 0)
|
|
raw_due = order.get("deliveryDate") or order.get("dueDate")
|
|
required_at = normalize_date(raw_due) or business_day
|
|
order_priorities[order_id] = priority
|
|
order_count += 1
|
|
for index, item in enumerate(order.get("items") or (), start=1):
|
|
if not isinstance(item, Mapping) or _status(item.get("status")) in _INACTIVE: continue
|
|
quantity = normalize_quantity(item.get("quantity"))
|
|
if quantity <= 0: continue
|
|
line_id = str(item.get("id") or item.get("lineId") or index)
|
|
material = _resolve_material(by_id, by_code, material_id=item.get("productId") or item.get("materialId"), material_code=item.get("productCode") or item.get("materialCode"), fallback={"id": item.get("productId") or item.get("materialId"), "code": item.get("productCode") or item.get("materialCode"), "name": item.get("productName") or item.get("materialName"), "unit": item.get("unit"), "type": "FINISHED_PRODUCT"})
|
|
before = set(requirements)
|
|
root_token = f"{order_id}:{line_id}"
|
|
order_line_count += 1
|
|
walk(root_token=root_token, sales_order_id=order_id, sales_order_no=order_no, sales_order_line_id=line_id, material=material, quantity=quantity, unit=str(item.get("unit") or material.get("unit") or "PCS"), required_at=required_at, bom_depth=0, parent_id=None, ancestors=())
|
|
if normalize_date(raw_due) is None:
|
|
root_ids = sorted(set(requirements) - before)
|
|
if root_ids: _add_blocker(blockers, blocker_codes, code="INVALID_REQUIRED_DATE", entity_id=root_ids[0], message="订单交期缺失或无效,暂以业务日期作为占位", evidence={"businessDate": business_day, "orderNo": order_no})
|
|
|
|
candidates = _collect_supply_candidates(world, business_day, requirements, by_id, by_code)
|
|
external = [NettingRequirement(req.requirement_id, req.material_key, req.quantity, req.required_at, req.sourcing_type, order_priorities.get(req.sales_order_id, 0), req.sales_order_no) for req in requirements.values() if req.sourcing_type in {"BUY", "SUBCONTRACT"}]
|
|
netting = net_supply_requirements(external, candidates, business_date=business_day)
|
|
balances_by_id = {balance.requirement_id: balance for balance in netting.demand_balances}
|
|
for candidate in candidates:
|
|
matching = [req for req in requirements.values() if req.material_key == candidate.material_key and req.sourcing_type in {"BUY", "SUBCONTRACT"} and (not candidate.metadata.get("salesOrderId") or str(candidate.metadata.get("salesOrderId")) == req.sales_order_id)]
|
|
if _status(candidate.status) in _DRAFT_SUPPLY:
|
|
for req in matching: _add_blocker(blockers, blocker_codes, code="UNTRUSTED_DRAFT_SUPPLY", entity_id=req.requirement_id, message="DRAFT 采购/委外记录仅是建议,不可作为可信供应", evidence={"eventId": candidate.event_id, "status": candidate.status}, severity="HARD" if strict else "WARNING")
|
|
elif candidate.available_at is None and candidate.quantity > 0:
|
|
for req in matching: _add_blocker(blockers, blocker_codes, code="UNTRUSTED_SUPPLY_DATE", entity_id=req.requirement_id, message="在途/采购/委外供应缺少可信到达日期", evidence={"eventId": candidate.event_id, "status": candidate.status})
|
|
for req in requirements.values():
|
|
balance = balances_by_id.get(req.requirement_id)
|
|
if balance and balance.shortage_quantity > 0:
|
|
_add_blocker(blockers, blocker_codes, code="SUPPLY_SHORTAGE", entity_id=req.requirement_id, message="截止需求日期的可信供应不足", evidence={"requiredQuantity": balance.required_quantity, "allocatedQuantity": balance.allocated_quantity, "shortageQuantity": balance.shortage_quantity, "requiredAt": req.required_at})
|
|
for req_id, codes in blocker_codes.items():
|
|
if req_id in requirements: requirements[req_id].blocker_codes.update(codes)
|
|
|
|
frozen_requirements = tuple(Requirement(req.requirement_id, req.sales_order_id, req.sales_order_no, req.sales_order_line_id, req.material_id, req.material_key, req.material_code, req.material_name, req.quantity, req.unit, req.required_at, req.sourcing_type, req.bom_depth, tuple(sorted(req.parent_quantities)), tuple(sorted(req.child_ids)), tuple(PeggingRef(parent_id, qty) for parent_id, qty in sorted(req.parent_quantities.items())), req.operation_code, req.routing_status, req.resource_status, tuple(sorted(req.blocker_codes))) for req in sorted(requirements.values(), key=lambda row: row.requirement_id))
|
|
frozen_by_id = {req.requirement_id: req for req in frozen_requirements}
|
|
demand_id_by_req = {req.requirement_id: _stable_id("MFG", req.requirement_id) for req in frozen_requirements if req.sourcing_type == "MAKE"}
|
|
|
|
def descendant_codes(requirement_id: str, seen: set[str] | None = None) -> set[str]:
|
|
seen = set(seen or ())
|
|
if requirement_id in seen: return {"BOM_CYCLE"}
|
|
seen.add(requirement_id)
|
|
req = frozen_by_id[requirement_id]
|
|
result = set(req.blocker_codes)
|
|
for child_id in req.child_requirement_ids: result.update(descendant_codes(child_id, seen))
|
|
return result
|
|
manufacturing_demands: list[ManufacturingDemand] = []
|
|
make_events: list[SupplyEvent] = []
|
|
make_balances: list[DemandBalance] = []
|
|
for req in frozen_requirements:
|
|
if req.sourcing_type != "MAKE": continue
|
|
all_codes = tuple(sorted(descendant_codes(req.requirement_id)))
|
|
child_rows = [frozen_by_id[child_id] for child_id in req.child_requirement_ids]
|
|
material_status = "BLOCKED" if any("SUPPLY_SHORTAGE" in child.blocker_codes for child in child_rows) else ("PENDING_DEPENDENCIES" if child_rows else "READY")
|
|
manufacturing_demands.append(ManufacturingDemand(demand_id_by_req[req.requirement_id], req.requirement_id, req.sales_order_id, req.sales_order_no, req.sales_order_line_id, req.material_id, req.material_code, req.material_name, req.quantity, req.unit, req.required_at, req.bom_depth, tuple(sorted(demand_id_by_req[parent] for parent in req.parent_requirement_ids if parent in demand_id_by_req)), req.child_requirement_ids, req.routing_status, req.resource_status, material_status, "BLOCKED" if all_codes else "READY_FOR_SCHEDULING", all_codes))
|
|
make_events.append(SupplyEvent(f"MAKE:{req.requirement_id}", req.material_key, req.quantity, None, "MAKE", "PLANNED", False, req.requirement_id, (SupplyAllocation(req.requirement_id, req.quantity),), 0.0, {"requiresScheduling": True}))
|
|
make_balances.append(DemandBalance(req.requirement_id, req.quantity, req.quantity, 0.0))
|
|
all_events = tuple(sorted(tuple(netting.supply_events) + tuple(make_events), key=lambda event: (event.material_key, event.event_id)))
|
|
all_balances = tuple(sorted(tuple(netting.demand_balances) + tuple(make_balances), key=lambda balance: balance.requirement_id))
|
|
frozen_blockers = tuple(sorted(blockers.values(), key=lambda row: row.blocker_id))
|
|
counts = Counter(blocker.code for blocker in frozen_blockers)
|
|
source_hash = _source_revision(world, business_day)
|
|
summary = {
|
|
"businessDate": business_day, "strict": bool(strict), "orderCount": order_count,
|
|
"orderLineCount": order_line_count, "requirementCount": len(frozen_requirements),
|
|
"makeCount": sum(req.sourcing_type == "MAKE" for req in frozen_requirements),
|
|
"buyCount": sum(req.sourcing_type == "BUY" for req in frozen_requirements),
|
|
"subcontractCount": sum(req.sourcing_type == "SUBCONTRACT" for req in frozen_requirements),
|
|
"blockedRequirementCount": sum(bool(req.blocker_codes) for req in frozen_requirements),
|
|
"supplyEventCount": len(all_events), "trustedSupplyEventCount": sum(event.trusted for event in all_events),
|
|
"blockerCounts": dict(sorted(counts.items())),
|
|
}
|
|
return ClosedLoopProblem(_SCHEMA_VERSION, f"CLP-{business_day.replace('-', '')}-{source_hash[:16]}", source_hash, business_day, frozen_requirements, tuple(sorted(manufacturing_demands, key=lambda row: row.demand_id)), all_events, all_balances, frozen_blockers, summary)
|
|
|
|
__all__ = ["Blocker", "ClosedLoopProblem", "ManufacturingDemand", "PeggingRef", "Requirement", "build_closed_loop_problem"]
|