767 lines
29 KiB
Python
767 lines
29 KiB
Python
#!/usr/bin/env python3
|
||
"""Build a repeatable Ruiyang folder -> import -> MRP -> scheduling demo.
|
||
|
||
The source workbook remains authoritative. The script uses the product importers
|
||
and scheduling engines, writes only disposable demo artifacts, and records every
|
||
assumption needed to turn incomplete site master data into a clean presentation.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import copy
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
from collections import Counter, defaultdict
|
||
from datetime import date, timedelta
|
||
from pathlib import Path
|
||
from typing import Any, Iterable
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||
if str(REPO_ROOT) not in sys.path:
|
||
sys.path.insert(0, str(REPO_ROOT))
|
||
|
||
from openpyxl import load_workbook
|
||
|
||
from server.aps_domain.closed_loop_runtime import run_closed_loop_candidate
|
||
from server.aps_domain.mrp import decompose_orders, list_mrp, summarize_decomposition
|
||
from server.engines.base import EngineParams
|
||
from server.engines.rule_engine import RuleEngine
|
||
from server.importers.mom_pack import import_mom_excel, parse_mom_workbook
|
||
from server.state.seed import empty_world
|
||
|
||
DEFAULT_SOURCE_DIR = Path(
|
||
os.environ.get("RUIYANG_DEMO_DIR")
|
||
or (REPO_ROOT / "demo-data" / "ruiyang-source")
|
||
)
|
||
DEFAULT_OUTPUT_DIR = REPO_ROOT / "outputs" / "ruiyang-demo"
|
||
|
||
FIELD_MAPPING: list[dict[str, Any]] = [
|
||
{
|
||
"sheet": "02-工艺模型",
|
||
"sourceFields": ["层次", "ERP品号", "产品名称", "单套用量", "单位", "品号属性", "客户图号"],
|
||
"targets": [
|
||
"flexMaterials.code/name/type/unit/spec/sourcingType",
|
||
"flexBom.productCode/materialCode/quantity",
|
||
"canonical materials/boms/bomItems",
|
||
],
|
||
"rule": "层次构造多层 BOM;M自制件映射 MAKE,P外购件映射 BUY。",
|
||
},
|
||
{
|
||
"sheet": "04-物料模型",
|
||
"sourceFields": ["物料编码", "物料名称", "物料组", "物料属性", "基本单位", "图号"],
|
||
"targets": ["flexMaterials", "canonical materials"],
|
||
"rule": "补齐物料名称、分组、单位、图号和供应类型。",
|
||
},
|
||
{
|
||
"sheet": "07-设备模型",
|
||
"sourceFields": ["财务编号", "固定资产编号", "设备名称", "设备型号", "厂内编号", "出厂编号", "设备状态"],
|
||
"targets": ["flexEquipment.code/name/spec/status/capabilities"],
|
||
"rule": "按设备名称识别 CUT/BEND/WELD/PAINT;其他设备保留 GENERAL。",
|
||
},
|
||
{
|
||
"sheet": "01-生产模型",
|
||
"sourceFields": ["工厂名称", "工厂编码", "车间名称", "车间编码", "工段名称", "工段编码"],
|
||
"targets": ["flexZones", "canonical factories/workshops/lines"],
|
||
"rule": "车间投影为区域;canonical 侧由正式同步函数生成现场工厂/柔性能力池。",
|
||
},
|
||
{
|
||
"sheet": "02-工艺模型",
|
||
"sourceFields": ["制造流程(根据工艺代码表填写对应的工艺代码)"],
|
||
"targets": ["flexRoutings", "canonical routings/routingSteps"],
|
||
"rule": "当前正式导入器尚未逐列解释真实流程码;使用 CUT/BEND/WELD/PAINT/ASM 模板,演示报告明确标记 TEMPLATE。",
|
||
},
|
||
{
|
||
"sheet": "目录中的 DXF",
|
||
"sourceFields": ["文件名", "ACADVER", "实体统计", "SHA-256"],
|
||
"targets": ["sourceInventory.dxf"],
|
||
"rule": "仅做文件级盘点;当前 3 个 DXF 文件名在工作簿中无直接匹配,不虚构物料绑定。",
|
||
},
|
||
]
|
||
|
||
MIN_CANONICAL = {"boms": 3, "bomItems": 42, "routings": 3, "routingSteps": 15}
|
||
|
||
|
||
class IdCounter:
|
||
"""Small deterministic per-entity counter for an isolated in-memory world."""
|
||
|
||
def __init__(self, start: int = 3000) -> None:
|
||
self.start = start
|
||
self.values: dict[str, int] = {}
|
||
|
||
def __call__(self, kind: str) -> int:
|
||
value = self.values.get(kind, self.start) + 1
|
||
self.values[kind] = value
|
||
return value
|
||
|
||
|
||
def _jsonable(value: Any) -> Any:
|
||
if hasattr(value, "model_dump"):
|
||
return value.model_dump()
|
||
if isinstance(value, Path):
|
||
return str(value)
|
||
raise TypeError(f"Cannot serialize {type(value)!r}")
|
||
|
||
|
||
def write_json(path: Path, payload: Any) -> None:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(
|
||
json.dumps(payload, ensure_ascii=False, indent=2, default=_jsonable) + "\n",
|
||
encoding="utf-8",
|
||
)
|
||
|
||
|
||
def sha256(path: Path) -> str:
|
||
digest = hashlib.sha256()
|
||
with path.open("rb") as fh:
|
||
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
||
digest.update(chunk)
|
||
return digest.hexdigest()
|
||
|
||
|
||
def choose_workbook(source_dir: Path) -> Path:
|
||
candidates = [p for p in source_dir.glob("*.xlsx") if not p.name.startswith("~$")]
|
||
if not candidates:
|
||
raise FileNotFoundError(f"No xlsx workbook found in {source_dir}")
|
||
candidates.sort(key=lambda p: ("MOM" not in p.name and "主数据收集" not in p.name, p.name))
|
||
return candidates[0]
|
||
|
||
|
||
def _dxf_pairs(path: Path) -> Iterable[tuple[str, str]]:
|
||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||
lines = text.splitlines()
|
||
for idx in range(0, len(lines) - 1, 2):
|
||
yield lines[idx].strip(), lines[idx + 1].strip()
|
||
|
||
|
||
def inspect_dxf(path: Path) -> dict[str, Any]:
|
||
acad_version = ""
|
||
section = ""
|
||
entity_counts: Counter[str] = Counter()
|
||
pending_var = ""
|
||
for code, value in _dxf_pairs(path):
|
||
if code == "0" and value == "SECTION":
|
||
section = "PENDING"
|
||
continue
|
||
if section == "PENDING" and code == "2":
|
||
section = value
|
||
continue
|
||
if code == "0" and value == "ENDSEC":
|
||
section = ""
|
||
continue
|
||
if section == "HEADER" and code == "9":
|
||
pending_var = value
|
||
continue
|
||
if section == "HEADER" and pending_var == "$ACADVER" and code == "1":
|
||
acad_version = value
|
||
pending_var = ""
|
||
if section == "ENTITIES" and code == "0" and value not in {"SECTION", "ENDSEC", "EOF"}:
|
||
entity_counts[value] += 1
|
||
return {
|
||
"name": path.name,
|
||
"path": str(path),
|
||
"bytes": path.stat().st_size,
|
||
"sha256": sha256(path),
|
||
"acadVersion": acad_version or None,
|
||
"entityCounts": dict(entity_counts.most_common()),
|
||
}
|
||
|
||
|
||
def workbook_inventory(path: Path) -> tuple[dict[str, Any], list[str]]:
|
||
wb = load_workbook(path, read_only=True, data_only=True)
|
||
cells: list[str] = []
|
||
sheets: list[dict[str, Any]] = []
|
||
for ws in wb.worksheets:
|
||
sheets.append({"name": ws.title, "rows": ws.max_row, "columns": ws.max_column})
|
||
for row in ws.iter_rows(values_only=True):
|
||
for value in row:
|
||
if value is not None:
|
||
cells.append(str(value).strip())
|
||
return {
|
||
"name": path.name,
|
||
"path": str(path),
|
||
"bytes": path.stat().st_size,
|
||
"sha256": sha256(path),
|
||
"sheetCount": len(sheets),
|
||
"sheets": sheets,
|
||
}, cells
|
||
|
||
|
||
def inventory_source(source_dir: Path) -> tuple[dict[str, Any], Path]:
|
||
source_dir = source_dir.resolve()
|
||
if not source_dir.is_dir():
|
||
raise FileNotFoundError(f"Ruiyang source directory not found: {source_dir}")
|
||
workbook = choose_workbook(source_dir)
|
||
workbook_meta, workbook_cells = workbook_inventory(workbook)
|
||
dxf_rows = [inspect_dxf(path) for path in sorted(source_dir.glob("*.dxf"))]
|
||
for row in dxf_rows:
|
||
stem = re.sub(r"-e(?:\(\d+\))?$", "", Path(row["name"]).stem, flags=re.IGNORECASE)
|
||
refs = [cell for cell in workbook_cells if stem and stem in cell]
|
||
row["workbookReferences"] = refs[:20]
|
||
row["matchedToWorkbook"] = bool(refs)
|
||
files = [
|
||
{
|
||
"name": path.name,
|
||
"path": str(path),
|
||
"suffix": path.suffix.lower(),
|
||
"bytes": path.stat().st_size,
|
||
"sha256": sha256(path),
|
||
}
|
||
for path in sorted(source_dir.iterdir())
|
||
if path.is_file()
|
||
]
|
||
return {
|
||
"sourceDir": str(source_dir),
|
||
"fileCount": len(files),
|
||
"files": files,
|
||
"workbook": workbook_meta,
|
||
"dxf": dxf_rows,
|
||
"unmatchedDxfCount": sum(not row["matchedToWorkbook"] for row in dxf_rows),
|
||
}, workbook
|
||
|
||
|
||
def projection_counts(world: dict[str, Any]) -> dict[str, dict[str, int]]:
|
||
flex_keys = {
|
||
"materials": "flexMaterials",
|
||
"bom": "flexBom",
|
||
"equipment": "flexEquipment",
|
||
"zones": "flexZones",
|
||
"orders": "flexOrders",
|
||
"routings": "flexRoutings",
|
||
"operations": "flexOperations",
|
||
}
|
||
canonical_keys = [
|
||
"materials", "boms", "bomItems", "operations", "routings", "routingSteps",
|
||
"factories", "workshops", "lines", "salesOrders",
|
||
]
|
||
return {
|
||
"flex": {name: len(world.get(key) or []) for name, key in flex_keys.items()},
|
||
"canonical": {key: len(world.get(key) or []) for key in canonical_keys},
|
||
}
|
||
|
||
|
||
def validate_dual_projection(world: dict[str, Any]) -> dict[str, Any]:
|
||
counts = projection_counts(world)
|
||
canonical = counts["canonical"]
|
||
flex = counts["flex"]
|
||
checks = {
|
||
"minimumCanonical": {
|
||
key: {"actual": canonical[key], "minimum": minimum, "passed": canonical[key] >= minimum}
|
||
for key, minimum in MIN_CANONICAL.items()
|
||
},
|
||
"bomItemsMatchFlexBom": canonical["bomItems"] == flex["bom"],
|
||
"routingStepsMatchFlexRoutings": canonical["routingSteps"] == flex["routings"],
|
||
"materialsMatchFlexMaterials": canonical["materials"] == flex["materials"],
|
||
"salesOrdersMatchFlexOrders": canonical["salesOrders"] == flex["orders"],
|
||
}
|
||
failures = [
|
||
f"{key}: actual={item['actual']} minimum={item['minimum']}"
|
||
for key, item in checks["minimumCanonical"].items()
|
||
if not item["passed"]
|
||
]
|
||
for key in (
|
||
"bomItemsMatchFlexBom", "routingStepsMatchFlexRoutings",
|
||
"materialsMatchFlexMaterials", "salesOrdersMatchFlexOrders",
|
||
):
|
||
if not checks[key]:
|
||
failures.append(key)
|
||
if failures:
|
||
raise RuntimeError("Canonical/flex projection gate failed: " + "; ".join(failures))
|
||
return {"counts": counts, "checks": checks, "passed": True}
|
||
|
||
|
||
def normalize_demo_order(
|
||
world: dict[str, Any], *, business_date: str, due_date: str, order_no: str | None,
|
||
) -> dict[str, Any]:
|
||
flex_orders = world.get("flexOrders") or []
|
||
sales_orders = world.get("salesOrders") or []
|
||
if not flex_orders or not sales_orders:
|
||
raise RuntimeError("Import produced no demo order")
|
||
root_code = str(flex_orders[0].get("productCode") or "")
|
||
material_by_code = {str(row.get("code") or ""): row for row in world.get("materials") or []}
|
||
root_material = material_by_code.get(root_code) or {}
|
||
normalized_no = order_no or f"RY-DEMO-{root_code}"
|
||
source_due_missing = not bool(flex_orders[0].get("dueDate"))
|
||
source_order_no = str(flex_orders[0].get("orderNo") or "")
|
||
|
||
for row in flex_orders:
|
||
row["orderNo"] = normalized_no
|
||
row["salesOrderNo"] = normalized_no
|
||
row["dueDate"] = due_date
|
||
if root_material.get("name"):
|
||
row["productName"] = root_material["name"]
|
||
for row in sales_orders:
|
||
row["orderNo"] = normalized_no
|
||
row["orderDate"] = business_date
|
||
row["deliveryDate"] = due_date
|
||
row.setdefault("isRush", False)
|
||
row.setdefault("rushStrategy", "NORMAL")
|
||
for item in row.get("items") or []:
|
||
code = str(item.get("productCode") or "")
|
||
material = material_by_code.get(code) or {}
|
||
if material.get("name"):
|
||
item["productName"] = material["name"]
|
||
return {
|
||
"sourceOrderNo": source_order_no,
|
||
"demoOrderNo": normalized_no,
|
||
"rootProductCode": root_code,
|
||
"rootProductName": root_material.get("name") or flex_orders[0].get("productName"),
|
||
"sourceDueDateMissing": source_due_missing,
|
||
"demoBusinessDate": business_date,
|
||
"demoDueDate": due_date,
|
||
}
|
||
|
||
|
||
def compact_mrp(result: dict[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"counts": {
|
||
"orders": len(result.get("orders") or []),
|
||
"make": len(result.get("make") or []),
|
||
"purchase": len(result.get("purchase") or []),
|
||
"outsource": len(result.get("outsource") or []),
|
||
},
|
||
"summaryText": summarize_decomposition(result),
|
||
"orders": result.get("orders") or [],
|
||
"make": result.get("make") or [],
|
||
"purchase": result.get("purchase") or [],
|
||
"outsource": result.get("outsource") or [],
|
||
}
|
||
|
||
|
||
def strict_readiness(
|
||
world: dict[str, Any], *, business_date: str, schedule_start: str, order_no: str,
|
||
) -> dict[str, Any]:
|
||
strict_world = copy.deepcopy(world)
|
||
result = run_closed_loop_candidate(
|
||
strict_world,
|
||
IdCounter(6000),
|
||
business_date=business_date,
|
||
schedule_start_date=schedule_start,
|
||
order_nos=[order_no],
|
||
sort_mode="BOTTLENECK",
|
||
window="full",
|
||
name="锐扬真实数据严格闭环诊断",
|
||
strict=True,
|
||
)
|
||
planning_summary = ((result.get("planning") or {}).get("summary") or {})
|
||
return {
|
||
"executionMode": "CLOSED_LOOP_V1_STRICT_DIAGNOSTIC",
|
||
"solveStatus": result.get("solveStatus"),
|
||
"versionId": result.get("versionId"),
|
||
"orderCount": result.get("orderCount"),
|
||
"demandCount": result.get("demandCount"),
|
||
"admittedDemandCount": result.get("admittedDemandCount"),
|
||
"unscheduledDemandCount": result.get("unscheduledDemandCount"),
|
||
"conflictCount": result.get("conflictCount"),
|
||
"planningProblemId": result.get("planningProblemId"),
|
||
"planningSourceHash": result.get("planningSourceHash"),
|
||
"planningSummary": planning_summary,
|
||
"blockerCounts": planning_summary.get("blockerCounts") or {},
|
||
"conflictSamples": (strict_world.get("flexConflicts") or [])[:20],
|
||
"note": "这是正式严格准入结果;BLOCKED 表示真实主数据/供应尚未达到发布排产条件,不等于 MRP 未分解。",
|
||
}
|
||
|
||
|
||
def _select_equipment(world: dict[str, Any], operation_code: str) -> tuple[dict[str, Any], bool]:
|
||
equipment = [
|
||
row for row in world.get("flexEquipment") or []
|
||
if str(row.get("status") or "RUNNING") == "RUNNING"
|
||
]
|
||
exact = next(
|
||
(row for row in equipment if operation_code in (row.get("capabilities") or [])),
|
||
None,
|
||
)
|
||
if exact:
|
||
return exact, False
|
||
if operation_code == "ASM":
|
||
keywords = ("压铆", "组装", "装配", "铆")
|
||
inferred = next(
|
||
(row for row in equipment if any(word in str(row.get("name") or "") for word in keywords)),
|
||
None,
|
||
)
|
||
if inferred:
|
||
return inferred, True
|
||
general = next((row for row in equipment if "GENERAL" in (row.get("capabilities") or [])), None)
|
||
if general:
|
||
return general, True
|
||
raise RuntimeError(f"No real Ruiyang equipment can be projected for operation {operation_code}")
|
||
|
||
|
||
def apply_demo_adapter(
|
||
world: dict[str, Any], mrp_result: dict[str, Any], *, schedule_start: str,
|
||
planning_horizon_days: int, assume_kitted: bool,
|
||
) -> dict[str, Any]:
|
||
sales_orders = world.get("salesOrders") or []
|
||
if not sales_orders or not sales_orders[0].get("items"):
|
||
raise RuntimeError("No sales order item available for scheduling")
|
||
line = (world.get("lines") or [None])[0]
|
||
if not line:
|
||
raise RuntimeError("Canonical line projection is missing")
|
||
|
||
product_ids = sorted({
|
||
int(item["productId"])
|
||
for order in sales_orders
|
||
for item in order.get("items") or []
|
||
if isinstance(item.get("productId"), int)
|
||
})
|
||
world["lineProducts"] = [
|
||
{
|
||
"id": idx,
|
||
"lineId": line["id"],
|
||
"productId": product_id,
|
||
"standardCapacity": 10,
|
||
"priority": 1,
|
||
"setupTime": 10,
|
||
}
|
||
for idx, product_id in enumerate(product_ids, 1)
|
||
]
|
||
|
||
selected_equipment: list[dict[str, Any]] = []
|
||
world["workstations"] = []
|
||
world["workstationOperations"] = []
|
||
world["equipment"] = []
|
||
for idx, operation in enumerate(world.get("operations") or [], 1):
|
||
equipment, inferred = _select_equipment(world, str(operation.get("code") or ""))
|
||
world["workstations"].append({
|
||
"id": idx,
|
||
"lineId": line["id"],
|
||
"code": f"WS-{operation['code']}",
|
||
"name": equipment.get("name") or equipment.get("code"),
|
||
"sequenceNo": idx,
|
||
"status": "ACTIVE",
|
||
})
|
||
world["workstationOperations"].append({
|
||
"id": idx,
|
||
"workstationId": idx,
|
||
"operationId": operation["id"],
|
||
"setupTime": 10,
|
||
"runTimePerUnit": float(operation.get("standardTime") or 1.0),
|
||
"isPrimary": True,
|
||
})
|
||
world["equipment"].append({
|
||
"id": idx,
|
||
"code": equipment.get("code") or f"EQ-{idx}",
|
||
"name": equipment.get("name") or equipment.get("code") or f"EQ-{idx}",
|
||
"model": equipment.get("spec") or "",
|
||
"workstationId": idx,
|
||
"capacityPerHour": 60,
|
||
"efficiencyFactor": 1.0,
|
||
"availabilityRate": float(equipment.get("availabilityRate") or 0.9),
|
||
"status": "RUNNING",
|
||
})
|
||
selected_equipment.append({
|
||
"operationCode": operation.get("code"),
|
||
"operationName": operation.get("name"),
|
||
"equipmentCode": equipment.get("code"),
|
||
"equipmentName": equipment.get("name"),
|
||
"capabilities": equipment.get("capabilities") or [],
|
||
"capabilityInferred": inferred,
|
||
})
|
||
|
||
start = date.fromisoformat(schedule_start)
|
||
world["shifts"] = [{
|
||
"id": 1,
|
||
"code": "D",
|
||
"name": "演示白班",
|
||
"startTime": "08:00",
|
||
"endTime": "17:00",
|
||
"breakPeriods": [{"start": "12:00", "end": "13:00"}],
|
||
"isOvertime": False,
|
||
"status": "ACTIVE",
|
||
}]
|
||
world["shiftCalendar"] = [
|
||
{
|
||
"id": offset + 1,
|
||
"lineId": line["id"],
|
||
"date": (start + timedelta(days=offset)).isoformat(),
|
||
"shiftId": 1,
|
||
"isWorking": (start + timedelta(days=offset)).weekday() < 5,
|
||
"teamId": None,
|
||
"maxWorkers": 10,
|
||
}
|
||
for offset in range(planning_horizon_days + 1)
|
||
]
|
||
|
||
assumed_demand: defaultdict[int, float] = defaultdict(float)
|
||
if assume_kitted:
|
||
for row in mrp_result.get("purchase") or []:
|
||
if isinstance(row.get("materialId"), int):
|
||
assumed_demand[row["materialId"]] += float(row.get("quantity") or 0)
|
||
for row in mrp_result.get("make") or []:
|
||
if int(row.get("bomDepth") or 0) > 0 and isinstance(row.get("productId"), int):
|
||
assumed_demand[row["productId"]] += float(row.get("quantity") or 0)
|
||
for material in world.get("materials") or []:
|
||
required = assumed_demand.get(material.get("id"), 0.0)
|
||
if required > 0:
|
||
material["stockQuantity"] = max(float(material.get("stockQuantity") or 0), required)
|
||
material["stock"] = max(float(material.get("stock") or 0), required)
|
||
classic_by_code = {
|
||
str(row.get("code") or ""): row for row in world.get("materials") or []
|
||
}
|
||
for material in world.get("flexMaterials") or []:
|
||
classic = classic_by_code.get(str(material.get("code") or ""))
|
||
if classic:
|
||
material["stock"] = float(classic.get("stockQuantity") or classic.get("stock") or 0)
|
||
|
||
return {
|
||
"mode": "EXPLICIT_PRESENTATION_ADAPTER",
|
||
"selectedEquipment": selected_equipment,
|
||
"lineProductCount": len(world["lineProducts"]),
|
||
"workstationCount": len(world["workstations"]),
|
||
"shiftCalendarCount": len(world["shiftCalendar"]),
|
||
"assumeKitted": assume_kitted,
|
||
"assumedKittedMaterialCount": len(assumed_demand),
|
||
"assumedKittedQuantity": round(sum(assumed_demand.values()), 6),
|
||
"assumptions": [
|
||
"源文件没有销售订单交期,使用命令行 demoDueDate。",
|
||
"canonical 产线只有柔性能力池,脚本把根产品显式绑定到该产线。",
|
||
"工位由真实 flexEquipment 逐工序投影;ASM 无原生能力标签时使用真实压铆设备代理。",
|
||
"当前正式导入器使用五道模板工艺,尚未逐列解析 02-工艺模型中的真实流程码。",
|
||
"assumeKitted=true 时按本次 MRP 净需求建立仅用于演示的齐套快照,不代表真实库存。",
|
||
],
|
||
}
|
||
|
||
|
||
def run_fixed_schedule(
|
||
world: dict[str, Any], *, schedule_start: str, planning_horizon_days: int,
|
||
) -> dict[str, Any]:
|
||
result = RuleEngine().solve(
|
||
world,
|
||
EngineParams(
|
||
engineType="RULE",
|
||
strategyTemplate="COMPREHENSIVE",
|
||
planningHorizonDays=planning_horizon_days,
|
||
startDate=schedule_start,
|
||
includeUnapproved=False,
|
||
name="锐扬真实数据演示排产",
|
||
),
|
||
IdCounter(9000),
|
||
)
|
||
return {
|
||
"result": result.model_dump(),
|
||
"scheduleVersions": world.get("scheduleVersions") or [],
|
||
"productionOrders": world.get("productionOrders") or [],
|
||
"workOrders": world.get("workOrders") or [],
|
||
"conflicts": world.get("conflicts") or [],
|
||
}
|
||
|
||
|
||
def run_demo(
|
||
*, source_dir: Path, output_dir: Path, business_date: str, schedule_start: str,
|
||
due_date: str, order_no: str | None = None, planning_horizon_days: int = 30,
|
||
assume_kitted: bool = True, run_strict_check: bool = True,
|
||
) -> dict[str, Any]:
|
||
business = date.fromisoformat(business_date)
|
||
schedule = date.fromisoformat(schedule_start)
|
||
due = date.fromisoformat(due_date)
|
||
if schedule < business:
|
||
raise ValueError("schedule_start must be on or after business_date")
|
||
if due < schedule:
|
||
raise ValueError("due_date must be on or after schedule_start")
|
||
|
||
inventory, workbook = inventory_source(source_dir)
|
||
parsed = parse_mom_workbook(str(workbook))
|
||
|
||
world = empty_world()
|
||
world["businessDate"] = business_date
|
||
import_result = import_mom_excel(world, str(workbook), replace=True)
|
||
projection = validate_dual_projection(world)
|
||
order_normalization = normalize_demo_order(
|
||
world,
|
||
business_date=business_date,
|
||
due_date=due_date,
|
||
order_no=order_no,
|
||
)
|
||
|
||
mrp_result = decompose_orders(world, IdCounter(3000), order_normalization["demoOrderNo"])
|
||
mrp_report = compact_mrp(mrp_result)
|
||
strict_report = (
|
||
strict_readiness(
|
||
world,
|
||
business_date=business_date,
|
||
schedule_start=schedule_start,
|
||
order_no=order_normalization["demoOrderNo"],
|
||
)
|
||
if run_strict_check
|
||
else {"skipped": True}
|
||
)
|
||
|
||
demo_world = copy.deepcopy(world)
|
||
adapter = apply_demo_adapter(
|
||
demo_world,
|
||
mrp_result,
|
||
schedule_start=schedule_start,
|
||
planning_horizon_days=planning_horizon_days,
|
||
assume_kitted=assume_kitted,
|
||
)
|
||
schedule_report = run_fixed_schedule(
|
||
demo_world,
|
||
schedule_start=schedule_start,
|
||
planning_horizon_days=planning_horizon_days,
|
||
)
|
||
order_panel = list_mrp(demo_world)
|
||
order_panel["salesOrders"] = demo_world.get("salesOrders") or []
|
||
order_panel["workOrders"] = demo_world.get("workOrders") or []
|
||
|
||
compact_demo = {
|
||
"schemaVersion": "ruiyang-demo/1.0",
|
||
"dates": {
|
||
"businessDate": business_date,
|
||
"scheduleStart": schedule_start,
|
||
"dueDate": due_date,
|
||
},
|
||
"source": {
|
||
"sourceDir": str(source_dir.resolve()),
|
||
"workbook": inventory["workbook"]["name"],
|
||
"workbookSha256": inventory["workbook"]["sha256"],
|
||
"dxfCount": len(inventory["dxf"]),
|
||
},
|
||
"projectionCounts": projection["counts"],
|
||
"projectionPassed": projection["passed"],
|
||
"order": order_normalization,
|
||
"masterData": {
|
||
"rootMaterial": next(
|
||
(
|
||
row for row in demo_world.get("materials") or []
|
||
if row.get("code") == order_normalization["rootProductCode"]
|
||
),
|
||
None,
|
||
),
|
||
"operations": demo_world.get("operations") or [],
|
||
"selectedEquipment": adapter["selectedEquipment"],
|
||
},
|
||
"mrp": {
|
||
"counts": mrp_report["counts"],
|
||
"makeSamples": (mrp_report.get("make") or [])[:10],
|
||
"purchaseSamples": (mrp_report.get("purchase") or [])[:10],
|
||
"outsourceSamples": (mrp_report.get("outsource") or [])[:10],
|
||
},
|
||
"strictReadiness": {
|
||
"solveStatus": strict_report.get("solveStatus"),
|
||
"conflictCount": strict_report.get("conflictCount"),
|
||
"blockerCounts": strict_report.get("blockerCounts") or {},
|
||
},
|
||
"schedule": {
|
||
"result": schedule_report["result"],
|
||
"productionOrders": schedule_report["productionOrders"],
|
||
"workOrders": schedule_report["workOrders"],
|
||
"conflicts": schedule_report["conflicts"],
|
||
},
|
||
}
|
||
|
||
summary = {
|
||
"generatedAt": f"{business_date} demo run",
|
||
"sourceDir": str(source_dir.resolve()),
|
||
"outputDir": str(output_dir.resolve()),
|
||
"dates": {
|
||
"businessDate": business_date,
|
||
"scheduleStart": schedule_start,
|
||
"dueDate": due_date,
|
||
},
|
||
"source": {
|
||
"workbook": inventory["workbook"]["name"],
|
||
"dxfCount": len(inventory["dxf"]),
|
||
"unmatchedDxfCount": inventory["unmatchedDxfCount"],
|
||
},
|
||
"parse": parsed.get("stats") or {},
|
||
"projection": projection,
|
||
"order": order_normalization,
|
||
"mrp": mrp_report["counts"],
|
||
"strict": {
|
||
"solveStatus": strict_report.get("solveStatus"),
|
||
"conflictCount": strict_report.get("conflictCount"),
|
||
"blockerCounts": strict_report.get("blockerCounts") or {},
|
||
"skipped": bool(strict_report.get("skipped")),
|
||
},
|
||
"demoAdapter": {
|
||
"assumeKitted": adapter["assumeKitted"],
|
||
"selectedEquipment": adapter["selectedEquipment"],
|
||
},
|
||
"schedule": schedule_report["result"],
|
||
"orderPanel": {
|
||
"salesOrders": len(order_panel.get("salesOrders") or []),
|
||
"make": len(order_panel.get("make") or []),
|
||
"purchaseOrders": len(order_panel.get("purchaseOrders") or []),
|
||
"outsourceOrders": len(order_panel.get("outsourceOrders") or []),
|
||
"productionOrders": len(order_panel.get("productionOrders") or []),
|
||
"workOrders": len(order_panel.get("workOrders") or []),
|
||
},
|
||
}
|
||
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
artifacts = {
|
||
"00-source-inventory.json": inventory,
|
||
"01-field-mapping.json": {"fieldMapping": FIELD_MAPPING},
|
||
"02-import-projection.json": {
|
||
"importResult": import_result,
|
||
"projectionGate": projection,
|
||
"orderNormalization": order_normalization,
|
||
},
|
||
"03-mrp.json": mrp_report,
|
||
"04-strict-readiness.json": strict_report,
|
||
"05-demo-adapter.json": adapter,
|
||
"06-schedule.json": schedule_report,
|
||
"07-order-panel.json": order_panel,
|
||
"compact-ruiyang-demo.json": compact_demo,
|
||
"world-ruiyang-demo.json": demo_world,
|
||
"summary.json": summary,
|
||
}
|
||
for name, payload in artifacts.items():
|
||
write_json(output_dir / name, payload)
|
||
write_json(
|
||
output_dir / "manifest.json",
|
||
{
|
||
"generatedForBusinessDate": business_date,
|
||
"artifacts": [
|
||
{"name": name, "sha256": sha256(output_dir / name), "bytes": (output_dir / name).stat().st_size}
|
||
for name in artifacts
|
||
],
|
||
},
|
||
)
|
||
return summary
|
||
|
||
|
||
def build_parser() -> argparse.ArgumentParser:
|
||
today = date.today()
|
||
parser = argparse.ArgumentParser(
|
||
description="Ruiyang real workbook folder -> import -> MRP -> schedulable demo",
|
||
)
|
||
parser.add_argument("--source-dir", type=Path, default=DEFAULT_SOURCE_DIR)
|
||
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
|
||
parser.add_argument("--business-date", default=today.isoformat())
|
||
parser.add_argument("--schedule-start")
|
||
parser.add_argument("--due-date")
|
||
parser.add_argument("--order-no")
|
||
parser.add_argument("--planning-horizon-days", type=int, default=30)
|
||
parser.add_argument("--no-assume-kitted", action="store_true")
|
||
parser.add_argument("--skip-strict-check", action="store_true")
|
||
return parser
|
||
|
||
|
||
def main() -> int:
|
||
args = build_parser().parse_args()
|
||
business = date.fromisoformat(args.business_date)
|
||
schedule_start = args.schedule_start or (business + timedelta(days=1)).isoformat()
|
||
due_date = args.due_date or (business + timedelta(days=30)).isoformat()
|
||
summary = run_demo(
|
||
source_dir=args.source_dir,
|
||
output_dir=args.output_dir,
|
||
business_date=args.business_date,
|
||
schedule_start=schedule_start,
|
||
due_date=due_date,
|
||
order_no=args.order_no,
|
||
planning_horizon_days=args.planning_horizon_days,
|
||
assume_kitted=not args.no_assume_kitted,
|
||
run_strict_check=not args.skip_strict_check,
|
||
)
|
||
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
||
print(f"\nArtifacts: {args.output_dir.resolve()}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|