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