2026-07-28 02:12:46 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
# 外部算法引擎适配器(moduleId: engines-external, 可重生 ✅)
|
|
|
|
|
|
# ISchedulingEngine:组中性 DTO → HTTP/local skill → 写回 world
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-08-20 11:39:21 +08:00
|
|
|
|
from copy import deepcopy
|
|
|
|
|
|
from datetime import datetime
|
2026-07-28 02:12:46 +08:00
|
|
|
|
from typing import Any
|
2026-08-20 11:39:21 +08:00
|
|
|
|
from zoneinfo import ZoneInfo
|
2026-07-28 02:12:46 +08:00
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
|
|
|
|
|
from server.aps_domain.scheduling_dto import (
|
|
|
|
|
|
SchedulingSolution, apply_flex_solution, world_to_flex_problem,
|
|
|
|
|
|
)
|
|
|
|
|
|
from server.contracts import ScheduleResult
|
|
|
|
|
|
from server.engines.base import EngineParams, ISchedulingEngine
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ExternalEngine(ISchedulingEngine):
|
|
|
|
|
|
"""外部算法排产:通过 SkillRegistry 选定 skill,HTTP 或 local://stub。"""
|
|
|
|
|
|
|
|
|
|
|
|
name = "EXTERNAL"
|
|
|
|
|
|
supports_anytime = False
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, skill_id: str | None = None) -> None:
|
|
|
|
|
|
self.skill_id = skill_id
|
|
|
|
|
|
|
|
|
|
|
|
def solve(self, world: dict[str, Any], params: EngineParams, next_id) -> ScheduleResult:
|
|
|
|
|
|
from server.agent_core.skills import get_skills
|
|
|
|
|
|
|
2026-08-20 11:39:21 +08:00
|
|
|
|
skill_id = self.skill_id or (
|
|
|
|
|
|
params.meta_skill_id if hasattr(params, "meta_skill_id") else None
|
|
|
|
|
|
)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
skill_id = skill_id or world.pop("_externalSkillId", None)
|
2026-08-20 11:39:21 +08:00
|
|
|
|
registry = get_skills()
|
|
|
|
|
|
skill = (
|
|
|
|
|
|
registry.get_enabled(skill_id, track="flex")
|
|
|
|
|
|
or registry.get_enabled(skill_id)
|
|
|
|
|
|
or registry.get_enabled()
|
|
|
|
|
|
)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
if not skill:
|
2026-08-20 11:39:21 +08:00
|
|
|
|
raise ValueError("No enabled external scheduling skill is available")
|
2026-07-28 02:12:46 +08:00
|
|
|
|
|
|
|
|
|
|
order_ids = list(params.orderIds or []) or None
|
2026-08-20 11:39:21 +08:00
|
|
|
|
start_time = (
|
|
|
|
|
|
params.startDate + " 08:00"
|
|
|
|
|
|
if params.startDate and " " not in params.startDate
|
|
|
|
|
|
else params.startDate
|
2026-07-28 02:12:46 +08:00
|
|
|
|
)
|
2026-08-20 11:39:21 +08:00
|
|
|
|
if _is_explicit_demo_world(world):
|
|
|
|
|
|
problem = world_to_flex_problem(
|
|
|
|
|
|
world,
|
|
|
|
|
|
order_ids=order_ids,
|
|
|
|
|
|
start_time=start_time,
|
|
|
|
|
|
)
|
|
|
|
|
|
solution = self._call_skill(skill, problem.model_dump())
|
|
|
|
|
|
summary = apply_flex_solution(
|
|
|
|
|
|
world,
|
|
|
|
|
|
solution,
|
|
|
|
|
|
next_id,
|
|
|
|
|
|
skill_id=skill["skill_id"],
|
|
|
|
|
|
)
|
|
|
|
|
|
summary["executionMode"] = "EXPLICIT_DEMO_LEGACY"
|
|
|
|
|
|
else:
|
|
|
|
|
|
summary, solution, problem = _run_closed_loop_external_candidate(
|
|
|
|
|
|
world,
|
|
|
|
|
|
next_id,
|
|
|
|
|
|
skill,
|
|
|
|
|
|
order_ids=order_ids,
|
|
|
|
|
|
start_time=start_time,
|
|
|
|
|
|
)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
|
2026-08-20 11:39:21 +08:00
|
|
|
|
evidence_refs = [f"skill:{skill['skill_id']}", f"run:{solution.runId}"]
|
|
|
|
|
|
if summary.get("planningProblemId"):
|
|
|
|
|
|
evidence_refs.append(f"planning-problem:{summary['planningProblemId']}")
|
2026-07-28 02:12:46 +08:00
|
|
|
|
return ScheduleResult(
|
|
|
|
|
|
versionId=summary["versionId"],
|
|
|
|
|
|
versionNo=summary["versionNo"],
|
|
|
|
|
|
engineType="EXTERNAL",
|
|
|
|
|
|
strategy=f"EXTERNAL:{skill['skill_id']}",
|
|
|
|
|
|
status="DRAFT",
|
|
|
|
|
|
orderCount=len(problem.orders),
|
|
|
|
|
|
poCount=summary["vlCount"],
|
|
|
|
|
|
woCount=summary["woCount"],
|
|
|
|
|
|
conflictCount=summary["conflictCount"],
|
|
|
|
|
|
totalTardiness=float((solution.kpi or {}).get("totalTardiness") or 0),
|
|
|
|
|
|
avgUtilization=float(summary.get("avgUtilization") or 0),
|
2026-08-20 11:39:21 +08:00
|
|
|
|
evidenceRefs=evidence_refs,
|
|
|
|
|
|
solveStatus=str(summary.get("solveStatus") or solution.status),
|
2026-07-28 02:12:46 +08:00
|
|
|
|
solveTimeSec=float((solution.solverMeta or {}).get("timeSec") or 0) or None,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def _call_skill(self, skill: dict[str, Any], problem: dict) -> SchedulingSolution:
|
|
|
|
|
|
ep = skill.get("endpoint") or ""
|
|
|
|
|
|
if ep.startswith("local://"):
|
|
|
|
|
|
from server.integrations.algo_skill_stub import solve_problem
|
|
|
|
|
|
return solve_problem(problem)
|
|
|
|
|
|
url = ep.rstrip("/") + "/schedule"
|
|
|
|
|
|
headers = {"Content-Type": "application/json"}
|
|
|
|
|
|
if skill.get("auth"):
|
|
|
|
|
|
headers["Authorization"] = f"Bearer {skill['auth']}"
|
|
|
|
|
|
timeout = float(skill.get("timeout_sec") or 60)
|
|
|
|
|
|
try:
|
|
|
|
|
|
with httpx.Client(timeout=timeout) as client:
|
|
|
|
|
|
resp = client.post(url, json=problem, headers=headers)
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
return SchedulingSolution(**resp.json())
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
raise ValueError(f"外部算法 skill「{skill.get('name')}」调用失败:{exc}") from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-20 11:39:21 +08:00
|
|
|
|
def _is_explicit_demo_world(world: dict[str, Any]) -> bool:
|
|
|
|
|
|
return any(
|
|
|
|
|
|
str(factory.get("name") or "") == "\u6f14\u793a\u5de5\u5382"
|
|
|
|
|
|
for factory in world.get("factories") or []
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _run_closed_loop_external_candidate(
|
|
|
|
|
|
world: dict[str, Any],
|
|
|
|
|
|
next_id,
|
|
|
|
|
|
skill: dict[str, Any],
|
|
|
|
|
|
*,
|
|
|
|
|
|
order_ids: list[int] | None,
|
|
|
|
|
|
start_time: str | None,
|
|
|
|
|
|
) -> tuple[dict[str, Any], SchedulingSolution, Any]:
|
|
|
|
|
|
"""Validate a legacy Skill result against V2 before materializing it."""
|
|
|
|
|
|
|
|
|
|
|
|
from server.aps_domain.closed_loop_problem import build_closed_loop_problem
|
|
|
|
|
|
from server.aps_domain.closed_loop_runtime import (
|
|
|
|
|
|
closed_loop_to_problem_v2,
|
|
|
|
|
|
flex_version_to_solution_v2,
|
|
|
|
|
|
persist_closed_loop_projection,
|
|
|
|
|
|
project_admitted_demands_to_flex_orders,
|
|
|
|
|
|
)
|
|
|
|
|
|
from server.aps_domain.scheduling_problem_v2 import SolveStatus
|
|
|
|
|
|
from server.aps_domain.scheduling_validator import validate_solution
|
|
|
|
|
|
|
|
|
|
|
|
selected_order_nos = None
|
|
|
|
|
|
if order_ids:
|
|
|
|
|
|
selected = set(order_ids)
|
|
|
|
|
|
selected_order_nos = sorted({
|
|
|
|
|
|
str(row.get("salesOrderNo") or row.get("orderNo"))
|
|
|
|
|
|
for row in world.get("flexOrders") or []
|
|
|
|
|
|
if row.get("id") in selected and (row.get("salesOrderNo") or row.get("orderNo"))
|
|
|
|
|
|
})
|
|
|
|
|
|
business_date = str(
|
|
|
|
|
|
world.get("businessDate")
|
|
|
|
|
|
or datetime.now(ZoneInfo("Asia/Shanghai")).date().isoformat()
|
|
|
|
|
|
)[:10]
|
|
|
|
|
|
closed_loop = build_closed_loop_problem(
|
|
|
|
|
|
world,
|
|
|
|
|
|
business_date=business_date,
|
|
|
|
|
|
order_nos=selected_order_nos,
|
|
|
|
|
|
strict=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
problem_v2 = closed_loop_to_problem_v2(world, closed_loop)
|
|
|
|
|
|
persist_closed_loop_projection(world, closed_loop, problem_v2)
|
|
|
|
|
|
admitted = [
|
|
|
|
|
|
row
|
|
|
|
|
|
for row in closed_loop.manufacturing_demands
|
|
|
|
|
|
if row.release_status in {"READY", "READY_FOR_SCHEDULING"}
|
|
|
|
|
|
and row.routing_status == "READY"
|
|
|
|
|
|
and row.resource_status == "READY"
|
|
|
|
|
|
]
|
|
|
|
|
|
if not admitted:
|
|
|
|
|
|
blocker_counts = closed_loop.stats.get("blockerCounts") or {}
|
|
|
|
|
|
details = ", ".join(f"{code}={count}" for code, count in sorted(blocker_counts.items())[:5])
|
|
|
|
|
|
raise ValueError(f"External Skill scheduling admission blocked: {details or 'no admitted MAKE demand'}")
|
|
|
|
|
|
|
|
|
|
|
|
candidate = deepcopy(world)
|
|
|
|
|
|
projected = project_admitted_demands_to_flex_orders(candidate, closed_loop)
|
|
|
|
|
|
legacy_problem = world_to_flex_problem(
|
|
|
|
|
|
candidate,
|
|
|
|
|
|
order_ids=projected["orderIds"],
|
|
|
|
|
|
start_time=start_time,
|
|
|
|
|
|
)
|
|
|
|
|
|
engine = ExternalEngine(skill_id=skill["skill_id"])
|
|
|
|
|
|
legacy_solution = engine._call_skill(skill, legacy_problem.model_dump())
|
|
|
|
|
|
summary = apply_flex_solution(
|
|
|
|
|
|
candidate,
|
|
|
|
|
|
legacy_solution,
|
|
|
|
|
|
next_id,
|
|
|
|
|
|
skill_id=skill["skill_id"],
|
|
|
|
|
|
)
|
|
|
|
|
|
version_id = int(summary["versionId"])
|
|
|
|
|
|
version = next(
|
|
|
|
|
|
row for row in candidate["flexScheduleVersions"] if row.get("id") == version_id
|
|
|
|
|
|
)
|
|
|
|
|
|
business_version_no = f"EXT{business_date.replace('-', '')}-{version_id:03d}"
|
|
|
|
|
|
version.update({
|
|
|
|
|
|
"versionNo": business_version_no,
|
|
|
|
|
|
"createdAt": f"{business_date} 00:00",
|
|
|
|
|
|
"orderCount": len(projected["orderIds"]),
|
|
|
|
|
|
"demandCount": len(closed_loop.manufacturing_demands),
|
|
|
|
|
|
"admittedDemandCount": len(admitted),
|
|
|
|
|
|
"unscheduledDemandCount": max(0, len(admitted) - int(summary.get("vlCount") or 0)),
|
|
|
|
|
|
"planningProblemId": closed_loop.problem_id,
|
|
|
|
|
|
"planningSourceHash": closed_loop.source_revision,
|
|
|
|
|
|
})
|
|
|
|
|
|
solution_v2 = flex_version_to_solution_v2(
|
|
|
|
|
|
candidate,
|
|
|
|
|
|
closed_loop,
|
|
|
|
|
|
problem_v2,
|
|
|
|
|
|
version_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
report = validate_solution(problem_v2, solution_v2, world=candidate)
|
|
|
|
|
|
if not report.valid or solution_v2.solveStatus != SolveStatus.FEASIBLE:
|
|
|
|
|
|
details = "; ".join(
|
|
|
|
|
|
f"{issue.code}:{issue.message}"
|
|
|
|
|
|
for issue in report.hardViolations[:5]
|
|
|
|
|
|
)
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"External Skill solution rejected by SchedulingProblemV2 validator: {details or solution_v2.solveStatus.value}"
|
|
|
|
|
|
)
|
|
|
|
|
|
version["schedulingSolutionV2"] = solution_v2.model_dump(mode="json")
|
|
|
|
|
|
version["validationReport"] = report.model_dump(mode="json")
|
|
|
|
|
|
version["solveStatus"] = solution_v2.solveStatus.value
|
|
|
|
|
|
version["engineType"] = "EXTERNAL"
|
|
|
|
|
|
for key in (
|
|
|
|
|
|
"flexOrders",
|
|
|
|
|
|
"flexRoutings",
|
|
|
|
|
|
"flexScheduleVersions",
|
|
|
|
|
|
"flexVirtualLines",
|
|
|
|
|
|
"flexWorkOrders",
|
|
|
|
|
|
"flexConflicts",
|
|
|
|
|
|
):
|
|
|
|
|
|
world[key] = candidate[key]
|
|
|
|
|
|
summary.update({
|
|
|
|
|
|
"versionNo": business_version_no,
|
|
|
|
|
|
"executionMode": "CLOSED_LOOP_V2_VALIDATED",
|
|
|
|
|
|
"solveStatus": solution_v2.solveStatus.value,
|
|
|
|
|
|
"planningProblemId": closed_loop.problem_id,
|
|
|
|
|
|
"planningSourceHash": closed_loop.source_revision,
|
|
|
|
|
|
"validation": version["validationReport"],
|
|
|
|
|
|
"projectedFlexOrders": projected,
|
|
|
|
|
|
})
|
|
|
|
|
|
return summary, legacy_solution, legacy_problem
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
def run_external_flex(store, *, skill_id: str | None = None,
|
|
|
|
|
|
order_ids: list[int] | None = None, actor: str = "planner") -> dict[str, Any]:
|
2026-08-20 11:39:21 +08:00
|
|
|
|
"""Run external flexible scheduling through the closed-loop V2 validator."""
|
|
|
|
|
|
|
|
|
|
|
|
from datetime import timedelta
|
|
|
|
|
|
|
|
|
|
|
|
from server.agent_core.audit import write_audit
|
2026-07-28 02:12:46 +08:00
|
|
|
|
from server.agent_core.skills import get_skills
|
2026-08-20 11:39:21 +08:00
|
|
|
|
from server.aps_domain.mrp import decompose_orders
|
|
|
|
|
|
from server.aps_domain.orders import sync_flex_orders_to_sales
|
|
|
|
|
|
from server.aps_domain.sourcing import annotate_world_sourcing
|
2026-07-28 02:12:46 +08:00
|
|
|
|
from server.timeutil import add_minutes, fmt_dt, today0
|
|
|
|
|
|
|
2026-08-20 11:39:21 +08:00
|
|
|
|
registry = get_skills()
|
|
|
|
|
|
skill = (
|
|
|
|
|
|
registry.get_enabled(skill_id, track="flex")
|
|
|
|
|
|
or registry.get_enabled(skill_id)
|
|
|
|
|
|
or registry.get_enabled()
|
|
|
|
|
|
)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
if not skill:
|
2026-08-20 11:39:21 +08:00
|
|
|
|
raise ValueError("No enabled external scheduling skill is available")
|
|
|
|
|
|
|
|
|
|
|
|
if _is_explicit_demo_world(store.data):
|
|
|
|
|
|
problem = world_to_flex_problem(
|
|
|
|
|
|
store.data,
|
|
|
|
|
|
order_ids=order_ids,
|
|
|
|
|
|
start_time=fmt_dt(add_minutes(today0(), 24 * 60)),
|
|
|
|
|
|
)
|
|
|
|
|
|
engine = ExternalEngine(skill_id=skill["skill_id"])
|
|
|
|
|
|
solution = engine._call_skill(skill, problem.model_dump())
|
|
|
|
|
|
summary = apply_flex_solution(
|
|
|
|
|
|
store.data,
|
|
|
|
|
|
solution,
|
|
|
|
|
|
store.next_id,
|
|
|
|
|
|
skill_id=skill["skill_id"],
|
|
|
|
|
|
)
|
|
|
|
|
|
summary["executionMode"] = "EXPLICIT_DEMO_LEGACY"
|
|
|
|
|
|
else:
|
|
|
|
|
|
synced = sync_flex_orders_to_sales(store.data)
|
|
|
|
|
|
annotate_world_sourcing(store.data)
|
|
|
|
|
|
decomposition = decompose_orders(store.data, store.next_id)
|
|
|
|
|
|
business_date = str(
|
|
|
|
|
|
store.data.get("businessDate")
|
|
|
|
|
|
or datetime.now(ZoneInfo("Asia/Shanghai")).date().isoformat()
|
|
|
|
|
|
)[:10]
|
|
|
|
|
|
start_day = datetime.fromisoformat(business_date).date() + timedelta(days=1)
|
|
|
|
|
|
summary, solution, problem = _run_closed_loop_external_candidate(
|
|
|
|
|
|
store.data,
|
|
|
|
|
|
store.next_id,
|
|
|
|
|
|
skill,
|
|
|
|
|
|
order_ids=order_ids,
|
|
|
|
|
|
start_time=f"{start_day.isoformat()} 08:00",
|
|
|
|
|
|
)
|
|
|
|
|
|
summary["salesOrdersSynced"] = synced
|
|
|
|
|
|
summary["decompose"] = {
|
|
|
|
|
|
"orders": len(decomposition.get("orders") or []),
|
|
|
|
|
|
"make": len(decomposition.get("make") or []),
|
|
|
|
|
|
"purchase": len(decomposition.get("purchase") or []),
|
|
|
|
|
|
"outsource": len(decomposition.get("outsource") or []),
|
|
|
|
|
|
}
|
2026-07-28 02:12:46 +08:00
|
|
|
|
|
2026-08-20 11:39:21 +08:00
|
|
|
|
write_audit(
|
|
|
|
|
|
store.data,
|
|
|
|
|
|
store.next_id,
|
|
|
|
|
|
actor=actor,
|
|
|
|
|
|
category="ALGO_RUN",
|
|
|
|
|
|
action="external.flex.schedule",
|
|
|
|
|
|
target={"type": "FLEX_VERSION", "id": summary["versionId"]},
|
|
|
|
|
|
power="P1",
|
|
|
|
|
|
rationale={
|
|
|
|
|
|
"skillId": skill["skill_id"],
|
|
|
|
|
|
"runId": solution.runId,
|
|
|
|
|
|
"executionMode": summary.get("executionMode"),
|
|
|
|
|
|
"solveStatus": summary.get("solveStatus") or solution.status,
|
|
|
|
|
|
"planningProblemId": summary.get("planningProblemId"),
|
|
|
|
|
|
"planningSourceHash": summary.get("planningSourceHash"),
|
|
|
|
|
|
"validationValid": (summary.get("validation") or {}).get("valid"),
|
|
|
|
|
|
},
|
|
|
|
|
|
evidence_refs=[
|
|
|
|
|
|
f"skill:{skill['skill_id']}",
|
|
|
|
|
|
f"run:{solution.runId}",
|
|
|
|
|
|
*(
|
|
|
|
|
|
[f"planning-problem:{summary['planningProblemId']}"]
|
|
|
|
|
|
if summary.get("planningProblemId")
|
|
|
|
|
|
else []
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
2026-07-28 02:12:46 +08:00
|
|
|
|
)
|
|
|
|
|
|
store.save()
|
|
|
|
|
|
return summary
|