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
|