aps-agent/server/aps_domain/masterdata.py

998 lines
54 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 # 前向类型引用
from datetime import datetime # 时间戳
from typing import Any # 类型标注
from server.timeutil import fmt_dt # 时间格式化
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.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"}
# 工艺模型表:一键清理时整表清空
_PROCESS_TABLES = (
"materials", "boms", "bomItems", "operations", "routings", "routingSteps",
"lineProducts", "workstationOperations", "changeoverMatrix",
)
# 资源树表:一键清理时整表清空
_RESOURCE_TABLES = (
"factories", "workshops", "lines", "workstations", "equipment",
"lineProducts", "workstationOperations", "shiftCalendar",
)
# 柔性轨主数据(MD-08/09):一键清理时整表清空(排产版本结果不碰)
_FLEX_TABLES = (
"flexZones", "flexOperations", "flexEquipment", "flexMolds",
"flexMaterials", "flexRoutings", "flexBom", "flexOrders", "flexParams",
)
def _now() -> str:
"""当前时间字符串(与世界状态 createdAt/updatedAt 口径一致)。"""
return fmt_dt(datetime.now())
# ---------------- P0 只读投影 ----------------
def master_overview(world: World) -> dict[str, Any]:
"""主数据管理页全量投影(P0 只读;资源树 + 物料BOM + 日历维保)。"""
# 产线被工单引用计数(未完成工单;确认卡影响面与"禁止删除"依据)
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"),
} 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),
} 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"],
"isExternal": s.get("isExternal", False),
"sourcingType": s.get("sourcingType") or ("OUTSOURCE" if s.get("isExternal") else "MAKE"),
} for s in steps],
})
# Folder-pack imports use the flexible APS schema. Project the same data into
# the formal master-data view when no formal BOM/routing records exist, so the
# Master Data UI does not incorrectly report that recognized data is missing.
material_by_code = {m.get("code"): m for m in world.get("materials", [])}
if not boms and world.get("flexBom"):
grouped_bom: dict[str, list[dict[str, Any]]] = {}
for item in world.get("flexBom", []):
grouped_bom.setdefault(str(item.get("productCode") or ""), []).append(item)
for bom_id, (product_code, items) in enumerate(grouped_bom.items(), start=1):
product = material_by_code.get(product_code, {})
boms.append({
"id": bom_id, "productId": product.get("id", 0),
"productName": product.get("name") or product_code,
"version": "FLEX-IMPORT", "isDefault": True, "status": "ACTIVE",
"items": [{
"id": item_id,
"materialId": material_by_code.get(row.get("materialCode"), {}).get("id", 0),
"materialName": material_by_code.get(row.get("materialCode"), {}).get("name")
or row.get("materialCode", ""),
"quantity": row.get("quantity", 0),
"isKeyMaterial": bool(row.get("isKey", False)),
} for item_id, row in enumerate(items, start=1)],
})
if not operations and world.get("flexRoutings"):
operation_rows: dict[str, dict[str, Any]] = {}
for row in world.get("flexRoutings", []):
code = str(row.get("operationCode") or "")
if code and code not in operation_rows:
operation_rows[code] = row
operations = [{
"id": op_id, "code": code,
"name": row.get("operationName") or code,
"type": "EXTERNAL" if row.get("isExternal") else "INTERNAL",
"sourcingType": row.get("sourcingType") or ("OUTSOURCE" if row.get("isExternal") else "MAKE"),
"standardTime": row.get("stdTimePerUnit", 0),
} for op_id, (code, row) in enumerate(operation_rows.items(), start=1)]
if not routings and world.get("flexRoutings"):
operation_id = {row["code"]: row["id"] for row in operations}
grouped_routing: dict[str, list[dict[str, Any]]] = {}
for row in world.get("flexRoutings", []):
grouped_routing.setdefault(str(row.get("productCode") or ""), []).append(row)
for routing_id, (product_code, steps) in enumerate(grouped_routing.items(), start=1):
product = material_by_code.get(product_code, {})
routings.append({
"id": routing_id, "productId": product.get("id", 0),
"productCode": product_code,
"productName": product.get("name") or steps[0].get("productName") or product_code,
"version": "FLEX-IMPORT", "isDefault": True, "status": "ACTIVE",
"steps": [{
"id": step_id, "sequenceNo": int(row.get("seq") or step_id),
"operationId": operation_id.get(str(row.get("operationCode") or ""), 0),
"operationName": row.get("operationName") or row.get("operationCode", ""),
"setupTime": row.get("setupTime", 0),
"runTimePerUnit": row.get("stdTimePerUnit", 0),
"isExternal": bool(row.get("isExternal", False)),
"sourcingType": row.get("sourcingType") or ("OUTSOURCE" if row.get("isExternal") else "MAKE"),
} for step_id, row in enumerate(sorted(steps, key=lambda value: int(value.get("seq") or 0)), start=1)],
})
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),
} 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,
}
# ---------------- 校验(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("物料编码与名称不能为空")
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",
}
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),
}
# ---------------- 确认卡(影响面摘要) ----------------
def confirmation_for_master_action(world: World, action: str, payload: dict[str, Any]) -> tuple[str, list[str]]:
"""生成主数据 P2 确认卡标题与影响摘要。"""
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)",
]
raise ValueError(f"不支持的主数据动作:{action}")
# ---------------- P2 写入(调用方负责门禁/快照/审计/落盘) ----------------
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 = 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}
raise ValueError(f"不支持的主数据动作:{action}")