aps-agent/server/aps_domain/masterdata.py

387 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 主数据维护领域服务(moduleId: domain-masterdata, 可重生 ✅)
# MD-01/02/03 首切片:资源树 / 物料与BOM / 日历与维保 的只读投影
# + 三个最小 P2 写动作(产线编辑停启用 / 物料库存编辑 / 维保新增取消)。
# 规则:主数据写入影响后续新排产输入,必须由调用方走 Harness 确认卡;
# 被排产版本引用的资源禁止物理删除,只能停用(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")
# 产线/维保允许的状态集
LINE_STATUSES = {"ACTIVE", "INACTIVE"}
MAINTENANCE_STATUSES = {"PLANNED", "CANCELLED"}
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"],
"stock": m.get("stock", 0), "inTransit": m.get("inTransit", 0),
"safetyStock": m.get("safetyStock", 0),
"procurementLeadTime": m.get("procurementLeadTime", 0),
} for m in world.get("materials", [])]
material_name = {m["id"]: m["name"] 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,
})
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"],
"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"],
"operationName": op_name.get(s["operationId"], f"OP#{s['operationId']}"),
"setupTime": s["setupTime"], "runTimePerUnit": s["runTimePerUnit"],
"isExternal": s.get("isExternal", False),
} for s in steps],
})
# ---- 班次 / 日历摘要 / 维保 ----
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", [])]
line_name = {ln["id"]: ln["name"] for ln in world.get("lines", [])}
# 日历摘要:逐产线统计未来工作日数量(页面展示概览,不铺 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", [])]
return {
"factories": factories,
"materials": materials, "boms": boms, "routings": routings,
"shifts": shifts, "calendar": calendar, "maintenance": maintenance,
"equipmentOptions": equipment_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]:
"""物料编辑载荷归一化(首切片只允许改库存/在途/安全库存/采购前置期)。"""
m = _find_material(world, int(payload.get("id") or 0))
out: dict[str, Any] = {"id": m["id"]}
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("采购前置期不能为负数")
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 明细编辑载荷归一化(首切片:改用量/关键料标记,逐行;不增删行)。"""
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] = {"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]:
"""工艺步骤编辑载荷归一化(首切片:改准备/单件时间与外协标记,逐步骤)。"""
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] = {"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 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)
m = _find_material(world, p["id"])
return f"编辑物料 {m['name']}", [
f"物料:{m['name']}({m['code']})",
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)
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)
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
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)
m = _find_material(world, p["id"])
before_stock = m.get("stock", 0)
m.update({"stock": p["stock"], "inTransit": p["inTransit"],
"safetyStock": p["safetyStock"], "procurementLeadTime": p["procurementLeadTime"]})
return {"kind": "MATERIAL", "id": m["id"], "name": m["name"],
"beforeStock": before_stock, "afterStock": p["stock"]}
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)
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)
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"]}
raise ValueError(f"不支持的主数据动作:{action}")