aps-agent/server/aps_domain/rush.py

330 lines
14 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']} 张订单;调整后总延期 "
f"{impact['delayDelta']:+g} 小时,冲突变化 {impact['conflictDelta']:+d} 项。\n"
"这只是试算,现有计划没有改动;你确认后才生成一版待发布的调整方案。"
),
},
actions=[],
)
def format_impact_text(impact: dict[str, Any]) -> str:
rush = impact["rushOrder"]
lines = [
"插单影响评估完成。本次只做试算,现有计划没有改动。",
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']} 张订单;总延期变化 "
f"{impact['delayDelta']:+g} 小时,冲突变化 {impact['conflictDelta']:+d} 项。",
f"调整前 → 调整后:订单 {impact['baseline']['orderCount']}→{impact['after']['orderCount']} 张 · "
f"总延期 {impact['baseline']['totalTardiness']}→{impact['after']['totalTardiness']} 小时 · "
f"冲突 {impact['baseline']['conflictCount']}→{impact['after']['conflictCount']} 项。",
]
if impact["affectedOrders"]:
lines.append("可能受影响的订单:")
for item in impact["affectedOrders"][:5]:
lines.append(
f"· {item['orderNo']}({item['customerName']}):原计划 {item['beforeEnd']},"
f"调整后预计 {item['afterEnd']}"
)
if len(impact["affectedOrders"]) > 5:
lines.append(f"· 其余 {len(impact['affectedOrders']) - 5} 张订单也会有排期变化。")
else:
lines.append("按当前订单、设备、工艺和日历数据,没有发现其他订单被顺延。")
lines.append("如果确认采用,我会先生成一版待发布的调整方案,不会直接改车间执行。")
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}