2026-07-23 13:38:43 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
# 紧急插单专用流(moduleId: domain-rush, OR-04 首切片,可重生 ✅)
|
|
|
|
|
|
# 规则:evaluate 全程沙盒不碰主干;apply 写订单+试排草稿,P2 写前自动建档
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import copy
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
from server.aps_domain.orders import (
|
|
|
|
|
|
SCHEDULABLE_STATUSES,
|
|
|
|
|
|
apply_order_action,
|
|
|
|
|
|
find_order,
|
|
|
|
|
|
find_product_by_hint,
|
|
|
|
|
|
normalize_order_payload,
|
|
|
|
|
|
)
|
|
|
|
|
|
from server.contracts import ScheduleResult, UIBlock
|
|
|
|
|
|
from server.engines import get_engine
|
|
|
|
|
|
from server.engines.base import EngineParams
|
|
|
|
|
|
from server.timeutil import add_minutes, fmt_date, today0
|
|
|
|
|
|
|
|
|
|
|
|
World = dict[str, Any]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sandbox_counter():
|
|
|
|
|
|
counters: dict[str, int] = {}
|
|
|
|
|
|
|
|
|
|
|
|
def next_id(kind: str) -> int:
|
|
|
|
|
|
counters[kind] = counters.get(kind, 0) + 1000000
|
|
|
|
|
|
return counters[kind]
|
|
|
|
|
|
|
|
|
|
|
|
return next_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _engine_params(world: World, strategy: str) -> EngineParams:
|
|
|
|
|
|
from server.aps_domain.constraints import engine_constraint_flags
|
|
|
|
|
|
from server.aps_domain.params import get_schedule_params
|
|
|
|
|
|
sp = get_schedule_params(world)
|
|
|
|
|
|
return EngineParams(
|
|
|
|
|
|
orderIds=[],
|
|
|
|
|
|
engineType="RULE",
|
|
|
|
|
|
strategyTemplate=strategy,
|
|
|
|
|
|
planningHorizonDays=int(sp.get("planningHorizonDays") or 14),
|
|
|
|
|
|
startDate=fmt_date(add_minutes(today0(), 24 * 60)),
|
|
|
|
|
|
constraints=engine_constraint_flags(world),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _promised_by_so(world: World) -> dict[int, str]:
|
|
|
|
|
|
"""销售订单 → 最晚计划完工日。"""
|
|
|
|
|
|
out: dict[int, str] = {}
|
|
|
|
|
|
for po in world.get("productionOrders", []):
|
|
|
|
|
|
so_id = po.get("salesOrderId")
|
|
|
|
|
|
end = po.get("plannedEndDate")
|
|
|
|
|
|
if so_id is None or not end:
|
|
|
|
|
|
continue
|
|
|
|
|
|
prev = out.get(so_id)
|
|
|
|
|
|
if prev is None or end > prev:
|
|
|
|
|
|
out[int(so_id)] = end
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _run_sandbox(world: World, strategy: str) -> tuple[ScheduleResult, dict[int, str]]:
|
|
|
|
|
|
sandbox = copy.deepcopy(world)
|
|
|
|
|
|
# 清空排程产物,避免基准版本残留干扰对比
|
|
|
|
|
|
sandbox["productionOrders"] = []
|
|
|
|
|
|
sandbox["workOrders"] = []
|
|
|
|
|
|
sandbox["conflicts"] = []
|
|
|
|
|
|
result = get_engine("RULE").solve(sandbox, _engine_params(sandbox, strategy), _sandbox_counter())
|
|
|
|
|
|
return result, _promised_by_so(sandbox)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resolve_rush_payload(world: World, payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
|
"""规范化插单载荷:既有订单加急,或新建急单字段。"""
|
|
|
|
|
|
order_no = str(payload.get("orderNo") or "").strip().upper()
|
|
|
|
|
|
if order_no:
|
|
|
|
|
|
existing = find_order(world, order_no=order_no)
|
|
|
|
|
|
if existing is None:
|
|
|
|
|
|
raise ValueError(f"订单 {order_no} 不存在")
|
|
|
|
|
|
if existing["status"] not in SCHEDULABLE_STATUSES | {"SUBMITTED", "CHANGED", "DRAFT"}:
|
|
|
|
|
|
raise ValueError(f"订单 {order_no} 状态 {existing['status']} 不可插单评估")
|
|
|
|
|
|
first = (existing.get("items") or [{}])[0]
|
|
|
|
|
|
return {
|
|
|
|
|
|
"mode": "existing",
|
|
|
|
|
|
"id": existing["id"],
|
|
|
|
|
|
"orderNo": existing["orderNo"],
|
|
|
|
|
|
"customerName": existing["customerName"],
|
|
|
|
|
|
"customerLevel": existing.get("customerLevel") or "A",
|
|
|
|
|
|
"productId": first.get("productId"),
|
|
|
|
|
|
"productCode": first.get("productCode"),
|
|
|
|
|
|
"productName": first.get("productName"),
|
|
|
|
|
|
"quantity": first.get("quantity"),
|
|
|
|
|
|
"deliveryDate": existing["deliveryDate"],
|
|
|
|
|
|
"priority": min(int(existing.get("priority") or 5), 2),
|
|
|
|
|
|
"status": "APPROVED",
|
|
|
|
|
|
"isRush": True,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
hint = str(payload.get("productCode") or payload.get("productName") or "").strip()
|
|
|
|
|
|
product_id = payload.get("productId")
|
|
|
|
|
|
if not product_id and hint:
|
|
|
|
|
|
product = find_product_by_hint(world, hint)
|
|
|
|
|
|
if product is None:
|
|
|
|
|
|
raise ValueError(f"找不到成品:{hint}")
|
|
|
|
|
|
product_id = product["id"]
|
|
|
|
|
|
if not product_id:
|
|
|
|
|
|
raise ValueError("请指定 productId / productCode,或给出已有 orderNo")
|
|
|
|
|
|
|
|
|
|
|
|
raw = {
|
|
|
|
|
|
"customerName": str(payload.get("customerName") or "紧急插单客户").strip() or "紧急插单客户",
|
|
|
|
|
|
"customerLevel": str(payload.get("customerLevel") or "VIP").upper(),
|
|
|
|
|
|
"deliveryDate": payload.get("deliveryDate") or fmt_date(add_minutes(today0(), 5 * 24 * 60)),
|
|
|
|
|
|
"priority": int(payload.get("priority") or 1),
|
|
|
|
|
|
"status": "APPROVED",
|
|
|
|
|
|
"productId": int(product_id),
|
|
|
|
|
|
"quantity": int(payload.get("quantity") or 100),
|
|
|
|
|
|
"isRush": True,
|
|
|
|
|
|
"specialRequirements": str(payload.get("specialRequirements") or "OR-04 紧急插单"),
|
|
|
|
|
|
}
|
|
|
|
|
|
p = normalize_order_payload(world, raw)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"mode": "new",
|
|
|
|
|
|
**p,
|
|
|
|
|
|
"productCode": p.get("productCode"),
|
|
|
|
|
|
"productName": p.get("productName"),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def inject_rush(world: World, rush: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
|
"""把急单写入世界(沙盒或主干);返回订单对象。"""
|
|
|
|
|
|
if rush["mode"] == "existing":
|
|
|
|
|
|
order = find_order(world, order_id=rush["id"])
|
|
|
|
|
|
if order is None:
|
|
|
|
|
|
raise ValueError("目标订单不存在")
|
|
|
|
|
|
order["isRush"] = True
|
|
|
|
|
|
order["rushStrategy"] = "STRATEGY_SHIFT"
|
|
|
|
|
|
order["priority"] = min(int(order.get("priority") or 5), 2)
|
|
|
|
|
|
if order["status"] not in SCHEDULABLE_STATUSES:
|
|
|
|
|
|
order["status"] = "APPROVED"
|
|
|
|
|
|
return order
|
|
|
|
|
|
|
|
|
|
|
|
applied = apply_order_action(world, _sandbox_counter(), "order.upsert", {
|
|
|
|
|
|
"customerName": rush["customerName"],
|
|
|
|
|
|
"customerLevel": rush["customerLevel"],
|
|
|
|
|
|
"deliveryDate": rush["deliveryDate"],
|
|
|
|
|
|
"priority": rush["priority"],
|
|
|
|
|
|
"status": "APPROVED",
|
|
|
|
|
|
"productId": rush["productId"],
|
|
|
|
|
|
"quantity": rush["quantity"],
|
|
|
|
|
|
"isRush": True,
|
|
|
|
|
|
"specialRequirements": rush.get("specialRequirements") or "OR-04 紧急插单",
|
|
|
|
|
|
})
|
|
|
|
|
|
return applied["order"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def inject_rush_trunk(world: World, next_id, rush: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
|
"""主干写入急单(使用真实发号器)。"""
|
|
|
|
|
|
if rush["mode"] == "existing":
|
|
|
|
|
|
order = find_order(world, order_id=rush["id"])
|
|
|
|
|
|
if order is None:
|
|
|
|
|
|
raise ValueError("目标订单不存在")
|
|
|
|
|
|
order["isRush"] = True
|
|
|
|
|
|
order["rushStrategy"] = "STRATEGY_SHIFT"
|
|
|
|
|
|
order["priority"] = min(int(order.get("priority") or 5), 2)
|
|
|
|
|
|
if order["status"] not in SCHEDULABLE_STATUSES:
|
|
|
|
|
|
order["status"] = "APPROVED"
|
|
|
|
|
|
return order
|
|
|
|
|
|
applied = apply_order_action(world, next_id, "order.upsert", {
|
|
|
|
|
|
"customerName": rush["customerName"],
|
|
|
|
|
|
"customerLevel": rush["customerLevel"],
|
|
|
|
|
|
"deliveryDate": rush["deliveryDate"],
|
|
|
|
|
|
"priority": rush["priority"],
|
|
|
|
|
|
"status": "APPROVED",
|
|
|
|
|
|
"productId": rush["productId"],
|
|
|
|
|
|
"quantity": rush["quantity"],
|
|
|
|
|
|
"isRush": True,
|
|
|
|
|
|
"specialRequirements": rush.get("specialRequirements") or "OR-04 紧急插单",
|
|
|
|
|
|
})
|
|
|
|
|
|
return applied["order"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def evaluate_rush(world: World, payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
|
"""插单影响快评(P1):双沙盒对比,主干零接触。"""
|
|
|
|
|
|
strategy = str(payload.get("strategy") or "DELIVERY_FIRST").upper()
|
|
|
|
|
|
if strategy not in ("DELIVERY_FIRST", "CAPACITY_BALANCE", "COMPREHENSIVE"):
|
|
|
|
|
|
strategy = "DELIVERY_FIRST"
|
|
|
|
|
|
|
|
|
|
|
|
rush = resolve_rush_payload(world, payload)
|
|
|
|
|
|
base_result, base_promised = _run_sandbox(world, strategy)
|
|
|
|
|
|
|
|
|
|
|
|
rush_world = copy.deepcopy(world)
|
|
|
|
|
|
rush_order = inject_rush(rush_world, rush)
|
|
|
|
|
|
rush_result, rush_promised = _run_sandbox(rush_world, strategy)
|
|
|
|
|
|
|
|
|
|
|
|
affected: list[dict[str, Any]] = []
|
|
|
|
|
|
for so in world.get("salesOrders", []):
|
|
|
|
|
|
so_id = so["id"]
|
|
|
|
|
|
if so_id == rush_order.get("id"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
b, r = base_promised.get(so_id), rush_promised.get(so_id)
|
|
|
|
|
|
if b and r and r > b:
|
|
|
|
|
|
affected.append({
|
|
|
|
|
|
"orderNo": so["orderNo"],
|
|
|
|
|
|
"customerName": so["customerName"],
|
|
|
|
|
|
"beforeEnd": b,
|
|
|
|
|
|
"afterEnd": r,
|
|
|
|
|
|
})
|
|
|
|
|
|
affected.sort(key=lambda x: x["afterEnd"], reverse=True)
|
|
|
|
|
|
|
|
|
|
|
|
delay_delta = round(rush_result.totalTardiness - base_result.totalTardiness, 1)
|
|
|
|
|
|
conflict_delta = rush_result.conflictCount - base_result.conflictCount
|
|
|
|
|
|
impact = {
|
|
|
|
|
|
"evalId": uuid.uuid4().hex[:10],
|
|
|
|
|
|
"strategy": strategy,
|
|
|
|
|
|
"mode": rush["mode"],
|
|
|
|
|
|
"rushOrder": {
|
|
|
|
|
|
"orderNo": rush_order.get("orderNo"),
|
|
|
|
|
|
"id": rush_order.get("id"),
|
|
|
|
|
|
"customerName": rush_order.get("customerName"),
|
|
|
|
|
|
"productCode": (rush_order.get("items") or [{}])[0].get("productCode") or rush.get("productCode"),
|
|
|
|
|
|
"productName": (rush_order.get("items") or [{}])[0].get("productName") or rush.get("productName"),
|
|
|
|
|
|
"quantity": (rush_order.get("items") or [{}])[0].get("quantity") or rush.get("quantity"),
|
|
|
|
|
|
"deliveryDate": rush_order.get("deliveryDate"),
|
|
|
|
|
|
"isRush": True,
|
|
|
|
|
|
"promisedEnd": rush_promised.get(rush_order["id"]),
|
|
|
|
|
|
},
|
|
|
|
|
|
"baseline": {
|
|
|
|
|
|
"orderCount": base_result.orderCount,
|
|
|
|
|
|
"conflictCount": base_result.conflictCount,
|
|
|
|
|
|
"totalTardiness": round(base_result.totalTardiness, 1),
|
|
|
|
|
|
"avgUtilization": round(base_result.avgUtilization, 3),
|
|
|
|
|
|
},
|
|
|
|
|
|
"after": {
|
|
|
|
|
|
"orderCount": rush_result.orderCount,
|
|
|
|
|
|
"conflictCount": rush_result.conflictCount,
|
|
|
|
|
|
"totalTardiness": round(rush_result.totalTardiness, 1),
|
|
|
|
|
|
"avgUtilization": round(rush_result.avgUtilization, 3),
|
|
|
|
|
|
},
|
|
|
|
|
|
"affectedOrderCount": len(affected),
|
|
|
|
|
|
"delayDelta": delay_delta,
|
|
|
|
|
|
"conflictDelta": conflict_delta,
|
|
|
|
|
|
"affectedOrders": affected[:12],
|
|
|
|
|
|
"payload": rush, # apply 复用
|
|
|
|
|
|
}
|
2026-08-20 11:39:21 +08:00
|
|
|
|
# OR-04 LNS 扩展:固定窗口内最小扰动 / 超阈值升级全量重排(P1 沙盒,可经 payload 关闭)
|
|
|
|
|
|
if payload.get("lns") is not False:
|
|
|
|
|
|
from server.aps_domain.lns import lns_local_repair
|
|
|
|
|
|
impact["lns"] = lns_local_repair(world, payload, window=payload.get("window"))
|
|
|
|
|
|
else:
|
|
|
|
|
|
impact["lns"] = None
|
2026-07-23 13:38:43 +08:00
|
|
|
|
return impact
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def impact_to_block(impact: dict[str, Any]) -> UIBlock:
|
|
|
|
|
|
"""对话侧影响摘要块(采用走口令「采用插单」或订单面板确认)。"""
|
|
|
|
|
|
rush = impact["rushOrder"]
|
|
|
|
|
|
return UIBlock(
|
|
|
|
|
|
blockId=f"rush-{impact['evalId']}",
|
|
|
|
|
|
type="text",
|
|
|
|
|
|
props={
|
|
|
|
|
|
"kind": "rush-impact",
|
|
|
|
|
|
"impact": impact,
|
|
|
|
|
|
"title": "插单影响快评",
|
|
|
|
|
|
"text": (
|
|
|
|
|
|
f"急单:{rush.get('orderNo') or '(新建)'} · {rush.get('productName')} × {rush.get('quantity')}\n"
|
|
|
|
|
|
f"受影响订单 {impact['affectedOrderCount']} 条 · 延迟变化 {impact['delayDelta']:+}h · 冲突变化 {impact['conflictDelta']:+}\n"
|
|
|
|
|
|
f"策略:{impact['strategy']}(沙盒,未改主干)\n"
|
|
|
|
|
|
"确认后请说「采用插单」。"
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
actions=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def format_impact_text(impact: dict[str, Any]) -> str:
|
|
|
|
|
|
rush = impact["rushOrder"]
|
|
|
|
|
|
lines = [
|
|
|
|
|
|
f"插单快评完成(沙盒,未改主干)· 策略 {_strategy_cn(impact['strategy'])}",
|
|
|
|
|
|
f"急单:{rush.get('customerName')} · {rush.get('productName')} × {rush.get('quantity')} · 交期 {rush.get('deliveryDate')}"
|
|
|
|
|
|
+ (f" · 预计完工 {rush.get('promisedEnd')}" if rush.get("promisedEnd") else ""),
|
|
|
|
|
|
f"影响:受波及订单 {impact['affectedOrderCount']} 条 · 总延迟 {impact['delayDelta']:+}h · 冲突 {impact['conflictDelta']:+}",
|
|
|
|
|
|
f"基准 → 插单后:订单 {impact['baseline']['orderCount']}→{impact['after']['orderCount']} · "
|
|
|
|
|
|
f"延迟 {impact['baseline']['totalTardiness']}→{impact['after']['totalTardiness']}h · "
|
|
|
|
|
|
f"冲突 {impact['baseline']['conflictCount']}→{impact['after']['conflictCount']}",
|
|
|
|
|
|
]
|
|
|
|
|
|
if impact["affectedOrders"]:
|
|
|
|
|
|
sample = "、".join(a["orderNo"] for a in impact["affectedOrders"][:5])
|
|
|
|
|
|
lines.append(f"延期样例:{sample}" + ("…" if len(impact["affectedOrders"]) > 5 else ""))
|
|
|
|
|
|
lines.append("确认无误后说「采用插单」或点「采用此插单」(P2,写前自动建档)。")
|
|
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _strategy_cn(s: str) -> str:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"DELIVERY_FIRST": "交期优先",
|
|
|
|
|
|
"CAPACITY_BALANCE": "产能均衡",
|
|
|
|
|
|
"COMPREHENSIVE": "综合优化",
|
|
|
|
|
|
}.get(s, s)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def apply_rush(world: World, next_id, payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
|
"""采用插单:写主干订单池 + 生成 DRAFT 排产版本。调用方负责 checkpoint/审计。"""
|
|
|
|
|
|
rush = resolve_rush_payload(world, payload.get("payload") or payload)
|
|
|
|
|
|
strategy = str(payload.get("strategy") or "DELIVERY_FIRST").upper()
|
|
|
|
|
|
order = inject_rush_trunk(world, next_id, rush)
|
|
|
|
|
|
# 正式试排写草稿版本
|
|
|
|
|
|
from server.aps_domain.constraints import engine_constraint_flags
|
|
|
|
|
|
from server.aps_domain.params import get_schedule_params
|
|
|
|
|
|
sp = get_schedule_params(world)
|
|
|
|
|
|
params = EngineParams(
|
|
|
|
|
|
orderIds=[],
|
|
|
|
|
|
engineType="RULE",
|
|
|
|
|
|
strategyTemplate=strategy if strategy in ("DELIVERY_FIRST", "CAPACITY_BALANCE", "COMPREHENSIVE") else "DELIVERY_FIRST",
|
|
|
|
|
|
planningHorizonDays=int(sp.get("planningHorizonDays") or 14),
|
|
|
|
|
|
startDate=fmt_date(add_minutes(today0(), 24 * 60)),
|
|
|
|
|
|
constraints=engine_constraint_flags(world),
|
|
|
|
|
|
)
|
|
|
|
|
|
result = get_engine("RULE").solve(world, params, next_id)
|
|
|
|
|
|
return {"order": order, "result": result, "strategy": params.strategyTemplate}
|