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)}
|