# ============================================================ # MPS 计划层 API(moduleId: gateway-mps-api, 可重生 ✅) # R71.4:中长期主计划草稿、有限产能粗评估、可追溯信封与 gap 对比。 # 只读 draft/capacity/trace/gap;persist 复用 PlanStore 创建不可变 L2 节点。 # ============================================================ from __future__ import annotations import hashlib from typing import Any from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field from server.agent_core.plan_runtime import get_plan_store from server.aps_domain.mps_planning import ( MpsTraceStore, assess_mps_capacity, bind_mps_draft, build_mps_draft, compare_mps_schedule_gap, get_mps_trace, link_mps_trace_local_fact, list_mps_schedule_versions, persist_mps_draft, ) from server.aps_home import aps_home from server.auth.context import get_identity from server.state.projects import get_project_store from server.state.store import get_store router = APIRouter(prefix="/api/mps", tags=["mps-r71"]) class MpsDraftRequest(BaseModel): mode: str = Field(default="WEEK", pattern="^(DAY|WEEK|MONTH|HYBRID)$") startDate: str | None = None horizonDays: int = Field(default=90, ge=1, le=730) includeForecast: bool = True includeFirm: bool = True longCycleOnly: bool = False capacityMode: str = Field(default="FINITE", pattern="^(FINITE|INFINITE)$") class MpsPersistRequest(BaseModel): draft: dict[str, Any] planId: str | None = Field(default=None, max_length=128) class MpsGapRequest(BaseModel): draft: dict[str, Any] | None = None scheduleId: int | None = None track: str = Field(default="fixed", pattern="^(fixed|flex|auto)$") traceId: str | None = Field(default=None, max_length=128) class MpsTraceLinkRequest(BaseModel): draftHash: str = Field(min_length=64, max_length=64, pattern="^[0-9a-f]{64}$") worldFingerprint: str = Field( min_length=64, max_length=64, pattern="^[0-9a-f]{64}$", ) scheduleId: int scheduleVersionNo: str = Field(min_length=1, max_length=128) scheduleFingerprint: str = Field( min_length=64, max_length=64, pattern="^[0-9a-f]{64}$", ) track: str = Field(pattern="^(fixed|flex)$") factType: str = Field(pattern="^(ADOPT|PUBLISH)$") factId: str | None = Field( default=None, max_length=128, pattern=r"^[A-Za-z0-9_.:/-]+$", ) def _current_scope() -> tuple[Any, Any, str, str]: identity = get_identity(required=True) world = get_store() project_id = str(get_project_store().active_world_key() or world.world_key) return identity, world, project_id, str(world.world_key) def _trace_store(tenant_uuid: str, project_id: str, world_key: str) -> MpsTraceStore: scope_key = hashlib.sha256( f"{tenant_uuid}\0{project_id}\0{world_key}".encode() ).hexdigest() return MpsTraceStore( str(aps_home() / "data" / "mps_traces" / f"{scope_key}.json"), tenant_uuid=tenant_uuid, project_id=project_id, world_key=world_key, ) @router.post("/draft") def mps_draft(req: MpsDraftRequest) -> dict[str, Any]: identity, store, project_id, world_key = _current_scope() world = store.data try: draft = bind_mps_draft( world, build_mps_draft( world, mode=req.mode, start_date=req.startDate, horizon_days=req.horizonDays, include_forecast=req.includeForecast, include_firm=req.includeFirm, long_cycle_only=req.longCycleOnly, capacity_mode=req.capacityMode, ), tenant_uuid=identity.tenant_uuid, project_id=project_id, world_key=world_key, ) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc return {"draft": draft, "capacity": assess_mps_capacity(world, draft)} @router.post("/persist") def mps_persist(req: MpsPersistRequest) -> dict[str, Any]: identity, store, project_id, world_key = _current_scope() actor = identity.username or f"user:{identity.user_id}" created_by = "SYSTEM" if identity.auth_kind == "system" else "USER" try: return persist_mps_draft( req.draft, world=store.data, plan_store=get_plan_store(), trace_store=_trace_store(identity.tenant_uuid, project_id, world_key), tenant_uuid=identity.tenant_uuid, project_id=project_id, world_key=world_key, plan_id=req.planId, created_by=created_by, actor=actor, ) except Exception as exc: # 计划仓异常统一映射为 409 raise HTTPException(status_code=409, detail=f"MPS 持久化失败:{exc}") from exc @router.get("/trace/{trace_id}") def mps_trace(trace_id: str) -> dict[str, Any]: identity, _store, project_id, world_key = _current_scope() try: return get_mps_trace( trace_id, trace_store=_trace_store(identity.tenant_uuid, project_id, world_key), ) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @router.get("/traces") def mps_traces() -> list[dict[str, Any]]: identity, _store, project_id, world_key = _current_scope() return _trace_store(identity.tenant_uuid, project_id, world_key).list() @router.get("/schedule-versions") def mps_schedule_versions() -> list[dict[str, Any]]: _identity, store, _project_id, _world_key = _current_scope() return list_mps_schedule_versions(store.data) @router.post("/trace/{trace_id}/link") def mps_trace_link(trace_id: str, req: MpsTraceLinkRequest) -> dict[str, Any]: identity, store, project_id, world_key = _current_scope() actor = identity.username or f"user:{identity.user_id}" try: return link_mps_trace_local_fact( store.data, trace_store=_trace_store(identity.tenant_uuid, project_id, world_key), trace_id=trace_id, tenant_uuid=identity.tenant_uuid, project_id=project_id, world_key=world_key, draft_hash=req.draftHash, world_fingerprint=req.worldFingerprint, schedule_id=req.scheduleId, schedule_version_no=req.scheduleVersionNo, schedule_fingerprint=req.scheduleFingerprint, track=req.track, fact_type=req.factType, fact_id=req.factId, actor=actor, ) except ValueError as exc: status_code = 404 if "does not exist" in str(exc) else 409 raise HTTPException(status_code=status_code, detail=str(exc)) from exc except OSError as exc: raise HTTPException( status_code=409, detail=f"MPS 本地关联写入失败:{exc}", ) from exc @router.post("/gap") def mps_gap(req: MpsGapRequest) -> dict[str, Any]: identity, store, project_id, world_key = _current_scope() world = store.data trace = None if req.traceId: try: trace = get_mps_trace( req.traceId, trace_store=_trace_store(identity.tenant_uuid, project_id, world_key), ) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc draft = req.draft or ((trace or {}).get("draftSnapshot") if trace else None) if not draft: raise HTTPException( status_code=422, detail="MPS gap requires a draft or traceId" ) if trace and req.draft and req.draft.get("draftHash") != trace.get("draftHash"): raise HTTPException( status_code=409, detail="MPS draft does not match the referenced trace" ) try: return compare_mps_schedule_gap( world, draft, tenant_uuid=identity.tenant_uuid, project_id=project_id, world_key=world_key, schedule_id=req.scheduleId, track=req.track, trace=trace, ) except ValueError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc