aps-agent/server/aps_domain/rush.py

321 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 紧急插单专用流(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 复用
}
# 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
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}