aps-agent/scripts/ruiyang_demo.py

767 lines
29 KiB
Python
Raw Permalink 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.

#!/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())