# ============================================================ # 主数据 R71.3 API(moduleId: gateway-masterdata-api, 可重生 ✅) # 工位/设备 CRUD(停用不进排产)、BOM/工艺版本发布与回滚、班次日历周模板。 # 写动作一律只出 P2 确认卡;执行统一走 /api/actions/confirm + execute_confirmed。 # ============================================================ from __future__ import annotations from typing import Any from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field from starlette.responses import RedirectResponse from server.agent_core import harness from server.agent_core.audit import write_audit from server.aps_domain.masterdata import ( MASTER_ACTIONS, active_equipment, active_workstations, confirmation_for_master_action, ) from server.state.checkpoints import get_checkpoints from server.state.store import get_store router = APIRouter(prefix="/api/masterdata", tags=["masterdata-r71"]) class MasterdataStageRequest(BaseModel): sessionId: str | None = Field(default=None, max_length=128) action: str payload: dict[str, Any] = Field(default_factory=dict) evidenceRefs: list[str] = Field(default_factory=list) class MasterdataConfirmRequest(BaseModel): sessionId: str | None = Field(default=None, max_length=128) confirmId: str = Field(min_length=1, max_length=64) approve: bool = True note: str | None = Field(default=None, max_length=500) class CalendarHolidayRequest(BaseModel): sessionId: str | None = Field(default=None, max_length=128) date: str = Field(pattern=r"^\d{4}-\d{2}-\d{2}$") name: str = Field(min_length=1, max_length=128) note: str | None = None evidenceRefs: list[str] = Field(default_factory=list) def _resource_view(world: dict[str, Any]) -> dict[str, Any]: """工位/设备 CRUD 面板的只读投影;停用资源带 disabled 标记供前端展示。""" line_map = {ln["id"]: ln for ln in world.get("lines", [])} workstations = [] for ws in world.get("workstations", []): workstations.append({ **ws, "lineName": line_map.get(ws.get("lineId", 0), {}).get("name", ""), "disabled": _is_disabled(ws.get("status")), }) equipment = [] for eq in world.get("equipment", []): equipment.append({ **eq, "workstationName": next((w["name"] for w in workstations if w["id"] == eq.get("workstationId")), ""), "disabled": _is_disabled(eq.get("status")), }) return { "workstations": workstations, "equipment": equipment, "lines": [{"id": ln["id"], "code": ln["code"], "name": ln["name"], "status": ln.get("status", "ACTIVE")} for ln in world.get("lines", [])], "shifts": [{"id": s["id"], "code": s["code"], "name": s["name"], "startTime": s["startTime"], "endTime": s["endTime"]} for s in world.get("shifts", [])], } def _is_disabled(status: Any) -> bool: return str(status or "").strip().upper() in ("INACTIVE", "DISABLED", "DOWN", "FAULT", "SCRAPPED") def _version_view(world: dict[str, Any]) -> dict[str, Any]: material_map = {m["id"]: m for m in world.get("materials", [])} versions = [] for v in world.get("masterdataVersions", []): mat = material_map.get(v.get("productId", 0), {}) versions.append({ "id": v["id"], "kind": v["kind"], "productId": v.get("productId"), "productCode": mat.get("code", ""), "productName": mat.get("name", ""), "version": v.get("version"), "status": v.get("status"), "note": v.get("note", ""), "createdAt": v.get("createdAt", ""), }) return { "versions": versions, "boms": [{"id": b["id"], "productId": b["productId"], "version": b["version"], "isDefault": b.get("isDefault", False), "status": b.get("status", "ACTIVE")} for b in world.get("boms", [])], "routings": [{"id": r["id"], "productId": r["productId"], "version": r["version"], "isDefault": r.get("isDefault", False), "status": r.get("status", "ACTIVE")} for r in world.get("routings", [])], } def _calendar_view(world: dict[str, Any]) -> dict[str, Any]: return { "templates": list(world.get("calendarTemplates", [])), "holidays": list(world.get("calendarHolidays", [])), "shiftCalendar": list(world.get("shiftCalendar", [])), } @router.get("/resources") async def get_resources() -> dict[str, Any]: """工位/设备/产线/班次只读投影(P0)。""" return _resource_view(get_store().data) @router.get("/resources/active") async def get_active_resources() -> dict[str, Any]: """排产可用资源子集:停用工位/设备不出现(P0,供排产输入契约校验)。""" world = get_store().data return { "workstations": active_workstations(world), "equipment": active_equipment(world), } @router.get("/versions") async def get_versions() -> dict[str, Any]: """BOM/工艺版本历史(P0)。""" return _version_view(get_store().data) @router.get("/calendar") async def get_calendar() -> dict[str, Any]: """班次日历周模板、节假日与已生成日历(P0)。""" return _calendar_view(get_store().data) @router.get("/calendar/templates") async def get_calendar_templates() -> dict[str, Any]: return {"templates": list(get_store().data.get("calendarTemplates", []))} @router.post("/calendar/holidays") async def add_calendar_holiday(req: CalendarHolidayRequest) -> dict[str, Any]: """登记节假日只生成 P2 确认卡,确认前不写主干日历。""" return await stage_action(MasterdataStageRequest( sessionId=req.sessionId, action="master.calendar.holiday.upsert", payload={"date": req.date, "name": req.name, "note": req.note or ""}, evidenceRefs=req.evidenceRefs, )) @router.post("/stage") async def stage_action(req: MasterdataStageRequest) -> dict[str, Any]: """主数据写入暂存为 P2 确认卡(不改世界)。""" if req.action not in MASTER_ACTIONS: raise HTTPException(status_code=422, detail=f"不支持的主数据动作:{req.action}") store = get_store() try: title, lines = confirmation_for_master_action(store.data, req.action, req.payload) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc staged_params = { **req.payload, "_confirmTitle": title, "_confirmSummary": lines, } checkpoints = get_checkpoints() checkpoint = checkpoints.create( store.data, label=f"主数据出卡基线:{req.action}", reason=f"stage:{req.action}", conversation_note=f"发起主数据确认:{title}", ) fingerprint_ref = f"masterdata-world:{harness.world_fingerprint(store.data)}" evidence_refs = list(dict.fromkeys([*req.evidenceRefs, fingerprint_ref])) try: block = harness.stage_confirmation( req.sessionId or "web", req.action, staged_params, title=title, summary_lines=lines, evidence_refs=evidence_refs, before_snapshot=str(checkpoint["pairId"]), ) except Exception: checkpoints.delete(str(checkpoint["pairId"])) raise write_audit( store.data, store.next_id, actor=req.sessionId or "web", category="GATE", action=req.action + ".stage", target={"type": "MASTER_DATA_R71", "id": req.payload.get("id")}, power="P2", rationale={"confirmId": block.props["confirmId"]}, ) store.save() return { "message": f"{title} 已进入 P2 确认队列。", "confirmId": block.props["confirmId"], "title": title, "summary": lines, "block": block.model_dump(), } @router.post("/confirm", deprecated=True) async def confirm_action(_req: MasterdataConfirmRequest) -> RedirectResponse: """兼容旧客户端;307 保留 POST 请求体并转交统一确认端点。""" return RedirectResponse(url="/api/actions/confirm", status_code=307) @router.get("/pending") async def list_pending() -> dict[str, Any]: """当前待确认的主数据动作(P0;用于面板展示待批卡片)。""" store = get_store() records = harness.list_pending() cards = [] for record in records or []: action = str(record.get("action") or "") if action not in MASTER_ACTIONS: continue params = record.get("params") or {} cards.append({ "confirmId": record.get("confirmId"), "action": action, "title": params.get("_confirmTitle") or record.get("title") or "", "summary": params.get("_confirmSummary") or record.get("summary") or [], "params": params, "createdAt": record.get("createdAt"), "expiresAt": record.get("expiresAt"), }) return {"pending": cards, "storePath": getattr(store, "path", None)}