286 lines
11 KiB
Python
286 lines
11 KiB
Python
# ============================================================
|
||
# 中性排产问题/解 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
|
||
|
||
from server.timeutil import add_minutes, fmt_dt, today0
|
||
|
||
|
||
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
|
||
orders: list[ProblemOrder] # schema required
|
||
routings: list[ProblemStep] # schema required
|
||
resources: list[ProblemResource] # schema required
|
||
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
|
||
operations: list[SolutionOp] # schema required
|
||
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,
|
||
"assignments": [
|
||
{
|
||
"seq": op.seq,
|
||
"operationCode": op.operationCode,
|
||
"equipmentCode": op.resourceCode,
|
||
"moldCode": None,
|
||
"start": op.start,
|
||
"end": op.end,
|
||
}
|
||
for op in ops_sorted
|
||
],
|
||
})
|
||
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()),
|
||
"orderCount": len(by_order), "vlCount": vl_count, "woCount": wo_count,
|
||
"conflictCount": len(solution.conflicts),
|
||
"totalTardiness": float((solution.kpi or {}).get("totalTardiness") or 0),
|
||
"solveStatus": solution.status,
|
||
"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,
|
||
}
|