180 lines
7.3 KiB
Python
180 lines
7.3 KiB
Python
# ============================================================
|
||
# 排产参数(moduleId: domain-params, 可重生 ✅)
|
||
# OR-02:客户等级权重 / 目标权重;变更走 P2,只影响后续新版本
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
from copy import deepcopy
|
||
from typing import Any
|
||
|
||
World = dict[str, Any]
|
||
|
||
DEFAULT_LEVEL_WEIGHTS = {"VIP": 3.0, "A": 2.0, "B": 1.0, "C": 1.0}
|
||
DEFAULT_OBJECTIVE_WEIGHTS = {"tardiness": 0.4, "cost": 0.3, "utilization": 0.2, "balance": 0.1}
|
||
|
||
|
||
def default_schedule_params() -> dict[str, Any]:
|
||
return {
|
||
"defaultEngine": "RULE",
|
||
"planningHorizonDays": 14,
|
||
"timeGranularityMinutes": 15,
|
||
"deliveryBufferRatio": 0.95,
|
||
"weights": dict(DEFAULT_OBJECTIVE_WEIGHTS),
|
||
"customerLevelWeights": dict(DEFAULT_LEVEL_WEIGHTS),
|
||
"freezeWindowHours": 24,
|
||
"cpTimeLimitSeconds": 8, # SC-03 CP-SAT 时限
|
||
}
|
||
|
||
|
||
def get_schedule_params(world: World) -> dict[str, Any]:
|
||
"""合并默认值后的排产参数投影(P0)。"""
|
||
base = default_schedule_params()
|
||
cur = world.get("scheduleParams") or {}
|
||
out = {**base, **cur}
|
||
out["weights"] = {**DEFAULT_OBJECTIVE_WEIGHTS, **(cur.get("weights") or {})}
|
||
out["customerLevelWeights"] = {
|
||
**DEFAULT_LEVEL_WEIGHTS,
|
||
**(cur.get("customerLevelWeights") or {}),
|
||
}
|
||
return out
|
||
|
||
|
||
def level_weight(world: World, level: str | None) -> float:
|
||
"""取客户等级排序权重(越高越优先排)。"""
|
||
weights = get_schedule_params(world)["customerLevelWeights"]
|
||
return float(weights.get(str(level or "C").upper(), 1.0))
|
||
|
||
|
||
def normalize_params_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||
"""校验并规范化 params.update 载荷。"""
|
||
out: dict[str, Any] = {}
|
||
if payload.get("resetDefaults"):
|
||
out["resetDefaults"] = True
|
||
return out
|
||
|
||
if "customerLevelWeights" in payload and payload["customerLevelWeights"] is not None:
|
||
raw = payload["customerLevelWeights"]
|
||
if not isinstance(raw, dict):
|
||
raise ValueError("customerLevelWeights 必须是对象")
|
||
levels: dict[str, float] = {}
|
||
for k, v in raw.items():
|
||
key = str(k).upper()
|
||
if key not in DEFAULT_LEVEL_WEIGHTS:
|
||
raise ValueError(f"不支持的客户等级:{k}")
|
||
try:
|
||
val = float(v)
|
||
except (TypeError, ValueError) as exc:
|
||
raise ValueError(f"{key} 权重必须是数字") from exc
|
||
if val < 0 or val > 100:
|
||
raise ValueError(f"{key} 权重须在 0~100")
|
||
levels[key] = val
|
||
out["customerLevelWeights"] = levels
|
||
|
||
if "weights" in payload and payload["weights"] is not None:
|
||
raw = payload["weights"]
|
||
if not isinstance(raw, dict):
|
||
raise ValueError("weights 必须是对象")
|
||
obj: dict[str, float] = {}
|
||
for k, v in raw.items():
|
||
key = str(k)
|
||
if key not in DEFAULT_OBJECTIVE_WEIGHTS:
|
||
raise ValueError(f"不支持的目标权重键:{k}")
|
||
try:
|
||
val = float(v)
|
||
except (TypeError, ValueError) as exc:
|
||
raise ValueError(f"{key} 权重必须是数字") from exc
|
||
if val < 0 or val > 1:
|
||
raise ValueError(f"{key} 目标权重须在 0~1")
|
||
obj[key] = val
|
||
out["weights"] = obj
|
||
|
||
if "planningHorizonDays" in payload and payload["planningHorizonDays"] is not None:
|
||
days = int(payload["planningHorizonDays"])
|
||
if days < 1 or days > 90:
|
||
raise ValueError("planningHorizonDays 须在 1~90")
|
||
out["planningHorizonDays"] = days
|
||
|
||
if "defaultEngine" in payload and payload["defaultEngine"] is not None:
|
||
eng = str(payload["defaultEngine"]).upper()
|
||
if eng not in ("RULE", "CP", "GA", "HYBRID", "OPTIMIZE"):
|
||
raise ValueError("defaultEngine 须为 RULE/CP/GA/HYBRID/OPTIMIZE")
|
||
out["defaultEngine"] = eng
|
||
|
||
if "cpTimeLimitSeconds" in payload and payload["cpTimeLimitSeconds"] is not None:
|
||
sec = float(payload["cpTimeLimitSeconds"])
|
||
if sec < 0.5 or sec > 120:
|
||
raise ValueError("cpTimeLimitSeconds 须在 0.5~120")
|
||
out["cpTimeLimitSeconds"] = sec
|
||
|
||
if not out:
|
||
raise ValueError("未提供可更新的排产参数字段")
|
||
return out
|
||
|
||
|
||
def confirmation_for_params_update(world: World, payload: dict[str, Any]) -> tuple[str, list[str]]:
|
||
"""生成 params.update 确认卡文案。"""
|
||
norm = normalize_params_payload(payload)
|
||
cur = get_schedule_params(world)
|
||
if norm.get("resetDefaults"):
|
||
return "恢复排产参数默认值", [
|
||
"客户等级权重恢复 VIP=3 / A=2 / B=1 / C=1",
|
||
"目标权重恢复交期/成本/利用率/均衡默认比",
|
||
"只影响后续新排产版本,不回写历史版本",
|
||
]
|
||
lines: list[str] = []
|
||
if "customerLevelWeights" in norm:
|
||
before = cur["customerLevelWeights"]
|
||
after = {**before, **norm["customerLevelWeights"]}
|
||
parts = [f"{k}:{before.get(k)}→{after[k]}" for k in ("VIP", "A", "B", "C")]
|
||
lines.append("客户等级权重:" + " / ".join(parts))
|
||
if "weights" in norm:
|
||
before = cur["weights"]
|
||
after = {**before, **norm["weights"]}
|
||
parts = [f"{k}:{before.get(k)}→{after[k]}" for k in after]
|
||
lines.append("目标权重:" + " / ".join(parts))
|
||
if "planningHorizonDays" in norm:
|
||
lines.append(f"展望期:{cur['planningHorizonDays']}→{norm['planningHorizonDays']} 天")
|
||
if "defaultEngine" in norm:
|
||
lines.append(f"默认引擎:{cur.get('defaultEngine')}→{norm['defaultEngine']}")
|
||
if "cpTimeLimitSeconds" in norm:
|
||
lines.append(f"CP 时限:{cur.get('cpTimeLimitSeconds')}→{norm['cpTimeLimitSeconds']} 秒")
|
||
lines.append("只影响后续新排产版本,不回写历史版本")
|
||
return "更新排产参数", lines
|
||
|
||
|
||
def apply_params_update(world: World, payload: dict[str, Any]) -> dict[str, Any]:
|
||
"""写入 scheduleParams(P2 批准后调用)。"""
|
||
norm = normalize_params_payload(payload)
|
||
before = deepcopy(get_schedule_params(world))
|
||
if norm.get("resetDefaults"):
|
||
world["scheduleParams"] = default_schedule_params()
|
||
else:
|
||
sp = world.setdefault("scheduleParams", default_schedule_params())
|
||
if "customerLevelWeights" in norm:
|
||
sp["customerLevelWeights"] = {
|
||
**DEFAULT_LEVEL_WEIGHTS,
|
||
**(sp.get("customerLevelWeights") or {}),
|
||
**norm["customerLevelWeights"],
|
||
}
|
||
if "weights" in norm:
|
||
sp["weights"] = {
|
||
**DEFAULT_OBJECTIVE_WEIGHTS,
|
||
**(sp.get("weights") or {}),
|
||
**norm["weights"],
|
||
}
|
||
if "planningHorizonDays" in norm:
|
||
sp["planningHorizonDays"] = norm["planningHorizonDays"]
|
||
if "defaultEngine" in norm:
|
||
sp["defaultEngine"] = norm["defaultEngine"]
|
||
if "cpTimeLimitSeconds" in norm:
|
||
sp["cpTimeLimitSeconds"] = norm["cpTimeLimitSeconds"]
|
||
after = get_schedule_params(world)
|
||
return {
|
||
"kind": "SCHEDULE_PARAMS",
|
||
"id": "scheduleParams",
|
||
"name": "排产参数",
|
||
"before": before,
|
||
"after": after,
|
||
"reset": bool(norm.get("resetDefaults")),
|
||
}
|