aps-agent/server/aps_domain/masterdata.py

1590 lines
84 KiB
Python
Raw 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.

# ============================================================
# 主数据维护领域服务(moduleId: domain-masterdata, 可重生 ✅)
# 对齐聚制云 §4.4:资源树 + 工艺模型(物料/BOM/工序/路线/产线绑定)+ 日历维保
# 写动作一律 P2 确认卡;被排产版本引用的资源禁止物理删除,只能停用(INACTIVE)。
# ============================================================
from __future__ import annotations
import copy
from datetime import date, datetime, timedelta
from typing import Any
from server.timeutil import fmt_dt
from server.aps_domain.masterdata_sync import masterdata_view_world, synchronized_master_action
World = dict[str, Any] # 世界状态类型别名
# 主数据写动作白名单(Gateway 与 workflow 共用)
MASTER_ACTIONS = (
"master.line.upsert", "master.material.upsert", "master.maintenance.upsert",
"master.bom.upsert", "master.routing.upsert",
"master.operation.upsert", "master.lineProduct.upsert",
"master.changeover.upsert",
"master.workstation.upsert", "master.equipment.upsert",
"master.bom.release", "master.bom.rollback",
"master.routing.release", "master.routing.rollback",
"master.calendar.holiday.upsert", "master.calendar.template.create",
"master.calendar.week.copy",
"master.clear",
)
CLEAR_SCOPES = {"resource", "process", "calendar", "flex", "all"}
# 产线/维保/物料允许的状态集
LINE_STATUSES = {"ACTIVE", "INACTIVE"}
MAINTENANCE_STATUSES = {"PLANNED", "CANCELLED"}
MATERIAL_TYPES = {"FINISHED_PRODUCT", "SEMI_FINISHED", "RAW_MATERIAL"}
MATERIAL_STATUSES = {"ACTIVE", "INACTIVE"}
WORKSTATION_STATUSES = {"ACTIVE", "INACTIVE", "DISABLED"}
EQUIPMENT_STATUSES = {"ACTIVE", "RUNNING", "MAINTENANCE", "INACTIVE", "DISABLED"}
# 排产资源停用语义:与 closed_loop_problem._RESOURCE_INACTIVE 保持同一闭集。
# 工位/设备状态进入该集合后,只读投影与后续新排产不再选择它们。
RESOURCE_INACTIVE_STATUSES = frozenset({"DOWN", "FAULT", "MAINTENANCE", "INACTIVE", "DISABLED", "SCRAPPED"})
MASTERDATA_VERSION_KINDS = frozenset({"BOM", "ROUTING"})
# 工艺模型表:一键清理时整表清空
_PROCESS_TABLES = (
"materials", "boms", "bomItems", "operations", "routings", "routingSteps",
"lineProducts", "workstationOperations", "changeoverMatrix",
)
# 资源树表:一键清理时整表清空
_RESOURCE_TABLES = (
"factories", "workshops", "lines", "workstations", "equipment",
"lineProducts", "workstationOperations", "shiftCalendar",
"calendarTemplates", "calendarHolidays", "masterdataVersions",
)
# 柔性轨主数据(MD-08/09):一键清理时整表清空(排产版本结果不碰)
_FLEX_TABLES = (
"flexZones", "flexOperations", "flexEquipment", "flexMolds",
"flexMaterials", "flexRoutings", "flexBom", "flexOrders", "flexParams",
)
def _now() -> str:
"""当前时间字符串(与世界状态 createdAt/updatedAt 口径一致)。"""
from zoneinfo import ZoneInfo
return fmt_dt(datetime.now(ZoneInfo("Asia/Shanghai")))
# ---------------- P0 只读投影 ----------------
def master_overview(world: World) -> dict[str, Any]:
"""主数据管理页全量投影(P0 只读;资源树 + 物料BOM + 日历维保)。"""
world = masterdata_view_world(world)
# 产线被工单引用计数(未完成工单;确认卡影响面与"禁止删除"依据)
line_wo_count: dict[int, int] = {}
for wo in world.get("workOrders", []):
if wo.get("status") not in ("COMPLETED", "CANCELLED"):
line_wo_count[wo["lineId"]] = line_wo_count.get(wo["lineId"], 0) + 1
# ---- 资源树(工厂 → 车间 → 产线 → 工位;设备挂工位) ----
equipment_by_ws = {}
for eq in world.get("equipment", []):
equipment_by_ws.setdefault(eq["workstationId"], []).append({
"id": eq["id"], "code": eq["code"], "name": eq["name"],
"model": eq.get("model", ""), "status": eq.get("status", "RUNNING"),
})
factories = []
for f in world.get("factories", []):
workshops = []
for w in world.get("workshops", []):
if w["factoryId"] != f["id"]:
continue
lines = []
for ln in world.get("lines", []):
if ln["workshopId"] != w["id"]:
continue
stations = [{
"id": ws["id"], "code": ws["code"], "name": ws["name"],
"sequenceNo": ws["sequenceNo"], "status": ws.get("status", "ACTIVE"),
"equipment": equipment_by_ws.get(ws["id"], []),
} for ws in world.get("workstations", []) if ws["lineId"] == ln["id"]]
lines.append({
"id": ln["id"], "code": ln["code"], "name": ln["name"],
"capacityPerDay": ln["capacityPerDay"],
"efficiencyFactor": ln.get("efficiencyFactor", 1.0),
"status": ln.get("status", "ACTIVE"),
"openWorkOrders": line_wo_count.get(ln["id"], 0),
"workstations": stations,
})
workshops.append({"id": w["id"], "code": w["code"], "name": w["name"], "lines": lines})
factories.append({"id": f["id"], "code": f["code"], "name": f["name"], "workshops": workshops})
# ---- 物料 / BOM / 工艺路线 ----
materials = [{
"id": m["id"], "code": m["code"], "name": m["name"], "spec": m.get("spec", ""),
"type": m["type"], "unit": m["unit"],
"sourcingType": m.get("sourcingType") or ("BUY" if m.get("type") == "RAW_MATERIAL" else "MAKE"),
"productFamily": m.get("productFamily") or "",
"stock": m.get("stock", 0), "inTransit": m.get("inTransit", 0),
"safetyStock": m.get("safetyStock", 0),
"procurementLeadTime": m.get("procurementLeadTime", 0),
"status": m.get("status", "ACTIVE"),
"sourceRef": m.get("sourceRef"), "masterOverrides": m.get("masterOverrides", []),
} for m in world.get("materials", [])]
material_name = {m["id"]: m["name"] for m in world.get("materials", [])}
material_code = {m["id"]: m["code"] for m in world.get("materials", [])}
boms = []
for b in world.get("boms", []):
items = [{
"id": i["id"], "materialId": i["materialId"],
"materialName": material_name.get(i["materialId"], f"#{i['materialId']}"),
"quantity": i["quantity"], "isKeyMaterial": i.get("isKeyMaterial", False),
"lossRate": i.get("lossRate", 0), "sourceRef": i.get("sourceRef"),
} for i in world.get("bomItems", []) if i["bomId"] == b["id"]]
boms.append({
"id": b["id"], "productId": b["productId"],
"productName": material_name.get(b["productId"], f"#{b['productId']}"),
"version": b["version"], "isDefault": b.get("isDefault", False),
"status": b.get("status", "ACTIVE"), "items": items,
})
operations = [{
"id": o["id"], "code": o["code"], "name": o["name"],
"type": o.get("type", "INTERNAL"),
"sourcingType": o.get("sourcingType") or ("OUTSOURCE" if o.get("type") == "EXTERNAL" else "MAKE"),
"standardTime": o.get("standardTime", 0),
} for o in world.get("operations", [])]
op_name = {o["id"]: o["name"] for o in world.get("operations", [])}
routings = []
for r in world.get("routings", []):
steps = sorted([s for s in world.get("routingSteps", []) if s["routingId"] == r["id"]],
key=lambda s: s["sequenceNo"])
routings.append({
"id": r["id"], "productId": r["productId"],
"productCode": material_code.get(r["productId"], ""),
"productName": material_name.get(r["productId"], f"#{r['productId']}"),
"version": r["version"], "isDefault": r.get("isDefault", False),
"status": r.get("status", "ACTIVE"),
"steps": [{
"id": s["id"], "sequenceNo": s["sequenceNo"],
"operationId": s["operationId"],
"operationName": op_name.get(s["operationId"], f"OP#{s['operationId']}"),
"setupTime": s["setupTime"], "runTimePerUnit": s["runTimePerUnit"],
"stdTimeSource": s.get("stdTimeSource", ""), "sourceRef": s.get("sourceRef"),
"masterOverrides": s.get("masterOverrides", []),
"isExternal": s.get("isExternal", False),
"sourcingType": s.get("sourcingType") or ("OUTSOURCE" if s.get("isExternal") else "MAKE"),
} for s in steps],
})
line_name = {ln["id"]: ln["name"] for ln in world.get("lines", [])}
line_products = [{
"id": lp["id"], "lineId": lp["lineId"], "lineName": line_name.get(lp["lineId"], f"#{lp['lineId']}"),
"productId": lp["productId"],
"productName": material_name.get(lp["productId"], f"#{lp['productId']}"),
"standardCapacity": lp.get("standardCapacity", 0),
"priority": lp.get("priority", 99), "setupTime": lp.get("setupTime", 0),
} for lp in world.get("lineProducts", [])]
# ---- 班次 / 日历摘要 / 维保 ----
shifts = [{
"id": s["id"], "code": s["code"], "name": s["name"],
"startTime": s["startTime"], "endTime": s["endTime"],
"breakPeriods": s.get("breakPeriods", []),
} for s in world.get("shifts", [])]
# 日历摘要:逐产线统计未来工作日数量(页面展示概览,不铺 30 天明细)
working_days: dict[int, set] = {}
for sc in world.get("shiftCalendar", []):
if sc.get("isWorking"):
working_days.setdefault(sc["lineId"], set()).add(sc["date"])
calendar = [{
"lineId": lid, "lineName": line_name.get(lid, f"#{lid}"),
"workingDays": len(days),
"workingDates": sorted(days),
} for lid, days in sorted(working_days.items())]
equipment_name = {eq["id"]: eq["name"] for eq in world.get("equipment", [])}
maintenance = [{
"id": m["id"], "equipmentId": m["equipmentId"],
"equipmentName": equipment_name.get(m["equipmentId"], f"#{m['equipmentId']}"),
"type": m.get("type", "MAINTENANCE"),
"plannedStart": m["plannedStart"], "plannedEnd": m["plannedEnd"],
"status": m.get("status", "PLANNED"), "description": m.get("description", ""),
} for m in world.get("maintenance", [])]
equipment_options = [{"id": eq["id"], "code": eq["code"], "name": eq["name"]}
for eq in world.get("equipment", [])]
product_options = [{"id": m["id"], "code": m["code"], "name": m["name"]}
for m in world.get("materials", [])
if m.get("type") in ("FINISHED_PRODUCT", "SEMI_FINISHED")
and m.get("status", "ACTIVE") == "ACTIVE"]
line_options = [{"id": ln["id"], "code": ln["code"], "name": ln["name"]}
for ln in world.get("lines", []) if ln.get("status", "ACTIVE") == "ACTIVE"]
from server.aps_domain.changeover import build_changeover_view
changeover = build_changeover_view(world)
return {
"factories": factories,
"materials": materials, "boms": boms, "operations": operations, "routings": routings,
"lineProducts": line_products,
"changeover": changeover,
"shifts": shifts, "calendar": calendar, "maintenance": maintenance,
"equipmentOptions": equipment_options,
"productOptions": product_options, "lineOptions": line_options,
"workstations": [{
"id": ws["id"], "lineId": ws["lineId"], "code": ws["code"], "name": ws["name"],
"sequenceNo": ws.get("sequenceNo", 0), "status": ws.get("status", "ACTIVE"),
} for ws in world.get("workstations", [])],
"equipment": [{
"id": eq["id"], "workstationId": eq["workstationId"], "code": eq["code"], "name": eq["name"],
"model": eq.get("model", ""), "status": eq.get("status", "RUNNING"),
} for eq in world.get("equipment", [])],
"calendarTemplates": list(world.get("calendarTemplates", [])),
"calendarHolidays": list(world.get("calendarHolidays", [])),
"masterdataVersions": [{
"id": v["id"], "kind": v["kind"], "productId": v["productId"], "version": v["version"],
"status": v["status"], "note": v.get("note", ""), "createdAt": v.get("createdAt", ""),
} for v in world.get("masterdataVersions", [])],
}
# ---------------- 校验(P0 纯校验) ----------------
def _find_line(world: World, line_id: int) -> dict[str, Any]:
"""取产线;不存在抛 ValueError。"""
line = next((ln for ln in world["lines"] if ln["id"] == line_id), None)
if line is None:
raise ValueError(f"产线不存在:{line_id}")
return line
def _find_material(world: World, material_id: int) -> dict[str, Any]:
"""取物料;不存在抛 ValueError。"""
m = next((m for m in world["materials"] if m["id"] == material_id), None)
if m is None:
raise ValueError(f"物料不存在:{material_id}")
return m
def normalize_line_payload(world: World, payload: dict[str, Any]) -> dict[str, Any]:
"""产线编辑载荷归一化(首切片只允许改名称/产能/效率/状态,禁止删除)。"""
line = _find_line(world, int(payload.get("id") or 0))
out: dict[str, Any] = {"id": line["id"]}
out["name"] = str(payload.get("name") or line["name"]).strip()
if not out["name"]:
raise ValueError("产线名称不能为空")
out["capacityPerDay"] = int(float(payload.get("capacityPerDay") or line["capacityPerDay"]))
if out["capacityPerDay"] <= 0:
raise ValueError("日产能必须为正数")
out["efficiencyFactor"] = round(float(payload.get("efficiencyFactor") or line.get("efficiencyFactor", 1.0)), 3)
if not (0.1 <= out["efficiencyFactor"] <= 2.0):
raise ValueError("效率系数必须在 0.1 ~ 2.0 之间")
status = str(payload.get("status") or line.get("status", "ACTIVE")).upper()
if status not in LINE_STATUSES:
raise ValueError("产线状态必须是 ACTIVE/INACTIVE")
out["status"] = status
return out
def normalize_material_payload(world: World, payload: dict[str, Any]) -> dict[str, Any]:
"""物料新建/全字段编辑/停用(对齐手册工艺模型·物料管理)。无 id = 新建。"""
mid = payload.get("id")
if mid:
m = _find_material(world, int(mid))
out: dict[str, Any] = {"id": m["id"], "op": "update"}
out["code"] = str(payload.get("code") or m["code"]).strip()
out["name"] = str(payload.get("name") or m["name"]).strip()
out["spec"] = str(payload.get("spec", m.get("spec", ""))).strip()
mtype = str(payload.get("type") or m["type"]).upper()
if mtype not in MATERIAL_TYPES:
raise ValueError("物料类型必须是 FINISHED_PRODUCT/SEMI_FINISHED/RAW_MATERIAL")
out["type"] = mtype
out["unit"] = str(payload.get("unit") or m["unit"]).strip() or "件"
if "productFamily" in payload or m.get("type") in ("FINISHED_PRODUCT", "SEMI_FINISHED"):
out["productFamily"] = str(payload.get("productFamily", m.get("productFamily", ""))).strip()
for field in ("stock", "inTransit", "safetyStock"):
val = payload.get(field, m.get(field, 0))
out[field] = float(val)
if out[field] < 0:
raise ValueError(f"{field} 不能为负数")
out["procurementLeadTime"] = int(float(payload.get("procurementLeadTime", m.get("procurementLeadTime", 0))))
if out["procurementLeadTime"] < 0:
raise ValueError("采购前置期不能为负数")
status = str(payload.get("status") or m.get("status", "ACTIVE")).upper()
if status not in MATERIAL_STATUSES:
raise ValueError("物料状态必须是 ACTIVE/INACTIVE")
out["status"] = status
if not out["code"] or not out["name"]:
raise ValueError("物料编码与名称不能为空")
if out["code"] != m["code"] and any(x.get("code") == m["code"] for x in world.get("flexMaterials", [])):
raise ValueError("已关联排产的物料编码不能直接修改;请保留原编码")
dup = next((x for x in world["materials"] if x["code"] == out["code"] and x["id"] != m["id"]), None)
if dup:
raise ValueError(f"物料编码已存在:{out['code']}")
return out
code = str(payload.get("code") or "").strip()
name = str(payload.get("name") or "").strip()
if not code or not name:
raise ValueError("新建物料必须提供编码与名称")
if any(x["code"] == code for x in world["materials"]):
raise ValueError(f"物料编码已存在:{code}")
mtype = str(payload.get("type") or "RAW_MATERIAL").upper()
if mtype not in MATERIAL_TYPES:
raise ValueError("物料类型必须是 FINISHED_PRODUCT/SEMI_FINISHED/RAW_MATERIAL")
out = {
"op": "create",
"code": code, "name": name,
"spec": str(payload.get("spec") or "").strip(),
"type": mtype,
"unit": str(payload.get("unit") or "件").strip() or "件",
"stock": float(payload.get("stock") or 0),
"inTransit": float(payload.get("inTransit") or 0),
"safetyStock": float(payload.get("safetyStock") or 0),
"procurementLeadTime": int(float(payload.get("procurementLeadTime") or 0)),
"productFamily": str(payload.get("productFamily") or "").strip(),
"status": "ACTIVE",
"newId": int(payload.get("newId") or 0),
}
for field in ("stock", "inTransit", "safetyStock"):
if out[field] < 0:
raise ValueError(f"{field} 不能为负数")
if out["procurementLeadTime"] < 0:
raise ValueError("采购前置期不能为负数")
return out
def normalize_maintenance_payload(world: World, payload: dict[str, Any]) -> dict[str, Any]:
"""维保载荷归一化:新增(无 id)或取消(有 id + status=CANCELLED)。"""
out: dict[str, Any] = {}
if payload.get("id"):
mid = int(payload["id"])
mnt = next((m for m in world["maintenance"] if m["id"] == mid), None)
if mnt is None:
raise ValueError(f"维保计划不存在:{mid}")
status = str(payload.get("status") or "CANCELLED").upper()
if status not in MAINTENANCE_STATUSES:
raise ValueError("维保状态必须是 PLANNED/CANCELLED")
out["id"] = mid
out["status"] = status
return out
eq_id = int(payload.get("equipmentId") or 0)
eq = next((e for e in world["equipment"] if e["id"] == eq_id), None)
if eq is None:
raise ValueError(f"设备不存在:{eq_id}")
start = str(payload.get("plannedStart") or "").strip()
end = str(payload.get("plannedEnd") or "").strip()
if not start or not end:
raise ValueError("维保起止时间不能为空")
if end <= start: # 字符串格式统一(YYYY-MM-DD HH:MM),可直接比较
raise ValueError("维保结束时间必须晚于开始时间")
out.update({
"equipmentId": eq_id, "equipmentName": eq["name"],
"plannedStart": start, "plannedEnd": end,
"description": str(payload.get("description") or "").strip() or "计划维保",
})
return out
def normalize_bom_payload(world: World, payload: dict[str, Any]) -> dict[str, Any]:
"""BOM:编辑行 / 新增行 / 删除行 / 新建 BOM 头(对齐手册产品 BOM)。"""
op = str(payload.get("op") or "").lower()
if payload.get("delete") or op == "delete":
item_id = int(payload.get("itemId") or 0)
bi = next((i for i in world["bomItems"] if i["id"] == item_id), None)
if bi is None:
raise ValueError(f"BOM 明细不存在:{item_id}")
return {"op": "delete_item", "itemId": item_id, "bomId": bi["bomId"], "materialId": bi["materialId"]}
if op == "create_bom" or (not payload.get("itemId") and payload.get("productId") and not payload.get("bomId") and not payload.get("materialId")):
pid = int(payload.get("productId") or 0)
prod = _find_material(world, pid)
if prod["type"] not in ("FINISHED_PRODUCT", "SEMI_FINISHED"):
raise ValueError("只能为成品/半成品建 BOM")
version = str(payload.get("version") or "V1.0").strip() or "V1.0"
return {
"op": "create_bom", "productId": pid, "version": version,
"isDefault": bool(payload.get("isDefault", True)),
}
if payload.get("bomId") and payload.get("materialId") and not payload.get("itemId"):
bom_id = int(payload["bomId"])
bom = next((b for b in world["boms"] if b["id"] == bom_id), None)
if bom is None:
raise ValueError(f"BOM 不存在:{bom_id}")
mid = int(payload["materialId"])
mat = _find_material(world, mid)
if mid == bom["productId"]:
raise ValueError("BOM 不能自引用产品")
if any(i["bomId"] == bom_id and i["materialId"] == mid for i in world["bomItems"]):
raise ValueError(f"该 BOM 已包含物料 {mat['code']}")
qty = float(payload.get("quantity") or 1)
if qty <= 0:
raise ValueError("单件用量必须为正数")
return {
"op": "add_item", "bomId": bom_id, "materialId": mid,
"quantity": qty, "isKeyMaterial": bool(payload.get("isKeyMaterial", False)),
}
# 默认:编辑已有行
item_id = int(payload.get("itemId") or 0)
bi = next((i for i in world["bomItems"] if i["id"] == item_id), None)
if bi is None:
raise ValueError(f"BOM 明细不存在:{item_id}")
out: dict[str, Any] = {"op": "update_item", "itemId": item_id}
out["quantity"] = float(payload.get("quantity", bi["quantity"]))
if out["quantity"] <= 0:
raise ValueError("单件用量必须为正数")
out["isKeyMaterial"] = bool(payload.get("isKeyMaterial", bi.get("isKeyMaterial", False)))
return out
def normalize_routing_payload(world: World, payload: dict[str, Any]) -> dict[str, Any]:
"""工艺路线:编辑步骤 / 增删步骤 / 新建路线(对齐手册「去配置」简化版)。"""
op = str(payload.get("op") or "").lower()
if payload.get("delete") or op == "delete":
step_id = int(payload.get("stepId") or 0)
step = next((s for s in world["routingSteps"] if s["id"] == step_id), None)
if step is None:
raise ValueError(f"工艺步骤不存在:{step_id}")
return {"op": "delete_step", "stepId": step_id, "routingId": step["routingId"]}
if op == "create_routing" or (payload.get("productId") and not payload.get("stepId") and not payload.get("routingId")):
pid = int(payload.get("productId") or 0)
prod = _find_material(world, pid)
if prod["type"] not in ("FINISHED_PRODUCT", "SEMI_FINISHED"):
raise ValueError("只能为成品/半成品建工艺路线")
version = str(payload.get("version") or "V1.0").strip() or "V1.0"
steps_in = payload.get("steps") or []
norm_steps = []
for idx, s in enumerate(steps_in):
oid = int(s.get("operationId") or 0)
op_row = next((o for o in world["operations"] if o["id"] == oid), None)
if op_row is None:
raise ValueError(f"工序不存在:{oid}")
setup = float(s.get("setupTime", 0))
run = float(s.get("runTimePerUnit", op_row.get("standardTime", 1) or 1))
if setup < 0 or run <= 0:
raise ValueError("准备时间不能为负,单件时间必须为正")
norm_steps.append({
"operationId": oid,
"sequenceNo": int(s.get("sequenceNo") or idx + 1),
"setupTime": setup, "runTimePerUnit": run,
"isExternal": bool(s.get("isExternal", False)),
})
return {
"op": "create_routing", "productId": pid, "version": version,
"isDefault": bool(payload.get("isDefault", True)), "steps": norm_steps,
}
if payload.get("routingId") and payload.get("operationId") and not payload.get("stepId"):
rid = int(payload["routingId"])
routing = next((r for r in world["routings"] if r["id"] == rid), None)
if routing is None:
raise ValueError(f"工艺路线不存在:{rid}")
oid = int(payload["operationId"])
op_row = next((o for o in world["operations"] if o["id"] == oid), None)
if op_row is None:
raise ValueError(f"工序不存在:{oid}")
existing = [s for s in world["routingSteps"] if s["routingId"] == rid]
seq = int(payload.get("sequenceNo") or (max((s["sequenceNo"] for s in existing), default=0) + 1))
setup = float(payload.get("setupTime", 0))
run = float(payload.get("runTimePerUnit", op_row.get("standardTime", 1) or 1))
if setup < 0 or run <= 0:
raise ValueError("准备时间不能为负,单件时间必须为正")
return {
"op": "add_step", "routingId": rid, "operationId": oid, "sequenceNo": seq,
"setupTime": setup, "runTimePerUnit": run,
"isExternal": bool(payload.get("isExternal", False)),
}
step_id = int(payload.get("stepId") or 0)
step = next((s for s in world["routingSteps"] if s["id"] == step_id), None)
if step is None:
raise ValueError(f"工艺步骤不存在:{step_id}")
out: dict[str, Any] = {"op": "update_step", "stepId": step_id}
out["setupTime"] = float(payload.get("setupTime", step["setupTime"]))
out["runTimePerUnit"] = float(payload.get("runTimePerUnit", step["runTimePerUnit"]))
if out["setupTime"] < 0 or out["runTimePerUnit"] <= 0:
raise ValueError("准备时间不能为负,单件时间必须为正")
out["isExternal"] = bool(payload.get("isExternal", step.get("isExternal", False)))
return out
def normalize_operation_payload(world: World, payload: dict[str, Any]) -> dict[str, Any]:
"""工序库新建/编辑。"""
if payload.get("id"):
oid = int(payload["id"])
op_row = next((o for o in world["operations"] if o["id"] == oid), None)
if op_row is None:
raise ValueError(f"工序不存在:{oid}")
code = str(payload.get("code") or op_row["code"]).strip()
name = str(payload.get("name") or op_row["name"]).strip()
if not code or not name:
raise ValueError("工序编码与名称不能为空")
if any(o["code"] == code and o["id"] != oid for o in world["operations"]):
raise ValueError(f"工序编码已存在:{code}")
return {
"op": "update", "id": oid, "code": code, "name": name,
"type": str(payload.get("type") or op_row.get("type", "INTERNAL")).upper(),
"standardTime": float(payload.get("standardTime", op_row.get("standardTime", 0))),
}
code = str(payload.get("code") or "").strip()
name = str(payload.get("name") or "").strip()
if not code or not name:
raise ValueError("新建工序必须提供编码与名称")
if any(o["code"] == code for o in world["operations"]):
raise ValueError(f"工序编码已存在:{code}")
return {
"op": "create", "code": code, "name": name,
"type": str(payload.get("type") or "INTERNAL").upper(),
"standardTime": float(payload.get("standardTime") or 0),
}
def normalize_line_product_payload(world: World, payload: dict[str, Any]) -> dict[str, Any]:
"""产线-产品绑定:新建 / 更新优先级产能 / 删除(排产前必需,对齐手册)。"""
if payload.get("delete") or str(payload.get("op") or "").lower() == "delete":
lp_id = int(payload.get("id") or 0)
lp = next((x for x in world["lineProducts"] if x["id"] == lp_id), None)
if lp is None:
raise ValueError(f"产线-产品绑定不存在:{lp_id}")
return {"op": "delete", "id": lp_id, "lineId": lp["lineId"], "productId": lp["productId"]}
if payload.get("id"):
lp_id = int(payload["id"])
lp = next((x for x in world["lineProducts"] if x["id"] == lp_id), None)
if lp is None:
raise ValueError(f"产线-产品绑定不存在:{lp_id}")
return {
"op": "update", "id": lp_id,
"standardCapacity": int(float(payload.get("standardCapacity", lp.get("standardCapacity", 0)))),
"priority": int(float(payload.get("priority", lp.get("priority", 99)))),
"setupTime": float(payload.get("setupTime", lp.get("setupTime", 0))),
}
line_id = int(payload.get("lineId") or 0)
product_id = int(payload.get("productId") or 0)
_find_line(world, line_id)
prod = _find_material(world, product_id)
if prod["type"] not in ("FINISHED_PRODUCT", "SEMI_FINISHED"):
raise ValueError("只能绑定成品/半成品到产线")
if any(x["lineId"] == line_id and x["productId"] == product_id for x in world["lineProducts"]):
raise ValueError("该产线已绑定此产品")
return {
"op": "create", "lineId": line_id, "productId": product_id,
"standardCapacity": int(float(payload.get("standardCapacity") or 500)),
"priority": int(float(payload.get("priority") or 99)),
"setupTime": float(payload.get("setupTime") or 30),
}
# ---------------- R71.3:资源停用 / 版本快照 / 周模板 ----------------
def is_resource_inactive(status: Any) -> bool:
"""判断工位/设备状态是否属于排产停用集合(历史版本不回写)。"""
return str(status or "ACTIVE").strip().upper() in RESOURCE_INACTIVE_STATUSES
def active_workstations(world: World) -> list[dict[str, Any]]:
"""只返回可参与新排产的工位。"""
return [ws for ws in world.get("workstations", []) if not is_resource_inactive(ws.get("status"))]
def active_equipment(world: World) -> list[dict[str, Any]]:
"""只返回可参与新排产的设备(RUNNING/ACTIVE 视为可用)。"""
return [eq for eq in world.get("equipment", []) if not is_resource_inactive(eq.get("status"))]
def _append_version_record(world: World, *, kind: str, product_id: int, version: str,
status: str, snapshot: dict[str, Any], note: str) -> dict[str, Any]:
table = world.setdefault("masterdataVersions", [])
row_id = max([int(r.get("id") or 0) for r in table] or [0]) + 1
row = {
"id": row_id,
"kind": kind,
"productId": product_id,
"version": version,
"status": status,
"note": note,
"snapshot": snapshot,
"createdAt": _now(),
}
table.append(row)
return row
def _bom_snapshot(world: World, bom_id: int) -> dict[str, Any]:
bom = next(b for b in world["boms"] if b["id"] == bom_id)
return {
"bom": copy.deepcopy(bom),
"items": [copy.deepcopy(i) for i in world.get("bomItems", []) if i["bomId"] == bom_id],
}
def _routing_snapshot(world: World, routing_id: int) -> dict[str, Any]:
routing = next(r for r in world["routings"] if r["id"] == routing_id)
return {
"routing": copy.deepcopy(routing),
"steps": [copy.deepcopy(s) for s in world.get("routingSteps", []) if s["routingId"] == routing_id],
}
def normalize_workstation_payload(world: World, payload: dict[str, Any]) -> dict[str, Any]:
"""工位新建 / 编辑 / 删除(被未完工工单引用时禁止物理删除)。"""
op = str(payload.get("op") or "").lower()
if payload.get("delete") or op == "delete":
wid = int(payload.get("id") or 0)
ws = next((w for w in world["workstations"] if w["id"] == wid), None)
if ws is None:
raise ValueError(f"工位不存在:{wid}")
refs = [wo for wo in world.get("workOrders", [])
if wo.get("workstationId") == wid and wo.get("status") not in ("COMPLETED", "CANCELLED")]
return {"op": "delete", "id": wid, "lineId": ws["lineId"], "code": ws["code"], "name": ws["name"],
"openWorkOrderCount": len(refs)}
code = str(payload.get("code") or "").strip()
name = str(payload.get("name") or "").strip()
if not code or not name:
raise ValueError("工位编码与名称不能为空")
if payload.get("id"):
wid = int(payload["id"])
ws = next((w for w in world["workstations"] if w["id"] == wid), None)
if ws is None:
raise ValueError(f"工位不存在:{wid}")
if any(w["code"] == code and w["id"] != wid for w in world["workstations"]):
raise ValueError(f"工位编码已存在:{code}")
status = str(payload.get("status") or ws.get("status", "ACTIVE")).upper()
if status not in WORKSTATION_STATUSES:
raise ValueError("工位状态必须是 ACTIVE/INACTIVE/DISABLED")
return {
"op": "update", "id": wid, "lineId": int(payload.get("lineId", ws["lineId"])),
"code": code, "name": name, "status": status,
"sequenceNo": int(payload.get("sequenceNo", ws.get("sequenceNo", 0))),
}
if any(w["code"] == code for w in world["workstations"]):
raise ValueError(f"工位编码已存在:{code}")
line_id = int(payload.get("lineId") or 0)
_find_line(world, line_id)
return {
"op": "create", "lineId": line_id, "code": code, "name": name,
"sequenceNo": int(payload.get("sequenceNo", 0)),
"status": str(payload.get("status") or "ACTIVE").upper(),
}
def normalize_equipment_payload(world: World, payload: dict[str, Any]) -> dict[str, Any]:
"""设备新建 / 编辑 / 删除(挂有维保计划时禁止物理删除)。"""
op = str(payload.get("op") or "").lower()
if payload.get("delete") or op == "delete":
eid = int(payload.get("id") or 0)
eq = next((e for e in world["equipment"] if e["id"] == eid), None)
if eq is None:
raise ValueError(f"设备不存在:{eid}")
open_mnt = [m for m in world.get("maintenance", [])
if m.get("equipmentId") == eid and m.get("status") != "CANCELLED"]
return {"op": "delete", "id": eid, "workstationId": eq["workstationId"],
"code": eq["code"], "name": eq["name"], "openMaintenanceCount": len(open_mnt)}
existing = next((e for e in world.get("equipment", []) if e["id"] == int(payload.get("id") or 0)), {})
code = str(payload.get("code") or existing.get("code") or "").strip()
name = str(payload.get("name") or existing.get("name") or "").strip()
if existing and code != existing["code"] and any(e.get("code") == existing["code"] for e in world.get("flexEquipment", [])):
raise ValueError("已关联排产的设备编码不能直接修改;请保留原编码")
if not code or not name:
raise ValueError("设备编码与名称不能为空")
if payload.get("id"):
eid = int(payload["id"])
eq = next((e for e in world["equipment"] if e["id"] == eid), None)
if eq is None:
raise ValueError(f"设备不存在:{eid}")
if any(e["code"] == code and e["id"] != eid for e in world["equipment"]):
raise ValueError(f"设备编码已存在:{code}")
status = str(payload.get("status") or eq.get("status", "RUNNING")).upper()
if status not in EQUIPMENT_STATUSES:
raise ValueError("设备状态必须是 ACTIVE/RUNNING/MAINTENANCE/INACTIVE/DISABLED")
ws_id = int(payload.get("workstationId", eq["workstationId"]))
if next((w for w in world["workstations"] if w["id"] == ws_id), None) is None:
raise ValueError(f"工位不存在:{ws_id}")
return {
"op": "update", "id": eid, "workstationId": ws_id, "code": code, "name": name,
"model": str(payload.get("model", eq.get("model", ""))).strip(),
"capacityPerHour": float(payload.get("capacityPerHour", eq.get("capacityPerHour", 0))),
"efficiencyFactor": float(payload.get("efficiencyFactor", eq.get("efficiencyFactor", 1.0))),
"availabilityRate": float(payload.get("availabilityRate", eq.get("availabilityRate", 1.0))),
"status": status,
}
if any(e["code"] == code for e in world["equipment"]):
raise ValueError(f"设备编码已存在:{code}")
ws_id = int(payload.get("workstationId") or 0)
if next((w for w in world["workstations"] if w["id"] == ws_id), None) is None:
raise ValueError(f"工位不存在:{ws_id}")
status = str(payload.get("status") or "RUNNING").upper()
if status not in EQUIPMENT_STATUSES:
raise ValueError("设备状态必须是 ACTIVE/RUNNING/MAINTENANCE/INACTIVE/DISABLED")
return {
"op": "create", "workstationId": ws_id, "code": code, "name": name,
"model": str(payload.get("model") or "").strip(),
"capacityPerHour": float(payload.get("capacityPerHour") or 0),
"efficiencyFactor": float(payload.get("efficiencyFactor") or 1.0),
"availabilityRate": float(payload.get("availabilityRate") or 1.0),
"status": status,
}
def normalize_bom_release_payload(world: World, payload: dict[str, Any]) -> dict[str, Any]:
"""BOM 发布:校验 BOM 头存在并生成版本快照。"""
bom_id = int(payload.get("bomId") or 0)
bom = next((b for b in world["boms"] if b["id"] == bom_id), None)
if bom is None:
raise ValueError(f"BOM 不存在:{bom_id}")
if any(v["kind"] == "BOM" and v["productId"] == bom["productId"] and v["version"] == bom["version"]
and v["status"] == "RELEASED" for v in world.get("masterdataVersions", [])):
raise ValueError(f"BOM 版本已发布:{bom['version']}")
return {"bomId": bom_id, "productId": bom["productId"], "version": bom["version"]}
def normalize_routing_release_payload(world: World, payload: dict[str, Any]) -> dict[str, Any]:
"""工艺路线发布:校验路线存在并生成版本快照。"""
routing_id = int(payload.get("routingId") or 0)
routing = next((r for r in world["routings"] if r["id"] == routing_id), None)
if routing is None:
raise ValueError(f"工艺路线不存在:{routing_id}")
if any(v["kind"] == "ROUTING" and v["productId"] == routing["productId"]
and v["version"] == routing["version"] and v["status"] == "RELEASED"
for v in world.get("masterdataVersions", [])):
raise ValueError(f"工艺路线版本已发布:{routing['version']}")
return {"routingId": routing_id, "productId": routing["productId"], "version": routing["version"]}
def normalize_bom_rollback_payload(world: World, payload: dict[str, Any]) -> dict[str, Any]:
"""BOM 回滚:目标版本必须来自已发布快照。"""
product_id = int(payload.get("productId") or 0)
version = str(payload.get("version") or "").strip()
if not version:
raise ValueError("回滚必须指定版本号")
target = next((v for v in world.get("masterdataVersions", [])
if v["kind"] == "BOM" and v["productId"] == product_id and v["version"] == version
and v["status"] == "RELEASED"), None)
if target is None:
raise ValueError(f"未找到已发布的 BOM 版本:{product_id} / {version}")
return {"productId": product_id, "version": version, "target": target}
def normalize_routing_rollback_payload(world: World, payload: dict[str, Any]) -> dict[str, Any]:
"""工艺路线回滚:目标版本必须来自已发布快照。"""
product_id = int(payload.get("productId") or 0)
version = str(payload.get("version") or "").strip()
if not version:
raise ValueError("回滚必须指定版本号")
target = next((v for v in world.get("masterdataVersions", [])
if v["kind"] == "ROUTING" and v["productId"] == product_id and v["version"] == version
and v["status"] == "RELEASED"), None)
if target is None:
raise ValueError(f"未找到已发布的工艺路线版本:{product_id} / {version}")
return {"productId": product_id, "version": version, "target": target}
def normalize_calendar_template_payload(world: World, payload: dict[str, Any]) -> dict[str, Any]:
"""班次日历周模板新建(工作日班次模板 + 节假日排除表)。"""
name = str(payload.get("name") or "").strip()
if not name:
raise ValueError("模板名称不能为空")
shifts = payload.get("shifts") or []
if not shifts:
raise ValueError("模板至少需要一个班次")
norm_shifts = []
for idx, shift in enumerate(shifts):
shift_id = int(shift.get("shiftId") or 0)
if next((s for s in world["shifts"] if s["id"] == shift_id), None) is None:
raise ValueError(f"班次不存在:{shift_id}")
workdays = [int(d) for d in (shift.get("workdays") or [0, 1, 2, 3, 4])]
if not workdays or any(d < 0 or d > 6 for d in workdays):
raise ValueError("workdays 必须是 0(周一)~6(周日) 的列表")
norm_shifts.append({
"shiftId": shift_id,
"workdays": sorted(set(workdays)),
"breaks": [dict(b) for b in (shift.get("breaks") or [])],
"teamId": int(shift.get("teamId") or 0) or None,
"maxWorkers": int(shift.get("maxWorkers") or 0) or None,
})
line_id = int(payload.get("lineId") or 0)
_find_line(world, line_id)
holiday_ids = [int(h) for h in (payload.get("holidayIds") or [])]
for hid in holiday_ids:
if next((h for h in world.get("calendarHolidays", []) if h["id"] == hid), None) is None:
raise ValueError(f"节假日不存在:{hid}")
return {
"name": name, "lineId": line_id, "shifts": norm_shifts,
"holidayIds": holiday_ids,
"description": str(payload.get("description") or "").strip(),
}
def normalize_calendar_holiday_payload(world: World, payload: dict[str, Any]) -> dict[str, Any]:
"""节假日新增/编辑:日期唯一,执行期重新校验以拒绝出卡后漂移。"""
holiday_id = int(payload.get("id") or 0)
date_text = str(payload.get("date") or "").strip()
try:
parsed = date.fromisoformat(date_text)
except ValueError as exc:
raise ValueError("节假日日期必须是 YYYY-MM-DD") from exc
if parsed.isoformat() != date_text:
raise ValueError("节假日日期必须是 YYYY-MM-DD")
name = str(payload.get("name") or "").strip()
if not name:
raise ValueError("节假日名称不能为空")
rows = world.get("calendarHolidays", [])
current = next((row for row in rows if row.get("id") == holiday_id), None) if holiday_id else None
if holiday_id and current is None:
raise ValueError(f"节假日不存在:{holiday_id}")
if any(row.get("date") == date_text and row.get("id") != holiday_id for row in rows):
raise ValueError(f"节假日已存在:{date_text}")
return {
"op": "update" if current is not None else "create",
"id": holiday_id or None,
"date": date_text,
"name": name,
"note": str(payload.get("note") or "").strip(),
}
def normalize_calendar_week_copy_payload(world: World, payload: dict[str, Any]) -> dict[str, Any]:
"""复制周模板:目标日期范围 + 可选产线列表。"""
template_id = int(payload.get("templateId") or 0)
template = next((tpl for tpl in world.get("calendarTemplates", []) if tpl["id"] == template_id), None)
if template is None:
raise ValueError(f"日历模板不存在:{template_id}")
start = str(payload.get("startDate") or "").strip()
end = str(payload.get("endDate") or "").strip()
if not start or not end or end < start:
raise ValueError("复制周必须提供 startDate/endDate 且结束不早于开始")
line_ids = [int(x) for x in (payload.get("lineIds") or [template["lineId"]])]
for lid in line_ids:
_find_line(world, lid)
return {
"templateId": template_id, "startDate": start, "endDate": end,
"lineIds": sorted(set(line_ids)),
}
def apply_workstation_action(world: World, next_id, payload: dict[str, Any]) -> dict[str, Any]:
p = normalize_workstation_payload(world, payload)
if p["op"] == "delete":
if p["openWorkOrderCount"]:
raise ValueError("该工位仍有未完工工单引用,禁止物理删除;请先停用")
ws = next(w for w in world["workstations"] if w["id"] == p["id"])
world["workstations"] = [w for w in world["workstations"] if w["id"] != p["id"]]
return {"kind": "WORKSTATION", "id": p["id"], "name": ws["name"],
"beforeStatus": ws.get("status", "ACTIVE"), "afterStatus": None}
if p["op"] == "create":
wid = next_id("workstation")
world["workstations"].append({
"id": wid, "lineId": p["lineId"], "code": p["code"], "name": p["name"],
"sequenceNo": p["sequenceNo"], "status": p["status"], "createdAt": _now(),
})
return {"kind": "WORKSTATION", "id": wid, "name": p["name"],
"beforeStatus": None, "afterStatus": p["status"]}
ws = next(w for w in world["workstations"] if w["id"] == p["id"])
before_status = ws.get("status", "ACTIVE")
ws.update({"lineId": p["lineId"], "code": p["code"], "name": p["name"],
"sequenceNo": p["sequenceNo"], "status": p["status"], "updatedAt": _now()})
return {"kind": "WORKSTATION", "id": ws["id"], "name": ws["name"],
"beforeStatus": before_status, "afterStatus": p["status"]}
def apply_equipment_action(world: World, next_id, payload: dict[str, Any]) -> dict[str, Any]:
p = normalize_equipment_payload(world, payload)
if p["op"] == "delete":
if p["openMaintenanceCount"]:
raise ValueError("该设备仍有未取消维保计划,禁止物理删除;请先停用")
eq = next(e for e in world["equipment"] if e["id"] == p["id"])
world["equipment"] = [e for e in world["equipment"] if e["id"] != p["id"]]
return {"kind": "EQUIPMENT", "id": p["id"], "name": eq["name"],
"beforeStatus": eq.get("status", "RUNNING"), "afterStatus": None}
if p["op"] == "create":
eid = next_id("equipment")
world["equipment"].append({
"id": eid, "workstationId": p["workstationId"], "code": p["code"], "name": p["name"],
"model": p["model"], "capacityPerHour": p["capacityPerHour"],
"efficiencyFactor": p["efficiencyFactor"], "availabilityRate": p["availabilityRate"],
"status": p["status"], "createdAt": _now(),
})
return {"kind": "EQUIPMENT", "id": eid, "name": p["name"],
"beforeStatus": None, "afterStatus": p["status"]}
eq = next(e for e in world["equipment"] if e["id"] == p["id"])
before_status = eq.get("status", "RUNNING")
eq.update({"workstationId": p["workstationId"], "code": p["code"], "name": p["name"],
"model": p["model"], "capacityPerHour": p["capacityPerHour"],
"efficiencyFactor": p["efficiencyFactor"], "availabilityRate": p["availabilityRate"],
"status": p["status"], "updatedAt": _now()})
return {"kind": "EQUIPMENT", "id": eq["id"], "name": eq["name"],
"beforeStatus": before_status, "afterStatus": p["status"]}
def apply_bom_release(world: World, next_id, payload: dict[str, Any]) -> dict[str, Any]:
p = normalize_bom_release_payload(world, payload)
snapshot = _bom_snapshot(world, p["bomId"])
record = _append_version_record(world, kind="BOM", product_id=p["productId"], version=p["version"],
status="RELEASED", snapshot=snapshot, note="人工发布 BOM 版本")
return {"kind": "BOM_RELEASE", "id": record["id"], "name": f"BOM {p['version']}",
"productId": p["productId"], "version": p["version"], "beforeStatus": None, "afterStatus": "RELEASED"}
def apply_bom_rollback(world: World, next_id, payload: dict[str, Any]) -> dict[str, Any]:
p = normalize_bom_rollback_payload(world, payload)
snapshot = p["target"]["snapshot"]
for b in world["boms"]:
if b["productId"] == p["productId"]:
b["isDefault"] = False
b["status"] = "ARCHIVED"
new_bom = copy.deepcopy(snapshot["bom"])
new_bom["id"] = next_id("bom")
new_bom["isDefault"] = True
new_bom["status"] = "ACTIVE"
world["boms"].append(new_bom)
for item in snapshot["items"]:
row = copy.deepcopy(item)
row["id"] = next_id("bomItem")
row["bomId"] = new_bom["id"]
world.setdefault("bomItems", []).append(row)
record = _append_version_record(world, kind="BOM", product_id=p["productId"], version=p["version"],
status="ROLLED_BACK", snapshot=_bom_snapshot(world, new_bom["id"]),
note=f"回滚到已发布版本 {p['version']}")
return {"kind": "BOM_ROLLBACK", "id": record["id"], "name": f"BOM {p['version']}",
"productId": p["productId"], "version": p["version"], "beforeStatus": "ACTIVE", "afterStatus": "ACTIVE"}
def apply_routing_release(world: World, next_id, payload: dict[str, Any]) -> dict[str, Any]:
p = normalize_routing_release_payload(world, payload)
snapshot = _routing_snapshot(world, p["routingId"])
record = _append_version_record(world, kind="ROUTING", product_id=p["productId"], version=p["version"],
status="RELEASED", snapshot=snapshot, note="人工发布工艺路线版本")
return {"kind": "ROUTING_RELEASE", "id": record["id"], "name": f"工艺 {p['version']}",
"productId": p["productId"], "version": p["version"], "beforeStatus": None, "afterStatus": "RELEASED"}
def apply_routing_rollback(world: World, next_id, payload: dict[str, Any]) -> dict[str, Any]:
p = normalize_routing_rollback_payload(world, payload)
snapshot = p["target"]["snapshot"]
for r in world["routings"]:
if r["productId"] == p["productId"]:
r["isDefault"] = False
r["status"] = "ARCHIVED"
new_routing = copy.deepcopy(snapshot["routing"])
new_routing["id"] = next_id("routing")
new_routing["isDefault"] = True
new_routing["status"] = "ACTIVE"
world["routings"].append(new_routing)
for step in snapshot["steps"]:
row = copy.deepcopy(step)
row["id"] = next_id("routingStep")
row["routingId"] = new_routing["id"]
world.setdefault("routingSteps", []).append(row)
record = _append_version_record(world, kind="ROUTING", product_id=p["productId"], version=p["version"],
status="ROLLED_BACK", snapshot=_routing_snapshot(world, new_routing["id"]),
note=f"回滚到已发布版本 {p['version']}")
return {"kind": "ROUTING_ROLLBACK", "id": record["id"], "name": f"工艺 {p['version']}",
"productId": p["productId"], "version": p["version"], "beforeStatus": "ACTIVE", "afterStatus": "ACTIVE"}
def apply_calendar_template_create(world: World, next_id, payload: dict[str, Any]) -> dict[str, Any]:
p = normalize_calendar_template_payload(world, payload)
tpl = {
"id": next_id("calendarTemplate"), "name": p["name"], "lineId": p["lineId"],
"shifts": p["shifts"], "holidayIds": p["holidayIds"],
"description": p["description"], "createdAt": _now(),
}
world.setdefault("calendarTemplates", []).append(tpl)
return {"kind": "CALENDAR_TEMPLATE", "id": tpl["id"], "name": tpl["name"],
"beforeStatus": None, "afterStatus": "ACTIVE"}
def apply_calendar_week_copy(world: World, next_id, payload: dict[str, Any]) -> dict[str, Any]:
from datetime import date
p = normalize_calendar_week_copy_payload(world, payload)
template = next(t for t in world.get("calendarTemplates", []) if t["id"] == p["templateId"])
holidays = {h["date"] for h in world.get("calendarHolidays", [])}
existing = {(row.get("lineId"), row.get("date"), row.get("shiftId"))
for row in world.get("shiftCalendar", [])}
shift_by_weekday: dict[int, list[dict[str, Any]]] = {}
for shift in template["shifts"]:
for day in shift["workdays"]:
shift_by_weekday.setdefault(day, []).append(shift)
cursor = date.fromisoformat(p["startDate"])
end = date.fromisoformat(p["endDate"])
generated = 0
skipped_holiday = 0
skipped_weekend = 0
skipped_existing = 0
while cursor <= end:
date_str = cursor.isoformat()
if date_str in holidays:
skipped_holiday += 1
cursor += timedelta(days=1)
continue
if cursor.weekday() >= 5 and not shift_by_weekday.get(cursor.weekday()):
skipped_weekend += 1
cursor += timedelta(days=1)
continue
for line_id in p["lineIds"]:
for shift in shift_by_weekday.get(cursor.weekday(), []):
key = (line_id, date_str, shift["shiftId"])
if key in existing:
skipped_existing += 1
continue
world.setdefault("shiftCalendar", []).append({
"id": next_id("shiftCalendar"),
"lineId": line_id, "date": date_str, "shiftId": shift["shiftId"],
"isWorking": True,
"teamId": shift.get("teamId"), "maxWorkers": shift.get("maxWorkers"),
"source": f"calendarTemplate:{template['id']}",
})
existing.add(key)
generated += 1
cursor += timedelta(days=1)
return {
"kind": "CALENDAR_WEEK_COPY", "id": template["id"], "name": template["name"],
"startDate": p["startDate"], "endDate": p["endDate"],
"lineIds": p["lineIds"], "generatedCount": generated,
"skippedHolidayCount": skipped_holiday, "skippedWeekendCount": skipped_weekend,
"skippedExistingCount": skipped_existing,
"beforeStatus": None, "afterStatus": "GENERATED",
}
# ---------------- 确认卡(影响面摘要) ----------------
# ---------------- 确认卡(影响面摘要) ----------------
def confirmation_for_master_action(world: World, action: str, payload: dict[str, Any]) -> tuple[str, list[str]]:
"""生成主数据 P2 确认卡标题与影响摘要。"""
world = masterdata_view_world(world)
if action == "master.line.upsert":
p = normalize_line_payload(world, payload)
line = _find_line(world, p["id"])
open_wo = sum(1 for wo in world.get("workOrders", [])
if wo["lineId"] == line["id"] and wo.get("status") not in ("COMPLETED", "CANCELLED"))
lines = [f"产线:{line['name']}({line['code']})→ 名称 {p['name']} · 日产能 {p['capacityPerDay']} · 效率 {p['efficiencyFactor']}"]
if p["status"] != line.get("status", "ACTIVE"):
verb = "停用" if p["status"] == "INACTIVE" else "启用"
lines.append(f"状态变更:{verb}该产线;停用后新排产不再选择它(历史版本不回写)")
if open_wo:
lines.append(f"当前有 {open_wo} 个未完工工单排在该线(仅新版本受影响)")
lines.append("批准后写入主干主数据,执行前自动建档可回滚(P2)")
return f"编辑产线 {line['name']}", lines
if action == "master.material.upsert":
p = normalize_material_payload(world, payload)
if p["op"] == "create":
return f"新建物料 {p['name']}", [
f"编码 {p['code']} · 类型 {p['type']} · 单位 {p['unit']}",
f"库存 {p['stock']} · 在途 {p['inTransit']} · 安全库存 {p['safetyStock']}",
"批准后进入物料库,可继续挂 BOM/工艺路线(P2)",
]
m = _find_material(world, p["id"])
return f"编辑物料 {p['name']}", [
f"物料:{p['name']}({p['code']})· 类型 {p['type']} · 状态 {p['status']}",
f"库存 {m.get('stock', 0)} → {p['stock']} · 在途 {m.get('inTransit', 0)} → {p['inTransit']}",
f"安全库存 {m.get('safetyStock', 0)} → {p['safetyStock']} · 采购前置期 {m.get('procurementLeadTime', 0)} → {p['procurementLeadTime']} 天",
"库存/状态变化会改变后续新排产的齐套与可选产品池(P2)",
]
if action == "master.maintenance.upsert":
p = normalize_maintenance_payload(world, payload)
if p.get("id"):
mnt = next(m for m in world["maintenance"] if m["id"] == p["id"])
verb = "取消" if p["status"] == "CANCELLED" else "恢复"
return f"{verb}维保计划 #{p['id']}", [
f"设备:{mnt.get('description', '')}({mnt['plannedStart']} ~ {mnt['plannedEnd']})",
f"{verb}后新排产的设备避让窗口随之变化(P2)",
]
return f"新增维保:{p['equipmentName']}", [
f"窗口:{p['plannedStart']} ~ {p['plannedEnd']}",
"新排产将避开该窗口或产生 EQUIPMENT 冲突提示(P2)",
]
if action == "master.bom.upsert":
p = normalize_bom_payload(world, payload)
if p["op"] == "create_bom":
prod = _find_material(world, p["productId"])
return f"新建 BOM:{prod['name']} {p['version']}", [
f"产品 {prod['code']} · 默认={'是' if p['isDefault'] else '否'}",
"批准后可继续添加 BOM 明细行(P2)",
]
if p["op"] == "add_item":
bom = next(b for b in world["boms"] if b["id"] == p["bomId"])
prod = _find_material(world, bom["productId"])
mat = _find_material(world, p["materialId"])
return f"BOM 增行:{prod['name']} ← {mat['name']}", [
f"用量 {p['quantity']} {mat['unit']} · 关键料 {'是' if p['isKeyMaterial'] else '否'}",
"增行会改变齐套毛需求与采购建议(P2)",
]
if p["op"] == "delete_item":
bi = next(i for i in world["bomItems"] if i["id"] == p["itemId"])
bom = next(b for b in world["boms"] if b["id"] == bi["bomId"])
prod = _find_material(world, bom["productId"])
mat = _find_material(world, bi["materialId"])
return f"BOM 删行:{prod['name']} ← {mat['name']}", [
f"将移除用量 {bi['quantity']} {mat['unit']}",
"删行会改变齐套毛需求与采购建议(P2)",
]
bi = next(i for i in world["bomItems"] if i["id"] == p["itemId"])
mat = _find_material(world, bi["materialId"])
bom = next(b for b in world["boms"] if b["id"] == bi["bomId"])
prod = _find_material(world, bom["productId"])
return f"编辑 BOM 明细:{prod['name']} ← {mat['name']}", [
f"BOM {bom['version']}({prod['name']})",
f"单件用量 {bi['quantity']} → {p['quantity']} {mat['unit']} · 关键料 {'是' if p['isKeyMaterial'] else '否'}",
"用量变化直接改变后续新排产的齐套需求与采购建议(P2)",
]
if action == "master.routing.upsert":
p = normalize_routing_payload(world, payload)
if p["op"] == "create_routing":
prod = _find_material(world, p["productId"])
return f"新建工艺路线:{prod['name']} {p['version']}", [
f"初始步骤 {len(p['steps'])} 道 · 默认={'是' if p['isDefault'] else '否'}",
"批准后可继续增删步骤(对齐手册去配置)(P2)",
]
if p["op"] == "add_step":
routing = next(r for r in world["routings"] if r["id"] == p["routingId"])
prod = _find_material(world, routing["productId"])
op = next(o for o in world["operations"] if o["id"] == p["operationId"])
return f"工艺增步:{prod['name']} · {op['name']}", [
f"序号 #{p['sequenceNo']} · 准备 {p['setupTime']} 分 · 单件 {p['runTimePerUnit']} 分",
"增步改变占槽路径与委外建议(P2)",
]
if p["op"] == "delete_step":
step = next(s for s in world["routingSteps"] if s["id"] == p["stepId"])
routing = next(r for r in world["routings"] if r["id"] == step["routingId"])
prod = _find_material(world, routing["productId"])
op = next((o for o in world["operations"] if o["id"] == step["operationId"]), {})
return f"工艺删步:{prod['name']} · {op.get('name', '')}", [
f"将移除第 {step['sequenceNo']} 步",
"删步改变占槽路径(P2)",
]
step = next(s for s in world["routingSteps"] if s["id"] == p["stepId"])
routing = next(r for r in world["routings"] if r["id"] == step["routingId"])
prod = _find_material(world, routing["productId"])
op = next((o for o in world["operations"] if o["id"] == step["operationId"]), {})
lines = [
f"工艺 {routing['version']}({prod['name']})第 {step['sequenceNo']} 步 · {op.get('name', '')}",
f"准备 {step['setupTime']} → {p['setupTime']} 分 · 单件 {step['runTimePerUnit']} → {p['runTimePerUnit']} 分",
]
if p["isExternal"] != step.get("isExternal", False):
lines.append("外协标记变更:外协工序在订单分解时生成委外订单建议" if p["isExternal"]
else "外协标记取消:该工序回归厂内排产")
lines.append("工时变化直接影响后续新排产的占槽时长(P2)")
return f"编辑工艺步骤:{prod['name']} · {op.get('name', '')}", lines
if action == "master.operation.upsert":
p = normalize_operation_payload(world, payload)
verb = "新建" if p["op"] == "create" else "编辑"
return f"{verb}工序 {p['name']}", [
f"编码 {p['code']} · 类型 {p['type']} · 标准工时 {p['standardTime']}",
"工序库变更影响后续新挂工艺路线可选步骤(P2)",
]
if action == "master.lineProduct.upsert":
p = normalize_line_product_payload(world, payload)
if p["op"] == "delete":
line = _find_line(world, p["lineId"])
prod = _find_material(world, p["productId"])
return f"解除绑定:{line['name']} × {prod['name']}", [
"解除后该产品不能再排到该产线;若无其它绑定则排产将报缺失项(P2)",
]
if p["op"] == "create":
line = _find_line(world, p["lineId"])
prod = _find_material(world, p["productId"])
return f"绑定产线产品:{line['name']} × {prod['name']}", [
f"标准产能 {p['standardCapacity']}/日 · 优先级 {p['priority']} · 换型 {p['setupTime']} 分",
"排产前必须有产线-产品绑定(对齐聚制云)(P2)",
]
lp = next(x for x in world["lineProducts"] if x["id"] == p["id"])
line = _find_line(world, lp["lineId"])
prod = _find_material(world, lp["productId"])
return f"更新绑定:{line['name']} × {prod['name']}", [
f"产能 {p['standardCapacity']} · 优先级 {p['priority']} · 换型 {p['setupTime']} 分",
"影响新排产选线优先级与换型占用(P2)",
]
if action == "master.changeover.upsert":
from server.aps_domain.changeover import normalize_changeover_payload
p = normalize_changeover_payload(world, payload)
if p["op"] == "delete":
return f"删除换型:{p['fromFamily']} → {p['toFamily']}", [
"删除后跨族换型回退缺省 30 分钟(同族仍为 0)",
"影响新排产首道工序额外准备时间(P2)",
]
return f"换型矩阵:{p['fromFamily']} → {p['toFamily']} = {p['setupMinutes']} 分", [
f"产品族切换准备时间 {p['setupMinutes']} 分钟" + (f";{p['note']}" if p.get("note") else ""),
"RuleEngine 在同线相邻订单跨族时叠加到首道工序(C10,可关)",
"批准后写入主干,执行前自动建档可回滚(P2)",
]
if action == "master.clear":
scope = str(payload.get("scope") or "all").lower()
if scope not in CLEAR_SCOPES:
raise ValueError("清理范围必须是 resource/process/calendar/flex/all")
labels = {
"resource": "资源(工厂/车间/产线/工位/设备)",
"process": "工艺模型(物料/BOM/工序/路线/产线绑定)",
"calendar": "日历与维保(维保计划)",
"flex": "柔性资源(设备/模具/工序池/区域/柔性BOM路线订单)",
"all": "主数据全板块(资源+工艺+维保+柔性)",
}
counts: list[str] = []
if scope in ("resource", "all"):
counts.append(f"产线 {len(world.get('lines', []))} / 设备 {len(world.get('equipment', []))}")
if scope in ("process", "all"):
counts.append(f"物料 {len(world.get('materials', []))} / BOM {len(world.get('boms', []))}")
if scope in ("calendar", "all"):
counts.append(f"维保 {len(world.get('maintenance', []))}")
if scope in ("flex", "all"):
counts.append(
f"柔性设备 {len(world.get('flexEquipment', []))} / 模具 {len(world.get('flexMolds', []))}"
)
return f"一键清理主数据 · {labels[scope]}", [
f"范围:{labels[scope]}",
f"将清空:{';'.join(counts) or '(当前无数据)'}",
"清空后列表为空,需重新录入或导入;订单与历史排产版本不碰",
"执行前自动建档,可回滚(P2)",
]
if action == "master.workstation.upsert":
p = normalize_workstation_payload(world, payload)
if p["op"] == "create":
return f"新建工位 {p['name']}", [
f"编码 {p['code']} · 产线 {p['lineId']} · 顺序 {p['sequenceNo']}",
"批准后进入资源池,可继续挂设备(P2)",
]
if p["op"] == "delete":
ws = next(w for w in world["workstations"] if w["id"] == p["id"])
line = [f"当前有 {p['openWorkOrderCount']} 个未完工工单引用该工位(仅新版本受影响)"] if p["openWorkOrderCount"] else []
return f"删除工位 {ws['name']}", [
f"工位:{ws['name']}({ws['code']})",
*line,
"删除后新排产不再选择该工位;历史排产版本不回写(P2)",
]
ws = next(w for w in world["workstations"] if w["id"] == p["id"])
lines = [f"工位:{ws['name']}({ws['code']})→ {p['name']} · 顺序 {p['sequenceNo']}"]
if p["status"] != ws.get("status", "ACTIVE"):
verb = "停用" if p["status"] in ("INACTIVE", "DISABLED") else "启用"
lines.append(f"状态变更:{verb}该工位;停用后新排产不再选择它(历史版本不回写)")
lines.append("批准后写入主干主数据,执行前自动建档可回滚(P2)")
return f"编辑工位 {ws['name']}", lines
if action == "master.equipment.upsert":
p = normalize_equipment_payload(world, payload)
if p["op"] == "create":
return f"新建设备 {p['name']}", [
f"编码 {p['code']} · 工位 {p['workstationId']} · 状态 {p['status']}",
"批准后进入设备台账;停用设备不参与新排产(P2)",
]
if p["op"] == "delete":
eq = next(e for e in world["equipment"] if e["id"] == p["id"])
line = [f"当前有 {p['openMaintenanceCount']} 条未取消维保计划(建议先停用)"] if p["openMaintenanceCount"] else []
return f"删除设备 {eq['name']}", [
f"设备:{eq['name']}({eq['code']})",
*line,
"删除后新排产不再选择该设备;历史排产版本不回写(P2)",
]
eq = next(e for e in world["equipment"] if e["id"] == p["id"])
lines = [f"设备:{eq['name']}({eq['code']})→ {p['name']} · 状态 {p['status']}"]
if p["status"] in ("INACTIVE", "DISABLED") and eq.get("status") not in ("INACTIVE", "DISABLED"):
lines.append("状态变更为停用;停用后新排产不再选择该设备(历史版本不回写)")
lines.append("批准后写入主干主数据,执行前自动建档可回滚(P2)")
return f"编辑设备 {eq['name']}", lines
if action == "master.bom.release":
p = normalize_bom_release_payload(world, payload)
prod = _find_material(world, p["productId"])
items = [i for i in world.get("bomItems", []) if i["bomId"] == p["bomId"]]
return f"发布 BOM 版本:{prod['name']} {p['version']}", [
f"产品 {prod['code']} · 明细行 {len(items)}",
"发布后生成不可变版本快照,历史可查、可回滚(P2)",
]
if action == "master.routing.release":
p = normalize_routing_release_payload(world, payload)
prod = _find_material(world, p["productId"])
steps = [s for s in world.get("routingSteps", []) if s["routingId"] == p["routingId"]]
return f"发布工艺路线版本:{prod['name']} {p['version']}", [
f"产品 {prod['code']} · 工序步骤 {len(steps)}",
"发布后生成不可变版本快照,历史可查、可回滚(P2)",
]
if action == "master.bom.rollback":
p = normalize_bom_rollback_payload(world, payload)
prod = _find_material(world, p["productId"])
return f"回滚 BOM:{prod['name']} → {p['version']}", [
f"将把当前默认 BOM 归档,并恢复 {p['version']} 发布快照为默认(P2)",
]
if action == "master.routing.rollback":
p = normalize_routing_rollback_payload(world, payload)
prod = _find_material(world, p["productId"])
return f"回滚工艺路线:{prod['name']} → {p['version']}", [
f"将把当前默认工艺归档,并恢复 {p['version']} 发布快照为默认(P2)",
]
if action == "master.calendar.template.create":
p = normalize_calendar_template_payload(world, payload)
return f"新建日历周模板:{p['name']}", [
f"产线 {p['lineId']} · 班次 {len(p['shifts'])} 个 · 节假日 {len(p['holidayIds'])} 天",
"批准后可用于复制周生成多周班次日历(P2)",
]
if action == "master.calendar.holiday.upsert":
p = normalize_calendar_holiday_payload(world, payload)
verb = "编辑" if p["op"] == "update" else "登记"
return f"{verb}节假日:{p['date']} {p['name']}", [
f"日期 {p['date']} · 名称 {p['name']}",
"批准后写入主干日历,并影响后续周模板复制与新排产(P2)",
]
if action == "master.calendar.week.copy":
p = normalize_calendar_week_copy_payload(world, payload)
template = next(t for t in world.get("calendarTemplates", []) if t["id"] == p["templateId"])
from datetime import date as _date
days = (_date.fromisoformat(p["endDate"]) - _date.fromisoformat(p["startDate"])).days + 1
return f"复制日历周模板:{template['name']}", [
f"范围 {p['startDate']} ~ {p['endDate']}({days} 天)· 产线 {len(p['lineIds'])} 条",
"将按模板班次生成工作班次,周末与节假日自动排除;已排日期不覆盖(P2)",
]
raise ValueError(f"不支持的主数据动作:{action}")
# ---------------- P2 写入(调用方负责门禁/快照/审计/落盘) ----------------
@synchronized_master_action
def apply_master_action(world: World, next_id, action: str, payload: dict[str, Any]) -> dict[str, Any]:
"""应用主数据写入动作到内存世界(P2 动作的业务侧实现)。"""
if action == "master.line.upsert":
p = normalize_line_payload(world, payload)
line = _find_line(world, p["id"])
before_status = line.get("status", "ACTIVE")
line.update({"name": p["name"], "capacityPerDay": p["capacityPerDay"],
"efficiencyFactor": p["efficiencyFactor"], "status": p["status"]})
return {"kind": "LINE", "id": line["id"], "name": line["name"],
"beforeStatus": before_status, "afterStatus": p["status"]}
if action == "master.material.upsert":
p = normalize_material_payload(world, payload)
if p["op"] == "create":
mid = int(p.get("newId") or 0) or next_id("material")
row = {
"id": mid, "code": p["code"], "name": p["name"], "spec": p["spec"],
"type": p["type"], "unit": p["unit"],
"stock": p["stock"], "inTransit": p["inTransit"],
"safetyStock": p["safetyStock"], "procurementLeadTime": p["procurementLeadTime"],
"productFamily": p.get("productFamily") or "",
"status": "ACTIVE", "createdAt": _now(),
}
world["materials"].append(row)
return {"kind": "MATERIAL", "id": mid, "name": p["name"],
"beforeStock": None, "afterStock": p["stock"]}
m = _find_material(world, p["id"])
before_stock = m.get("stock", 0)
m.update({
"code": p["code"], "name": p["name"], "spec": p["spec"], "type": p["type"],
"unit": p["unit"], "stock": p["stock"], "inTransit": p["inTransit"],
"safetyStock": p["safetyStock"], "procurementLeadTime": p["procurementLeadTime"],
"status": p["status"],
})
if "productFamily" in p:
m["productFamily"] = p["productFamily"]
return {"kind": "MATERIAL", "id": m["id"], "name": m["name"],
"beforeStock": before_stock, "afterStock": p["stock"]}
if action == "master.changeover.upsert":
from server.aps_domain.changeover import apply_changeover_action
return apply_changeover_action(world, next_id, payload)
if action == "master.maintenance.upsert":
p = normalize_maintenance_payload(world, payload)
if p.get("id"):
mnt = next(m for m in world["maintenance"] if m["id"] == p["id"])
before_status = mnt.get("status", "PLANNED")
mnt["status"] = p["status"]
return {"kind": "MAINTENANCE", "id": mnt["id"], "name": mnt.get("description", ""),
"beforeStatus": before_status, "afterStatus": p["status"]}
mid = next_id("maintenance")
mnt = {"id": mid, "equipmentId": p["equipmentId"], "type": "MAINTENANCE",
"plannedStart": p["plannedStart"], "plannedEnd": p["plannedEnd"],
"status": "PLANNED", "description": p["description"],
"createdAt": _now()}
world["maintenance"].append(mnt)
return {"kind": "MAINTENANCE", "id": mid, "name": p["description"],
"beforeStatus": None, "afterStatus": "PLANNED"}
if action == "master.bom.upsert":
p = normalize_bom_payload(world, payload)
if p["op"] == "create_bom":
if p["isDefault"]:
for b in world["boms"]:
if b["productId"] == p["productId"]:
b["isDefault"] = False
bid = next_id("bom")
world["boms"].append({
"id": bid, "productId": p["productId"], "version": p["version"],
"versionName": f"{p['version']}", "isDefault": p["isDefault"], "status": "ACTIVE",
})
prod = _find_material(world, p["productId"])
return {"kind": "BOM", "id": bid, "name": f"{prod['name']} {p['version']}"}
if p["op"] == "add_item":
iid = next_id("bomItem")
world["bomItems"].append({
"id": iid, "bomId": p["bomId"], "materialId": p["materialId"],
"quantity": p["quantity"], "operationId": None,
"isKeyMaterial": p["isKeyMaterial"],
})
mat = _find_material(world, p["materialId"])
return {"kind": "BOM_ITEM", "id": iid, "name": mat["name"],
"beforeQty": None, "afterQty": p["quantity"]}
if p["op"] == "delete_item":
bi = next(i for i in world["bomItems"] if i["id"] == p["itemId"])
mat = _find_material(world, bi["materialId"])
world["bomItems"] = [i for i in world["bomItems"] if i["id"] != p["itemId"]]
return {"kind": "BOM_ITEM", "id": p["itemId"], "name": mat["name"],
"beforeQty": bi["quantity"], "afterQty": None}
bi = next(i for i in world["bomItems"] if i["id"] == p["itemId"])
before_qty = bi["quantity"]
bi["quantity"] = p["quantity"]
bi["isKeyMaterial"] = p["isKeyMaterial"]
mat = _find_material(world, bi["materialId"])
return {"kind": "BOM_ITEM", "id": bi["id"], "name": mat["name"],
"beforeQty": before_qty, "afterQty": p["quantity"]}
if action == "master.routing.upsert":
p = normalize_routing_payload(world, payload)
if p["op"] == "create_routing":
if p["isDefault"]:
for r in world["routings"]:
if r["productId"] == p["productId"]:
r["isDefault"] = False
rid = next_id("routing")
world["routings"].append({
"id": rid, "productId": p["productId"], "version": p["version"],
"versionName": p["version"], "isDefault": p["isDefault"], "status": "ACTIVE",
})
for s in p["steps"]:
sid = next_id("routingStep")
world["routingSteps"].append({
"id": sid, "routingId": rid, "operationId": s["operationId"],
"sequenceNo": s["sequenceNo"], "prevStepId": None,
"setupTime": s["setupTime"], "runTimePerUnit": s["runTimePerUnit"],
"waitTime": 0, "transferTime": 0, "isExternal": s["isExternal"],
})
prod = _find_material(world, p["productId"])
return {"kind": "ROUTING", "id": rid, "name": f"{prod['name']} {p['version']}"}
if p["op"] == "add_step":
sid = next_id("routingStep")
world["routingSteps"].append({
"id": sid, "routingId": p["routingId"], "operationId": p["operationId"],
"sequenceNo": p["sequenceNo"], "prevStepId": None,
"setupTime": p["setupTime"], "runTimePerUnit": p["runTimePerUnit"],
"waitTime": 0, "transferTime": 0, "isExternal": p["isExternal"],
})
op = next(o for o in world["operations"] if o["id"] == p["operationId"])
return {"kind": "ROUTING_STEP", "id": sid, "name": op["name"]}
if p["op"] == "delete_step":
step = next(s for s in world["routingSteps"] if s["id"] == p["stepId"])
op = next((o for o in world["operations"] if o["id"] == step["operationId"]), {})
world["routingSteps"] = [s for s in world["routingSteps"] if s["id"] != p["stepId"]]
return {"kind": "ROUTING_STEP", "id": p["stepId"], "name": op.get("name", "")}
step = next(s for s in world["routingSteps"] if s["id"] == p["stepId"])
before_ext = step.get("isExternal", False)
step["setupTime"] = p["setupTime"]
step["runTimePerUnit"] = p["runTimePerUnit"]
step["isExternal"] = p["isExternal"]
op = next((o for o in world["operations"] if o["id"] == step["operationId"]), {})
return {"kind": "ROUTING_STEP", "id": step["id"], "name": op.get("name", f"步骤{step['sequenceNo']}"),
"beforeExternal": before_ext, "afterExternal": p["isExternal"]}
if action == "master.operation.upsert":
p = normalize_operation_payload(world, payload)
if p["op"] == "create":
oid = next_id("operation")
world["operations"].append({
"id": oid, "code": p["code"], "name": p["name"],
"type": p["type"], "standardTime": p["standardTime"],
})
return {"kind": "OPERATION", "id": oid, "name": p["name"]}
op_row = next(o for o in world["operations"] if o["id"] == p["id"])
op_row.update({
"code": p["code"], "name": p["name"], "type": p["type"],
"standardTime": p["standardTime"],
})
return {"kind": "OPERATION", "id": op_row["id"], "name": op_row["name"]}
if action == "master.lineProduct.upsert":
p = normalize_line_product_payload(world, payload)
if p["op"] == "delete":
lp = next(x for x in world["lineProducts"] if x["id"] == p["id"])
name = f"L{lp['lineId']}-P{lp['productId']}"
world["lineProducts"] = [x for x in world["lineProducts"] if x["id"] != p["id"]]
return {"kind": "LINE_PRODUCT", "id": p["id"], "name": name}
if p["op"] == "create":
lid = next_id("lineProduct")
world["lineProducts"].append({
"id": lid, "lineId": p["lineId"], "productId": p["productId"],
"standardCapacity": p["standardCapacity"], "priority": p["priority"],
"setupTime": p["setupTime"],
})
prod = _find_material(world, p["productId"])
return {"kind": "LINE_PRODUCT", "id": lid, "name": prod["name"]}
lp = next(x for x in world["lineProducts"] if x["id"] == p["id"])
lp.update({
"standardCapacity": p["standardCapacity"],
"priority": p["priority"], "setupTime": p["setupTime"],
})
prod = _find_material(world, lp["productId"])
return {"kind": "LINE_PRODUCT", "id": lp["id"], "name": prod["name"]}
if action == "master.clear":
scope = str(payload.get("scope") or "all").lower()
if scope not in CLEAR_SCOPES:
raise ValueError("清理范围必须是 resource/process/calendar/all")
cleared: list[str] = []
if scope in ("resource", "all"):
for table in _RESOURCE_TABLES:
n = len(world.get(table, []))
world[table] = []
if n:
cleared.append(f"{table}:{n}")
if scope in ("process", "all"):
for table in _PROCESS_TABLES:
# resource 已清过的 lineProducts / workstationOperations 跳过重复统计
if scope == "all" and table in ("lineProducts", "workstationOperations"):
world[table] = []
continue
n = len(world.get(table, []))
world[table] = []
if n:
cleared.append(f"{table}:{n}")
if scope in ("calendar", "all"):
n = len(world.get("maintenance", []))
world["maintenance"] = []
if n:
cleared.append(f"maintenance:{n}")
if scope in ("flex", "all"):
for table in _FLEX_TABLES:
n = len(world.get(table, []) if isinstance(world.get(table), list) else [])
if isinstance(world.get(table), list):
world[table] = []
elif table == "flexParams" and isinstance(world.get(table), dict):
n = len(world.get(table) or {})
world[table] = {}
if n:
cleared.append(f"{table}:{n}")
return {"kind": "MASTER_CLEAR", "id": 0, "name": scope, "cleared": cleared}
if action == "master.workstation.upsert":
return apply_workstation_action(world, next_id, payload)
if action == "master.equipment.upsert":
return apply_equipment_action(world, next_id, payload)
if action == "master.bom.release":
return apply_bom_release(world, next_id, payload)
if action == "master.bom.rollback":
return apply_bom_rollback(world, next_id, payload)
if action == "master.routing.release":
return apply_routing_release(world, next_id, payload)
if action == "master.routing.rollback":
return apply_routing_rollback(world, next_id, payload)
if action == "master.calendar.template.create":
return apply_calendar_template_create(world, next_id, payload)
if action == "master.calendar.holiday.upsert":
p = normalize_calendar_holiday_payload(world, payload)
if p["op"] == "create":
holiday_id = next_id("calendarHoliday")
row = {
"id": holiday_id,
"date": p["date"],
"name": p["name"],
"note": p["note"],
}
world.setdefault("calendarHolidays", []).append(row)
return {
"kind": "CALENDAR_HOLIDAY",
"id": holiday_id,
"name": p["name"],
"beforeDate": None,
"afterDate": p["date"],
}
row = next(item for item in world["calendarHolidays"] if item["id"] == p["id"])
before_date = row.get("date")
row.update({"date": p["date"], "name": p["name"], "note": p["note"]})
return {
"kind": "CALENDAR_HOLIDAY",
"id": row["id"],
"name": row["name"],
"beforeDate": before_date,
"afterDate": row["date"],
}
if action == "master.calendar.week.copy":
return apply_calendar_week_copy(world, next_id, payload)
raise ValueError(f"不支持的主数据动作:{action}")