aps-agent/server/gateway/mps_api.py

237 lines
8.1 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.

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