235 lines
9.0 KiB
Python
235 lines
9.0 KiB
Python
# ============================================================
|
||
# 主数据 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)}
|