256 lines
13 KiB
Python
256 lines
13 KiB
Python
|
|
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()
|