aps-agent/server/gateway/masterdata_api.py

235 lines
9.0 KiB
Python
Raw Normal View History

# ============================================================
# 主数据 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)}