237 lines
8.1 KiB
Python
237 lines
8.1 KiB
Python
# ============================================================
|
||
# 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
|