2026-07-28 02:12:46 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
# 中性排产问题/解 DTO 与 world 双向映射(moduleId: domain-scheduling-dto)
|
|
|
|
|
|
# 外部算法只认 SchedulingProblem / SchedulingSolution,不碰内部 flex* 表结构
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
|
2026-08-11 00:54:05 +08:00
|
|
|
|
from server.timeutil import add_minutes, fmt_dt, today0
|
2026-07-28 02:12:46 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProblemOrder(BaseModel):
|
|
|
|
|
|
orderId: str
|
|
|
|
|
|
productCode: str
|
|
|
|
|
|
quantity: float = 1
|
|
|
|
|
|
dueDate: str | None = None
|
|
|
|
|
|
priority: int = 5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProblemStep(BaseModel):
|
|
|
|
|
|
productCode: str
|
|
|
|
|
|
seq: int
|
|
|
|
|
|
operationCode: str
|
|
|
|
|
|
operationName: str = ""
|
|
|
|
|
|
stdTimePerUnit: float = 1.0
|
|
|
|
|
|
requireMold: bool = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProblemResource(BaseModel):
|
|
|
|
|
|
code: str
|
|
|
|
|
|
name: str = ""
|
|
|
|
|
|
capabilities: list[str] = Field(default_factory=list) # operationCode 列表
|
|
|
|
|
|
zone: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SchedulingProblem(BaseModel):
|
|
|
|
|
|
schemaVersion: str = "1.1" # 契约版本(M-D)
|
|
|
|
|
|
problemId: str
|
|
|
|
|
|
track: str = "flex"
|
|
|
|
|
|
startTime: str
|
|
|
|
|
|
horizonHours: float = 168
|
2026-08-11 00:54:05 +08:00
|
|
|
|
orders: list[ProblemOrder] # schema required
|
|
|
|
|
|
routings: list[ProblemStep] # schema required
|
|
|
|
|
|
resources: list[ProblemResource] # schema required
|
2026-07-28 02:12:46 +08:00
|
|
|
|
objectives: dict[str, float] = Field(default_factory=lambda: {"tardiness": 1.0})
|
|
|
|
|
|
constraints: dict[str, bool] = Field(default_factory=dict)
|
|
|
|
|
|
meta: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SolutionOp(BaseModel):
|
|
|
|
|
|
orderId: str
|
|
|
|
|
|
seq: int
|
|
|
|
|
|
operationCode: str
|
|
|
|
|
|
operationName: str = ""
|
|
|
|
|
|
resourceCode: str
|
|
|
|
|
|
start: str
|
|
|
|
|
|
end: str
|
|
|
|
|
|
runMin: float = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SolutionConflict(BaseModel):
|
|
|
|
|
|
type: str = "OTHER"
|
|
|
|
|
|
orderId: str | None = None
|
|
|
|
|
|
description: str = ""
|
|
|
|
|
|
severity: str = "medium"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SchedulingSolution(BaseModel):
|
|
|
|
|
|
schemaVersion: str = "1.1" # 契约版本(M-D)
|
|
|
|
|
|
problemId: str
|
|
|
|
|
|
runId: str
|
|
|
|
|
|
skillId: str = ""
|
|
|
|
|
|
status: str = "FEASIBLE" # OPTIMAL / FEASIBLE / INFEASIBLE
|
2026-08-11 00:54:05 +08:00
|
|
|
|
operations: list[SolutionOp] # schema required
|
2026-07-28 02:12:46 +08:00
|
|
|
|
conflicts: list[SolutionConflict] = Field(default_factory=list)
|
|
|
|
|
|
kpi: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
solverMeta: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def world_to_flex_problem(world: dict[str, Any], *, order_ids: list[int] | None = None,
|
|
|
|
|
|
start_time: str | None = None,
|
|
|
|
|
|
horizon_hours: float = 168,
|
|
|
|
|
|
include_knowledge: bool = False) -> SchedulingProblem:
|
|
|
|
|
|
"""柔性轨 world → 中性 Problem。
|
|
|
|
|
|
|
|
|
|
|
|
include_knowledge=True 时注入 meta.knowledgeRefs:按产品/工序在知识库
|
|
|
|
|
|
检索相关 SOP/工艺模式命中摘要(带出处),让外部算法拿到约束背景(M-E)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
orders_src = world.get("flexOrders") or []
|
|
|
|
|
|
if order_ids:
|
|
|
|
|
|
idset = set(order_ids)
|
|
|
|
|
|
orders_src = [o for o in orders_src if o.get("id") in idset]
|
|
|
|
|
|
orders = [
|
|
|
|
|
|
ProblemOrder(
|
|
|
|
|
|
orderId=str(o["orderNo"]),
|
|
|
|
|
|
productCode=str(o.get("productCode") or ""),
|
|
|
|
|
|
quantity=float(o.get("quantity") or 1),
|
|
|
|
|
|
dueDate=o.get("dueDate"),
|
|
|
|
|
|
priority=int(o.get("priority") or 5),
|
|
|
|
|
|
)
|
|
|
|
|
|
for o in orders_src
|
|
|
|
|
|
]
|
|
|
|
|
|
product_codes = {o.productCode for o in orders}
|
|
|
|
|
|
routings = [
|
|
|
|
|
|
ProblemStep(
|
|
|
|
|
|
productCode=r["productCode"], seq=int(r.get("seq") or 0),
|
|
|
|
|
|
operationCode=str(r.get("operationCode") or ""),
|
|
|
|
|
|
operationName=str(r.get("operationName") or r.get("operationCode") or ""),
|
|
|
|
|
|
stdTimePerUnit=float(r.get("stdTimePerUnit") or 1),
|
|
|
|
|
|
requireMold=bool(r.get("requireMold")),
|
|
|
|
|
|
)
|
|
|
|
|
|
for r in (world.get("flexRoutings") or [])
|
|
|
|
|
|
if r.get("productCode") in product_codes
|
|
|
|
|
|
]
|
|
|
|
|
|
resources = [
|
|
|
|
|
|
ProblemResource(
|
|
|
|
|
|
code=str(e.get("code") or ""),
|
|
|
|
|
|
name=str(e.get("name") or e.get("code") or ""),
|
|
|
|
|
|
capabilities=list(e.get("capabilities") or []),
|
|
|
|
|
|
zone=e.get("zone"),
|
|
|
|
|
|
)
|
|
|
|
|
|
for e in (world.get("flexEquipment") or [])
|
|
|
|
|
|
if e.get("status", "RUNNING") in ("RUNNING", "IDLE", None) or True
|
|
|
|
|
|
]
|
|
|
|
|
|
# 过滤故障设备
|
|
|
|
|
|
resources = [
|
|
|
|
|
|
r for r in resources
|
|
|
|
|
|
if not any(
|
|
|
|
|
|
e.get("code") == r.code and e.get("status") == "MAINTENANCE"
|
|
|
|
|
|
for e in (world.get("flexEquipment") or [])
|
|
|
|
|
|
)
|
|
|
|
|
|
]
|
|
|
|
|
|
start = start_time or fmt_dt(add_minutes(today0(), 24 * 60))
|
|
|
|
|
|
meta: dict[str, Any] = {"orderCount": len(orders)}
|
|
|
|
|
|
if include_knowledge:
|
|
|
|
|
|
meta["knowledgeRefs"] = _knowledge_refs(world, orders, routings)
|
|
|
|
|
|
return SchedulingProblem(
|
|
|
|
|
|
problemId=f"P-{datetime.now().strftime('%Y%m%d%H%M%S')}",
|
|
|
|
|
|
track="flex", startTime=start, horizonHours=horizon_hours,
|
|
|
|
|
|
orders=orders, routings=routings, resources=resources,
|
|
|
|
|
|
meta=meta,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _knowledge_refs(world: dict[str, Any], orders: list[ProblemOrder],
|
|
|
|
|
|
routings: list[ProblemStep], top_k: int = 3) -> list[dict[str, Any]]:
|
|
|
|
|
|
"""按产品名/工序名检索知识库,产出带出处的命中摘要(供外部算法参考)。"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
from server.knowledge.assets import get_knowledge
|
|
|
|
|
|
from server.knowledge.retrieval import hybrid_search
|
|
|
|
|
|
units = get_knowledge().iter_search_units()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return []
|
|
|
|
|
|
materials = {m.get("code"): m for m in world.get("flexMaterials") or []}
|
|
|
|
|
|
queries: list[str] = []
|
|
|
|
|
|
for o in orders[:5]:
|
|
|
|
|
|
name = (materials.get(o.productCode) or {}).get("name") or o.productCode
|
|
|
|
|
|
queries.append(f"{name} 工艺")
|
|
|
|
|
|
refs: list[dict[str, Any]] = []
|
|
|
|
|
|
seen: set[str] = set()
|
|
|
|
|
|
for q in queries:
|
|
|
|
|
|
try:
|
|
|
|
|
|
hits = hybrid_search(units, q, top_k=top_k)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
continue
|
|
|
|
|
|
for h in hits or []:
|
|
|
|
|
|
key = f"{h.get('assetId')}#{h.get('chunkId') or ''}"
|
|
|
|
|
|
if key in seen:
|
|
|
|
|
|
continue
|
|
|
|
|
|
seen.add(key)
|
|
|
|
|
|
refs.append({
|
|
|
|
|
|
"query": q,
|
|
|
|
|
|
"assetId": h.get("assetId"), "title": h.get("title"),
|
|
|
|
|
|
"version": h.get("version"), "snippet": (h.get("snippet") or h.get("text") or "")[:200],
|
|
|
|
|
|
"score": h.get("score"),
|
|
|
|
|
|
})
|
|
|
|
|
|
return refs[:10]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def apply_flex_solution(world: dict[str, Any], solution: SchedulingSolution,
|
|
|
|
|
|
next_id, *, skill_id: str = "") -> dict[str, Any]:
|
|
|
|
|
|
"""把柔性 Solution 写入 flex* 草稿版本,返回摘要 dict(对齐 flex 排产结果字段)。"""
|
|
|
|
|
|
versions = world.setdefault("flexScheduleVersions", [])
|
|
|
|
|
|
vid = next_id("flexScheduleVersion") if callable(next_id) else next_id("flexScheduleVersion")
|
|
|
|
|
|
version_no = f"EXT{datetime.now().strftime('%Y%m%d')}-{vid:03d}"
|
|
|
|
|
|
# 按订单分组建虚拟产线
|
|
|
|
|
|
by_order: dict[str, list[SolutionOp]] = {}
|
|
|
|
|
|
for op in solution.operations:
|
|
|
|
|
|
by_order.setdefault(op.orderId, []).append(op)
|
|
|
|
|
|
|
|
|
|
|
|
orders_by_no = {str(o["orderNo"]): o for o in (world.get("flexOrders") or [])}
|
|
|
|
|
|
eq_by_code = {str(e.get("code")): e for e in (world.get("flexEquipment") or [])}
|
|
|
|
|
|
|
|
|
|
|
|
vls = world.setdefault("flexVirtualLines", [])
|
|
|
|
|
|
wos = world.setdefault("flexWorkOrders", [])
|
|
|
|
|
|
cfs = world.setdefault("flexConflicts", [])
|
|
|
|
|
|
|
|
|
|
|
|
vl_count = 0
|
|
|
|
|
|
wo_count = 0
|
|
|
|
|
|
for order_no, ops in by_order.items():
|
|
|
|
|
|
fo = orders_by_no.get(order_no) or {}
|
|
|
|
|
|
vl_id = next_id("flexVirtualLine")
|
|
|
|
|
|
ops_sorted = sorted(ops, key=lambda x: x.seq)
|
|
|
|
|
|
start = ops_sorted[0].start if ops_sorted else None
|
|
|
|
|
|
end = ops_sorted[-1].end if ops_sorted else None
|
|
|
|
|
|
vls.append({
|
|
|
|
|
|
"id": vl_id, "vlNo": f"VL-{vl_id:04d}", "versionId": vid,
|
|
|
|
|
|
"orderNo": order_no, "productCode": fo.get("productCode"),
|
|
|
|
|
|
"quantity": fo.get("quantity") or 1,
|
|
|
|
|
|
"plannedStart": start, "plannedEnd": end,
|
2026-08-11 00:54:05 +08:00
|
|
|
|
"assignments": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"seq": op.seq,
|
|
|
|
|
|
"operationCode": op.operationCode,
|
|
|
|
|
|
"equipmentCode": op.resourceCode,
|
|
|
|
|
|
"moldCode": None,
|
|
|
|
|
|
"start": op.start,
|
|
|
|
|
|
"end": op.end,
|
|
|
|
|
|
}
|
|
|
|
|
|
for op in ops_sorted
|
|
|
|
|
|
],
|
2026-07-28 02:12:46 +08:00
|
|
|
|
})
|
|
|
|
|
|
vl_count += 1
|
|
|
|
|
|
for op in ops_sorted:
|
|
|
|
|
|
eq = eq_by_code.get(op.resourceCode) or {}
|
|
|
|
|
|
wo_id = next_id("flexWorkOrder")
|
|
|
|
|
|
wos.append({
|
|
|
|
|
|
"id": wo_id, "orderNo": f"WO-{wo_id:04d}", "versionId": vid, "vlId": vl_id,
|
|
|
|
|
|
"flexOrderNo": order_no, "productCode": fo.get("productCode"),
|
|
|
|
|
|
"quantity": fo.get("quantity") or 1,
|
|
|
|
|
|
"operationCode": op.operationCode, "operationName": op.operationName or op.operationCode,
|
|
|
|
|
|
"seq": op.seq,
|
|
|
|
|
|
"equipmentId": eq.get("id"), "equipmentCode": op.resourceCode,
|
|
|
|
|
|
"equipmentName": eq.get("name") or op.resourceCode,
|
|
|
|
|
|
"zone": eq.get("zone"), "moldCode": None, "teamCode": None,
|
|
|
|
|
|
"changeoverMin": 0, "moveMin": 0, "runMin": op.runMin,
|
|
|
|
|
|
"plannedStartTime": op.start, "plannedEndTime": op.end,
|
|
|
|
|
|
"status": "PLANNED", "isBottleneck": False,
|
|
|
|
|
|
})
|
|
|
|
|
|
wo_count += 1
|
|
|
|
|
|
|
|
|
|
|
|
for c in solution.conflicts:
|
|
|
|
|
|
cfs.append({
|
|
|
|
|
|
"id": next_id("flexConflict"), "versionId": vid,
|
|
|
|
|
|
"conflictType": c.type, "orderNo": c.orderId,
|
|
|
|
|
|
"severity": c.severity, "description": c.description,
|
|
|
|
|
|
"suggestedSolution": "", "isResolved": False,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
on_time = 0
|
|
|
|
|
|
for order_no, ops in by_order.items():
|
|
|
|
|
|
fo = orders_by_no.get(order_no) or {}
|
|
|
|
|
|
due = fo.get("dueDate")
|
|
|
|
|
|
if due and ops:
|
|
|
|
|
|
end = max(op.end for op in ops)
|
|
|
|
|
|
if str(end)[:10] <= str(due):
|
|
|
|
|
|
on_time += 1
|
|
|
|
|
|
|
|
|
|
|
|
versions.append({
|
|
|
|
|
|
"id": vid, "versionNo": version_no, "status": "DRAFT",
|
|
|
|
|
|
"sortMode": "EXTERNAL", "createdAt": fmt_dt(datetime.now()),
|
2026-08-11 00:54:05 +08:00
|
|
|
|
"orderCount": len(by_order), "vlCount": vl_count, "woCount": wo_count,
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"conflictCount": len(solution.conflicts),
|
2026-08-11 00:54:05 +08:00
|
|
|
|
"totalTardiness": float((solution.kpi or {}).get("totalTardiness") or 0),
|
|
|
|
|
|
"solveStatus": solution.status,
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"avgUtilization": float((solution.kpi or {}).get("avgUtilization") or 0.5),
|
|
|
|
|
|
"onTimeCount": on_time,
|
|
|
|
|
|
"skillId": skill_id or solution.skillId,
|
|
|
|
|
|
"externalRunId": solution.runId,
|
|
|
|
|
|
"engineType": "EXTERNAL",
|
|
|
|
|
|
})
|
|
|
|
|
|
return {
|
|
|
|
|
|
"versionId": vid, "versionNo": version_no,
|
|
|
|
|
|
"vlCount": vl_count, "woCount": wo_count,
|
|
|
|
|
|
"conflictCount": len(solution.conflicts),
|
|
|
|
|
|
"avgUtilization": float((solution.kpi or {}).get("avgUtilization") or 0.5),
|
|
|
|
|
|
"onTimeCount": on_time, "sortMode": "EXTERNAL",
|
|
|
|
|
|
"window": "full", "makespan": None,
|
|
|
|
|
|
"bottleneck": [], "deferredCount": 0,
|
|
|
|
|
|
"skillId": skill_id or solution.skillId,
|
|
|
|
|
|
"runId": solution.runId,
|
|
|
|
|
|
}
|