aps-agent/scripts/prepare_ruiyang_demo.py

256 lines
13 KiB
Python
Raw Normal View History

from __future__ import annotations
import argparse
import csv
import hashlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
from collections import Counter
from datetime import date
from pathlib import Path
from typing import Any, Iterable
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
GENERATOR = ROOT / "outputs" / "ruiyang-demo" / "generate_ruiyang_demo.py"
OUTPUT = ROOT / "outputs" / "ruiyang-demo"
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def write_csv(path: Path, fieldnames: list[str], rows: Iterable[dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8-sig", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
for row in rows:
writer.writerow(row)
def resolve_source(source_dir: Path, explicit: Path | None) -> Path:
if explicit:
source = explicit.expanduser().resolve()
else:
candidates = sorted(
path for path in source_dir.glob("*.xlsx")
if "MOM" in path.name and not path.name.startswith("~$")
)
if not candidates:
raise FileNotFoundError(f"{source_dir} 中未找到名称含 MOM 的 xlsx")
source = candidates[0].resolve()
if not source.is_file():
raise FileNotFoundError(source)
return source
def regenerate(source: Path, business_date: str) -> dict[str, Any]:
env = os.environ.copy()
env["RUIYANG_SOURCE_XLSX"] = str(source)
env["RUIYANG_BASE_DATE"] = business_date
env["PYTHONIOENCODING"] = "utf-8"
subprocess.run([sys.executable, str(GENERATOR)], cwd=ROOT, env=env, check=True)
return json.loads((OUTPUT / "ruiyang-demo-world.json").read_text(encoding="utf-8"))
def stage_folder(world: dict[str, Any], source_dir: Path, stage_dir: Path, business_date: str) -> list[Path]:
if stage_dir.exists():
shutil.rmtree(stage_dir)
stage_dir.mkdir(parents=True, exist_ok=True)
materials = []
for row in world.get("flexMaterials") or []:
materials.append({
"code": row.get("code"), "name": row.get("name"), "type": row.get("type"),
"unit": row.get("unit"), "stock": row.get("stock", 0), "inTransit": row.get("inTransit", 0),
"safetyStock": row.get("safetyStock", 0),
"procurementLeadTime": row.get("procurementLeadTime", 0), "expectedArrivalDate": row.get("expectedArrivalDate", ""),
"sourcingType": row.get("sourcingType", ""), "spec": row.get("spec", ""),
})
equipment = []
for row in world.get("flexEquipment") or []:
op_std = row.get("opStdTime") or {}
equipment.append({
"code": row.get("code"), "name": row.get("name"),
"capabilities": ";".join(str(value) for value in row.get("capabilities") or []),
"opStdTime": ";".join(f"{key}:{value}" for key, value in op_std.items()),
"movable": "是" if row.get("movable") else "否", "moveTimeMin": row.get("moveTimeMin", 0),
"zone": row.get("zone", "ZONE-A"), "availabilityRate": row.get("availabilityRate", 1),
"status": row.get("status", "RUNNING"),
"adaptableMolds": ";".join(str(value) for value in row.get("adaptableMolds") or []),
})
routing = [
{
"productCode": row.get("productCode"), "productName": row.get("productName"), "seq": row.get("seq"),
"operationCode": row.get("operationCode"), "operationName": row.get("operationName"),
"requireMold": "是" if row.get("requireMold") else "否", "stdTimePerUnit": row.get("stdTimePerUnit"),
}
for row in world.get("flexRoutings") or []
]
bom = [
{
"productCode": row.get("productCode"), "materialCode": row.get("materialCode"),
"quantity": row.get("quantity"), "consumeOp": row.get("consumeOp", ""),
"isKey": "是" if row.get("isKey") else "否",
}
for row in world.get("flexBom") or []
]
orders = [
{
"orderNo": row.get("orderNo"), "productCode": row.get("productCode"), "quantity": row.get("quantity"),
"dueDate": row.get("dueDate"), "priority": row.get("priority", 5),
"customerName": row.get("customerName", "现场客户"), "customerLevel": row.get("customerLevel", "B"),
"status": "RELEASED",
}
for row in world.get("flexOrders") or []
]
calendar = [
{
"shiftCode": row.get("shiftCode"), "name": row.get("name"), "startTime": row.get("startTime"),
"endTime": row.get("endTime"), "workdays": "/".join(str(value) for value in row.get("workdays") or []),
"breaks": ";".join(f"{value.get('start')}-{value.get('end')}" for value in row.get("breaks") or []),
"enabled": "是" if row.get("enabled", True) else "否",
}
for row in world.get("flexCalendar") or []
]
files = [
stage_dir / "01_materials.csv", stage_dir / "02_equipment.csv", stage_dir / "03_routing.csv",
stage_dir / "04_bom.csv", stage_dir / "05_orders.csv", stage_dir / "06_calendar.csv",
]
write_csv(files[0], ["code", "name", "type", "unit", "stock", "inTransit", "safetyStock", "procurementLeadTime", "expectedArrivalDate", "sourcingType", "spec"], materials)
write_csv(files[1], ["code", "name", "capabilities", "opStdTime", "movable", "moveTimeMin", "zone", "availabilityRate", "status", "adaptableMolds"], equipment)
write_csv(files[2], ["productCode", "productName", "seq", "operationCode", "operationName", "requireMold", "stdTimePerUnit"], routing)
write_csv(files[3], ["productCode", "materialCode", "quantity", "consumeOp", "isKey"], bom)
write_csv(files[4], ["orderNo", "productCode", "quantity", "dueDate", "priority", "customerName", "customerLevel", "status"], orders)
write_csv(files[5], ["shiftCode", "name", "startTime", "endTime", "workdays", "breaks", "enabled"], calendar)
source_files = sorted(path for path in source_dir.iterdir() if path.is_file())
manifest = {
"generatedAt": business_date,
"sourceDir": str(source_dir),
"sourceFiles": [{"name": path.name, "bytes": path.stat().st_size, "sha256": sha256(path)} for path in source_files],
"stagedFiles": [{"name": path.name, "bytes": path.stat().st_size, "sha256": sha256(path)} for path in files],
"dataBoundary": "产品/物料/设备编码来自湖南锐扬 MOM;订单数量、库存场景与连续仿真班为客户演示补充。",
}
(stage_dir / "source-manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
(stage_dir / "README.md").write_text(
"# 湖南锐扬 APS 文件夹演示包\n\n"
f"- 生成日期:{business_date}\n"
f"- 原始目录:`{source_dir}`\n"
"- 在桌面端新建项目时,把工程目录选择为本文件夹。\n"
"- 会话依次输入:`分析一下数据文件` → `执行MRP分解` → `跑一版柔性排产`。\n"
"- 排产完成后打开柔性甘特,切换到 `虚拟线预演`。\n"
"- 06_calendar.csv 是演示连续仿真窗;正式上线必须替换为客户真实班历。\n",
encoding="utf-8",
)
return files
def validate_stage(files: list[Path], stage_dir: Path) -> dict[str, Any]:
# Isolate every file/database used by the API validation.
temp = tempfile.TemporaryDirectory(prefix="aps-ruiyang-flow-", ignore_cleanup_errors=True)
temp_dir = Path(temp.name)
os.environ.update({
"APS_AUTH_ENABLED": "0", "APS_MODE": "web", "APS_DB_PATH": str(temp_dir / "master.db"),
"APS_PROJECTS_PATH": str(temp_dir / "projects.json"), "APS_APPROVAL_PATH": str(temp_dir / "approvals.json"),
"APS_APPROVAL_BACKEND": "file", "APS_CHECKPOINTS_PATH": str(temp_dir / "checkpoints.json"),
})
from server.aps_domain.importers import apply_import_commit, preview_file
from server.state.seed import empty_world
world = empty_world()
counters: Counter[str] = Counter()
def next_id(kind: str) -> int:
tables = {"salesOrder": "salesOrders", "material": "materials", "audit": "auditEvents"}
table = tables.get(kind, f"{kind}s")
current = max((row.get("id", 0) for row in world.get(table, []) if isinstance(row.get("id"), int)), default=0)
counters[kind] = max(counters[kind] + 1, current + 1)
return counters[kind]
import_rows: list[dict[str, Any]] = []
for path in files:
preview = preview_file(path.name, path.read_bytes(), world, soft=True)
if preview["totalErrors"]:
raise RuntimeError(f"{path.name} 存在 {preview['totalErrors']} 条阻断错误")
applied = apply_import_commit(world, next_id, preview["batches"])
import_rows.append({"file": path.name, "ok": preview["totalOk"], "applied": applied["summary"]})
world_path = temp_dir / "world.json"
world_path.write_text(json.dumps(world, ensure_ascii=False, indent=2), encoding="utf-8")
(OUTPUT / "ruiyang-stage-world.json").write_text(json.dumps(world, ensure_ascii=False, indent=2), encoding="utf-8")
from server.state.store import WorldStore, _stores
store = WorldStore(path=str(world_path), world_key="ruiyang-flow", tenant_uuid="platform")
_stores[("platform", "personal-1")] = store
from fastapi.testclient import TestClient
from server.main import app
client = TestClient(app)
decompose = client.post("/api/mrp/decompose", json={"orderNo": "SO-004"}).json()
if "result" not in decompose:
raise RuntimeError(f"MRP 分解失败:{decompose}")
schedule = client.post("/api/flex/schedule", json={"sortMode": "DELIVERY", "orderIds": [2, 3, 5], "window": "21d", "enforceTeams": False}).json()
schedule_result = schedule.get("result") or {}
if schedule_result.get("solveStatus") != "FEASIBLE":
raise RuntimeError(f"排产未通过:{schedule.get('message') or schedule_result}")
orders = client.get("/api/orders").json()
gantt = client.get("/api/flex/gantt").json()
result = {
"generatedAt": date.today().isoformat(),
"stageDir": str(stage_dir),
"imports": import_rows,
"counts": {
"materials": len(store.data.get("materials") or []), "boms": len(store.data.get("boms") or []),
"bomItems": len(store.data.get("bomItems") or []), "routings": len(store.data.get("routings") or []),
"routingSteps": len(store.data.get("routingSteps") or []), "salesOrders": len(orders.get("orders") or []),
"make": len(orders.get("make") or []), "purchase": len(orders.get("purchaseOrders") or []),
"outsource": len(orders.get("outsourceOrders") or []), "productionOrders": len(orders.get("productionOrders") or []),
"virtualLines": int(schedule_result.get("vlCount") or 0), "workOrders": len(gantt.get("workOrders") or []),
},
"schedule": {key: schedule_result.get(key) for key in ["versionNo", "solveStatus", "vlCount", "woCount", "conflictCount"]},
"virtualReplayReady": bool(gantt.get("workOrders")) and schedule_result.get("solveStatus") == "FEASIBLE",
}
required = result["counts"]
if not (required["salesOrders"] == 5 and required["make"] >= 3 and required["purchase"] >= 3
and required["productionOrders"] == 3 and required["virtualLines"] == 3 and required["workOrders"] == 15):
raise RuntimeError(f"端到端计数不符合演示门禁:{required}")
temp.cleanup()
return result
def main() -> None:
parser = argparse.ArgumentParser(description="从湖南锐扬原始目录生成并验证 APS 文件夹演示包")
parser.add_argument("--source-dir", required=True, type=Path)
parser.add_argument("--source-xlsx", type=Path)
parser.add_argument("--stage-dir", type=Path)
parser.add_argument("--business-date", default=date.today().isoformat())
args = parser.parse_args()
source_dir = args.source_dir.expanduser().resolve()
if not source_dir.is_dir():
raise NotADirectoryError(source_dir)
source = resolve_source(source_dir, args.source_xlsx)
stage_dir = (args.stage_dir or (source_dir / "APS演示数据")).expanduser().resolve()
world = regenerate(source, args.business_date)
files = stage_folder(world, source_dir, stage_dir, args.business_date)
validation = validate_stage(files, stage_dir)
validation["sourceXlsx"] = str(source)
validation["sourceSha256"] = sha256(source)
report_path = OUTPUT / "ruiyang-flow-validation.json"
report_path.write_text(json.dumps(validation, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(validation, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()