aps-agent/server/gateway/mps_api.py

237 lines
8.1 KiB
Python
Raw Normal View History

# ============================================================
# 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