aps-agent/server/gateway/masterdata_api.py

235 lines
9.0 KiB
Python
Raw Permalink 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.

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