# ============================================================ # 主数据维护领域服务(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}")