aps-agent/server/aps_domain/rush.py

315 lines
13 KiB
Python
Raw Normal View History

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