aps-agent/server/aps_domain/params.py

180 lines
7.3 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-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")),
}