aps-agent/server/aps_domain/planning.py

976 lines
36 KiB
Python
Raw Normal View History

# ============================================================
# 计划层 AP(moduleId: domain-planning, PL-01~06,可重生 ✅)
# 分桶 / 粗能力 / 可行性 / 削峰 / 产供方向决策(只读)
# ============================================================
from __future__ import annotations
from calendar import monthrange
from datetime import datetime, timedelta
from typing import Any
from server.aps_domain.orders import SCHEDULABLE_STATUSES
from server.timeutil import add_minutes, fmt_date, parse_dt, today0
World = dict[str, Any]
BUCKET_MODES = {"DAY", "WEEK", "MONTH", "HYBRID"}
CAPACITY_MODES = {"FINITE", "INFINITE"}
WARN_RATIO = 0.85
def _as_date(s: str) -> datetime:
return parse_dt(s[:10] + " 00:00")
def _monday(d: datetime) -> datetime:
return d - timedelta(days=d.weekday())
def _month_start(d: datetime) -> datetime:
return d.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
def _add_days(d: datetime, n: int) -> datetime:
return add_minutes(d, n * 24 * 60)
def _workdays_inclusive(start: datetime, end: datetime) -> int:
"""桶内工作日数(周一~周五,含起止)。"""
if end < start:
return 0
n = 0
cur = start
while cur <= end:
if cur.weekday() < 5:
n += 1
cur = _add_days(cur, 1)
return n
def daily_capacity(world: World) -> float:
"""ACTIVE 产线日产能合计(件/天,含效率因子)。"""
total = 0.0
for line in world.get("lines", []):
if line.get("status") != "ACTIVE":
continue
cap = float(line.get("capacityPerDay") or 0)
eff = float(line.get("efficiencyFactor") if line.get("efficiencyFactor") is not None else 1.0)
total += cap * eff
return round(total, 1)
def _bucket_spec(kind: str, start: datetime, end: datetime, label: str | None = None) -> dict[str, Any]:
return {
"kind": kind,
"key": f"{kind}:{fmt_date(start)}",
"label": label or _default_label(kind, start, end),
"start": fmt_date(start),
"end": fmt_date(end),
"workdays": _workdays_inclusive(start, end),
}
def _default_label(kind: str, start: datetime, end: datetime) -> str:
if kind == "DAY":
return start.strftime("%m-%d")
if kind == "WEEK":
iso = start.isocalendar()
return f"W{iso.week:02d}"
if kind == "MONTH":
return start.strftime("%Y-%m")
return f"{fmt_date(start)}~{fmt_date(end)}"
def _iter_day_buckets(start: datetime, days: int) -> list[dict[str, Any]]:
out = []
for i in range(max(0, days)):
d = _add_days(start, i)
out.append(_bucket_spec("DAY", d, d))
return out
def _iter_week_buckets(start: datetime, end: datetime) -> list[dict[str, Any]]:
out = []
cur = _monday(start)
last = end
while cur <= last:
week_end = _add_days(cur, 6)
# 裁剪到展望窗
b_start = max(cur, start)
b_end = min(week_end, last)
if b_start <= b_end:
out.append(_bucket_spec("WEEK", b_start, b_end))
cur = _add_days(cur, 7)
return out
def _iter_month_buckets(start: datetime, end: datetime) -> list[dict[str, Any]]:
out = []
cur = _month_start(start)
last = end
while cur <= last:
last_day = monthrange(cur.year, cur.month)[1]
month_end = cur.replace(day=last_day)
b_start = max(cur, start)
b_end = min(month_end, last)
if b_start <= b_end:
out.append(_bucket_spec("MONTH", b_start, b_end))
# 下一月
if cur.month == 12:
cur = cur.replace(year=cur.year + 1, month=1, day=1)
else:
cur = cur.replace(month=cur.month + 1, day=1)
return out
def build_bucket_skeleton(
*,
mode: str = "HYBRID",
start_date: str | None = None,
horizon_days: int = 90,
) -> list[dict[str, Any]]:
"""生成空分桶骨架(无需求/能力数字)。"""
mode = (mode or "HYBRID").upper()
if mode not in BUCKET_MODES:
raise ValueError("mode 必须是 DAY/WEEK/MONTH/HYBRID")
horizon_days = max(1, min(int(horizon_days or 90), 366))
start = _as_date(start_date) if start_date else today0()
end = _add_days(start, horizon_days - 1)
if mode == "DAY":
return _iter_day_buckets(start, horizon_days)
if mode == "WEEK":
return _iter_week_buckets(start, end)
if mode == "MONTH":
return _iter_month_buckets(start, end)
# HYBRID:近 7 日 → 随后约 4 周按周 → 其余按月(对齐康尼短中长期口径)
near_days = min(7, horizon_days)
buckets = _iter_day_buckets(start, near_days)
if horizon_days <= near_days:
return buckets
mid_start = _add_days(start, near_days)
mid_span = min(28, horizon_days - near_days) # ~4 周
mid_end = _add_days(mid_start, mid_span - 1)
buckets.extend(_iter_week_buckets(mid_start, mid_end))
if horizon_days > near_days + mid_span:
far_start = _add_days(mid_end, 1)
buckets.extend(_iter_month_buckets(far_start, end))
return buckets
def _in_bucket(date_s: str, bucket: dict[str, Any]) -> bool:
d = date_s[:10]
return bucket["start"] <= d <= bucket["end"]
def _collect_demands(world: World, *, include_forecast: bool) -> list[dict[str, Any]]:
"""收集待分桶需求点:确定订单 + ACTIVE 预测。"""
points: list[dict[str, Any]] = []
for so in world.get("salesOrders", []):
if so.get("status") not in SCHEDULABLE_STATUSES:
continue
due = str(so.get("deliveryDate") or "")[:10]
if not due:
continue
for item in so.get("items") or []:
if item.get("status") in ("CANCELLED", "COMPLETED"):
continue
qty = int(item.get("quantity") or 0)
if qty <= 0:
continue
points.append({
"source": "FIRM",
"ref": so.get("orderNo"),
"productId": item.get("productId"),
"productCode": item.get("productCode"),
"productName": item.get("productName"),
"quantity": qty,
"weightedQty": float(qty),
"dueDate": due,
})
if include_forecast:
from server.aps_domain.forecast import ensure_forecast_table
ensure_forecast_table(world)
for fc in world.get("forecastOrders", []):
if fc.get("status") != "ACTIVE":
continue
due = str(fc.get("dueDate") or fc.get("periodEnd") or "")[:10]
if not due:
continue
qty = int(fc.get("quantity") or 0)
if qty <= 0:
continue
conf = float(fc.get("confidence") if fc.get("confidence") is not None else 1.0)
conf = max(0.0, min(1.0, conf))
points.append({
"source": "FORECAST",
"ref": fc.get("forecastNo"),
"productId": fc.get("productId"),
"productCode": fc.get("productCode"),
"productName": fc.get("productName"),
"quantity": qty,
"weightedQty": round(qty * conf, 1),
"dueDate": due,
"confidence": conf,
})
return points
def _status_for(ratio: float) -> str:
if ratio >= 1.0:
return "OVER"
if ratio >= WARN_RATIO:
return "WARN"
return "OK"
def build_plan_buckets(
world: World,
*,
mode: str = "HYBRID",
start_date: str | None = None,
horizon_days: int = 90,
include_forecast: bool = True,
capacity_mode: str = "FINITE",
) -> dict[str, Any]:
"""
PL-01/PL-02:时间分桶 × 需求 vs 粗能力。
capacity_mode=FINITE:能力=日产能×工作日,超载/预警。
capacity_mode=INFINITE:不卡能力,给出所需日产能;附有限参照负荷。
"""
mode = (mode or "HYBRID").upper()
cap_mode = (capacity_mode or "FINITE").upper()
if cap_mode not in CAPACITY_MODES:
raise ValueError("capacityMode 必须是 FINITE/INFINITE")
skeleton = build_bucket_skeleton(mode=mode, start_date=start_date, horizon_days=horizon_days)
day_cap = daily_capacity(world)
demands = _collect_demands(world, include_forecast=include_forecast)
buckets: list[dict[str, Any]] = []
for raw in skeleton:
firm = 0
forecast = 0
weighted = 0.0
firm_orders = 0
forecast_orders = 0
by_product: dict[str, dict[str, Any]] = {}
for p in demands:
if not _in_bucket(p["dueDate"], raw):
continue
code = str(p.get("productCode") or "?")
slot = by_product.setdefault(code, {
"productCode": code,
"productName": p.get("productName") or code,
"firmQty": 0,
"forecastQty": 0,
"weightedQty": 0.0,
})
if p["source"] == "FIRM":
firm += p["quantity"]
firm_orders += 1
slot["firmQty"] += p["quantity"]
else:
forecast += p["quantity"]
forecast_orders += 1
slot["forecastQty"] += p["quantity"]
weighted += p["weightedQty"]
slot["weightedQty"] = round(slot["weightedQty"] + p["weightedQty"], 1)
finite_cap = round(day_cap * raw["workdays"], 1)
demand_total = firm + forecast
load_qty = weighted
finite_ratio = (load_qty / finite_cap) if finite_cap > 0 else (1.0 if load_qty > 0 else 0.0)
finite_gap = round(load_qty - finite_cap, 1)
finite_status = _status_for(finite_ratio)
workdays = max(1, int(raw["workdays"] or 0)) if load_qty > 0 else int(raw["workdays"] or 0)
required_daily = round(load_qty / workdays, 1) if workdays > 0 else round(load_qty, 1)
if cap_mode == "INFINITE":
buckets.append({
**raw,
"capacityMode": cap_mode,
"capacityUnlimited": True,
"capacity": None,
"firmQty": firm,
"forecastQty": forecast,
"demandQty": demand_total,
"weightedDemand": round(weighted, 1),
"firmOrderCount": firm_orders,
"forecastOrderCount": forecast_orders,
"loadRatio": 0.0,
"gap": 0.0,
"status": "OK",
"requiredDaily": required_daily,
"finiteCapacity": finite_cap,
"finiteLoadRatio": round(finite_ratio, 3),
"finiteGap": finite_gap,
"finiteStatus": finite_status,
"byProduct": sorted(by_product.values(), key=lambda x: -(x["firmQty"] + x["forecastQty"])),
})
else:
buckets.append({
**raw,
"capacityMode": cap_mode,
"capacityUnlimited": False,
"capacity": finite_cap,
"firmQty": firm,
"forecastQty": forecast,
"demandQty": demand_total,
"weightedDemand": round(weighted, 1),
"firmOrderCount": firm_orders,
"forecastOrderCount": forecast_orders,
"loadRatio": round(finite_ratio, 3),
"gap": finite_gap,
"status": finite_status,
"requiredDaily": required_daily,
"finiteCapacity": finite_cap,
"finiteLoadRatio": round(finite_ratio, 3),
"finiteGap": finite_gap,
"finiteStatus": finite_status,
"byProduct": sorted(by_product.values(), key=lambda x: -(x["firmQty"] + x["forecastQty"])),
})
over = sum(1 for b in buckets if b["status"] == "OVER")
warn = sum(1 for b in buckets if b["status"] == "WARN")
finite_over = sum(1 for b in buckets if b.get("finiteStatus") == "OVER")
finite_warn = sum(1 for b in buckets if b.get("finiteStatus") == "WARN")
total_firm = sum(b["firmQty"] for b in buckets)
total_fc = sum(b["forecastQty"] for b in buckets)
total_w = sum(b["weightedDemand"] for b in buckets)
total_cap = sum((b["capacity"] or 0) for b in buckets)
peak_required = max((b.get("requiredDaily") or 0) for b in buckets) if buckets else 0
shortfall = [
{"key": b["key"], "label": b["label"], "finiteGap": b.get("finiteGap", 0),
"finiteLoadRatio": b.get("finiteLoadRatio", 0), "requiredDaily": b.get("requiredDaily", 0)}
for b in buckets if b.get("finiteStatus") == "OVER"
]
if cap_mode == "INFINITE":
hint = ("无限产能粗评估:不卡产线能力,展示所需日产能;"
"finite* 列为有限产能参照。负荷=确定需求+预测×置信度。")
else:
hint = ("有限产能粗评估:能力=ACTIVE产线日产能×工作日;"
"≥85%预警、≥100%超载。混合分桶:近7日/中约4周/远月。")
return {
"mode": mode,
"capacityMode": cap_mode,
"startDate": skeleton[0]["start"] if skeleton else fmt_date(today0()),
"horizonDays": horizon_days,
"includeForecast": include_forecast,
"dailyCapacity": day_cap,
"warnThreshold": WARN_RATIO,
"summary": {
"bucketCount": len(buckets),
"overCount": over,
"warnCount": warn,
"okCount": len(buckets) - over - warn,
"finiteOverCount": finite_over,
"finiteWarnCount": finite_warn,
"firmQty": total_firm,
"forecastQty": total_fc,
"weightedDemand": round(total_w, 1),
"capacity": None if cap_mode == "INFINITE" else round(total_cap, 1),
"overallLoad": 0.0 if cap_mode == "INFINITE" else (
round((total_w / total_cap), 3) if total_cap else 0.0
),
"peakRequiredDaily": peak_required,
"shortfallBucketCount": len(shortfall),
},
"shortfallBuckets": shortfall[:12],
"buckets": buckets,
"hint": hint,
}
def build_rccp_compare(
world: World,
*,
mode: str = "HYBRID",
start_date: str | None = None,
horizon_days: int = 90,
include_forecast: bool = True,
) -> dict[str, Any]:
"""PL-02:有限 vs 无限粗能力对照摘要。"""
finite = build_plan_buckets(
world, mode=mode, start_date=start_date, horizon_days=horizon_days,
include_forecast=include_forecast, capacity_mode="FINITE",
)
infinite = build_plan_buckets(
world, mode=mode, start_date=start_date, horizon_days=horizon_days,
include_forecast=include_forecast, capacity_mode="INFINITE",
)
fs, iis = finite["summary"], infinite["summary"]
return {
"mode": mode,
"horizonDays": horizon_days,
"includeForecast": include_forecast,
"dailyCapacity": finite["dailyCapacity"],
"finite": {
"overCount": fs["overCount"],
"warnCount": fs["warnCount"],
"overallLoad": fs["overallLoad"],
"capacity": fs["capacity"],
"weightedDemand": fs["weightedDemand"],
},
"infinite": {
"peakRequiredDaily": iis["peakRequiredDaily"],
"weightedDemand": iis["weightedDemand"],
"shortfallBucketCount": iis["shortfallBucketCount"],
},
"delta": {
"capacityGapDaily": round(
max(0.0, float(iis["peakRequiredDaily"] or 0) - float(finite["dailyCapacity"] or 0)), 1
),
"finiteOverCount": fs["overCount"],
"message": (
f"有限模式超载桶 {fs['overCount']};无限模式峰值所需日产能 "
f"{iis['peakRequiredDaily']}(当前日产能 {finite['dailyCapacity']})。"
),
},
"shortfallBuckets": infinite.get("shortfallBuckets") or [],
"hint": "RCCP 双模式:有限看缺口桶,无限看峰值所需能力。完整表见「分桶计划」视口切换。",
# 默认带回有限全表,便于视口直接渲染
"report": finite,
}
def _feasibility_status(*, due: str, start: str, slack: float, load_ratio: float) -> str:
if due < start:
return "LATE"
if slack < 0:
return "INFEASIBLE"
if load_ratio >= WARN_RATIO:
return "AT_RISK"
return "FEASIBLE"
def _portfolio_verdict(statuses: list[str]) -> str:
if any(s in ("INFEASIBLE", "LATE") for s in statuses):
return "INFEASIBLE"
if any(s == "AT_RISK" for s in statuses):
return "AT_RISK"
return "FEASIBLE"
def build_feasibility(
world: World,
*,
start_date: str | None = None,
horizon_days: int = 90,
include_forecast: bool = True,
) -> dict[str, Any]:
"""
PL-03:计划层交期可行性(前置于详细排产)。
规则:按交期排序,累计加权需求 vs 从计划起点到交期的累计粗能力(日产能×工作日)。
"""
start = _as_date(start_date) if start_date else today0()
start_s = fmt_date(start)
horizon_days = max(1, min(int(horizon_days or 90), 366))
day_cap = daily_capacity(world)
# 订单级行(同一 SO 合并明细)
rows_in: list[dict[str, Any]] = []
for so in world.get("salesOrders", []):
if so.get("status") not in SCHEDULABLE_STATUSES:
continue
due = str(so.get("deliveryDate") or "")[:10]
if not due:
continue
qty = 0
products: list[str] = []
for item in so.get("items") or []:
if item.get("status") in ("CANCELLED", "COMPLETED"):
continue
q = int(item.get("quantity") or 0)
if q <= 0:
continue
qty += q
code = str(item.get("productCode") or "")
if code and code not in products:
products.append(code)
if qty <= 0:
continue
rows_in.append({
"source": "FIRM",
"ref": so.get("orderNo"),
"customerName": so.get("customerName"),
"customerLevel": so.get("customerLevel"),
"priority": int(so.get("priority") or 9),
"productCodes": products,
"quantity": qty,
"weightedQty": float(qty),
"dueDate": due,
})
if include_forecast:
from server.aps_domain.forecast import ensure_forecast_table
ensure_forecast_table(world)
for fc in world.get("forecastOrders", []):
if fc.get("status") != "ACTIVE":
continue
due = str(fc.get("dueDate") or fc.get("periodEnd") or "")[:10]
if not due:
continue
qty = int(fc.get("quantity") or 0)
if qty <= 0:
continue
conf = float(fc.get("confidence") if fc.get("confidence") is not None else 1.0)
conf = max(0.0, min(1.0, conf))
rows_in.append({
"source": "FORECAST",
"ref": fc.get("forecastNo"),
"customerName": fc.get("customerName") or "预测需求",
"customerLevel": fc.get("customerLevel") or "C",
"priority": 9,
"productCodes": [str(fc.get("productCode") or "")],
"quantity": qty,
"weightedQty": round(qty * conf, 1),
"dueDate": due,
"confidence": conf,
})
rows_in.sort(key=lambda r: (r["dueDate"], r["priority"], str(r["ref"] or "")))
# 有限分桶:用于缺口定位
rccp = build_plan_buckets(
world, mode="HYBRID", start_date=start_s, horizon_days=horizon_days,
include_forecast=include_forecast, capacity_mode="FINITE",
)
over_buckets = [b for b in rccp["buckets"] if b.get("status") == "OVER"]
cum_demand = 0.0
out_rows: list[dict[str, Any]] = []
for r in rows_in:
due = r["dueDate"]
due_dt = _as_date(due)
# 交期早于起点:无可用工作日
if due_dt < start:
workdays = 0
cum_cap = 0.0
else:
workdays = _workdays_inclusive(start, due_dt)
cum_cap = round(day_cap * workdays, 1)
cum_demand = round(cum_demand + float(r["weightedQty"]), 1)
slack = round(cum_cap - cum_demand, 1)
load_ratio = (cum_demand / cum_cap) if cum_cap > 0 else (1.0 if cum_demand > 0 else 0.0)
status = _feasibility_status(due=due, start=start_s, slack=slack, load_ratio=load_ratio)
reasons: list[str] = []
if status == "LATE":
reasons.append("交期早于计划起点")
elif status == "INFEASIBLE":
reasons.append(f"至交期累计能力不足 {abs(slack)} 件")
elif status == "AT_RISK":
reasons.append(f"至交期累计负荷 {round(load_ratio * 100)}%(≥{int(WARN_RATIO * 100)}% 预警)")
hit_overs = [
b["label"] for b in over_buckets
if b["start"] <= due and b["end"] >= start_s
][:4]
if hit_overs and status in ("INFEASIBLE", "AT_RISK"):
reasons.append("重叠超载桶:" + "、".join(hit_overs))
out_rows.append({
"source": r["source"],
"ref": r["ref"],
"customerName": r.get("customerName"),
"customerLevel": r.get("customerLevel"),
"priority": r["priority"],
"productCodes": r["productCodes"],
"quantity": r["quantity"],
"weightedQty": r["weightedQty"],
"dueDate": due,
"workdaysToDue": workdays,
"cumulativeCapacity": cum_cap,
"cumulativeDemand": cum_demand,
"slack": slack,
"loadRatio": round(load_ratio, 3),
"status": status,
"reasons": reasons,
})
statuses = [r["status"] for r in out_rows]
verdict = _portfolio_verdict(statuses) if statuses else "FEASIBLE"
counts = {
"all": len(out_rows),
"feasible": sum(1 for s in statuses if s == "FEASIBLE"),
"atRisk": sum(1 for s in statuses if s == "AT_RISK"),
"infeasible": sum(1 for s in statuses if s == "INFEASIBLE"),
"late": sum(1 for s in statuses if s == "LATE"),
}
return {
"startDate": start_s,
"horizonDays": horizon_days,
"includeForecast": include_forecast,
"dailyCapacity": day_cap,
"warnThreshold": WARN_RATIO,
"verdict": verdict,
"summary": {
**counts,
"shortfallBucketCount": len(over_buckets),
"overallLoad": rccp["summary"].get("overallLoad"),
},
"gaps": [
{
"key": b["key"], "label": b["label"], "start": b["start"], "end": b["end"],
"gap": b.get("gap"), "loadRatio": b.get("loadRatio"),
"firmQty": b.get("firmQty"), "forecastQty": b.get("forecastQty"),
}
for b in over_buckets[:12]
],
"orders": out_rows,
"hint": (
"可行性=按交期累计需求 vs 起点→交期累计粗能力(前置于排产);"
"不等同详细排程准时率。缺口桶来自有限产能分桶。"
),
}
def _peak_and_variance(loads: list[float], caps: list[float]) -> tuple[float, float, float]:
"""返回 (peakLoadRatio, avgLoadRatio, loadVariance)。"""
ratios = []
for load, cap in zip(loads, caps):
if cap > 0:
ratios.append(load / cap)
elif load > 0:
ratios.append(1.0)
else:
ratios.append(0.0)
if not ratios:
return 0.0, 0.0, 0.0
peak = max(ratios)
avg = sum(ratios) / len(ratios)
var = sum((r - avg) ** 2 for r in ratios) / len(ratios)
return round(peak, 3), round(avg, 3), round(var, 4)
def build_leveling(
world: World,
*,
mode: str = "WEEK",
start_date: str | None = None,
horizon_days: int = 90,
include_forecast: bool = True,
target_load: float = 0.85,
) -> dict[str, Any]:
"""
PL-05:计划层产能平衡/削峰建议(只读)。
从超载桶把多余需求挪到前/后有空档的桶(先提前、再延后),不改订单交期。
"""
mode = (mode or "WEEK").upper()
if mode not in BUCKET_MODES:
raise ValueError("mode 必须是 DAY/WEEK/MONTH/HYBRID")
target = float(target_load if target_load is not None else WARN_RATIO)
target = max(0.5, min(1.0, target))
base = build_plan_buckets(
world, mode=mode, start_date=start_date, horizon_days=horizon_days,
include_forecast=include_forecast, capacity_mode="FINITE",
)
buckets = base["buckets"]
loads = [float(b.get("weightedDemand") or 0) for b in buckets]
caps = [float(b.get("capacity") or 0) for b in buckets]
peak_b, avg_b, var_b = _peak_and_variance(loads, caps)
moves: list[dict[str, Any]] = []
working = list(loads)
for i, b in enumerate(buckets):
surplus = working[i] - caps[i]
if surplus <= 0.5:
continue
products = list(b.get("byProduct") or [])
prod_idx = 0
def _next_product() -> dict[str, Any]:
nonlocal prod_idx
if not products:
return {"productCode": "?", "productName": "混合需求"}
p = products[prod_idx % len(products)]
prod_idx += 1
return p
# ① 向前提到有空档的桶(PULL_AHEAD)
for j in range(i - 1, -1, -1):
if surplus <= 0.5:
break
room = caps[j] * target - working[j]
if room <= 0.5:
continue
qty = min(surplus, room)
p = _next_product()
moves.append({
"fromKey": b["key"], "toKey": buckets[j]["key"],
"fromLabel": b["label"], "toLabel": buckets[j]["label"],
"fromStart": b["start"], "toStart": buckets[j]["start"],
"quantity": round(qty, 1),
"direction": "PULL_AHEAD",
"productCode": p.get("productCode"),
"productName": p.get("productName"),
"reason": f"超载桶 {b['label']} → 提前到空档 {buckets[j]['label']}",
})
working[i] = round(working[i] - qty, 1)
working[j] = round(working[j] + qty, 1)
surplus = working[i] - caps[i]
# ② 向后推到空档(PUSH_BACK)
for j in range(i + 1, len(buckets)):
if surplus <= 0.5:
break
room = caps[j] * target - working[j]
if room <= 0.5:
continue
qty = min(surplus, room)
p = _next_product()
moves.append({
"fromKey": b["key"], "toKey": buckets[j]["key"],
"fromLabel": b["label"], "toLabel": buckets[j]["label"],
"fromStart": b["start"], "toStart": buckets[j]["start"],
"quantity": round(qty, 1),
"direction": "PUSH_BACK",
"productCode": p.get("productCode"),
"productName": p.get("productName"),
"reason": f"超载桶 {b['label']} → 延后到空档 {buckets[j]['label']}(可能影响交期)",
})
working[i] = round(working[i] - qty, 1)
working[j] = round(working[j] + qty, 1)
surplus = working[i] - caps[i]
peak_a, avg_a, var_a = _peak_and_variance(working, caps)
after_buckets = []
for b, load in zip(buckets, working):
cap = float(b.get("capacity") or 0)
ratio = (load / cap) if cap > 0 else (1.0 if load > 0 else 0.0)
after_buckets.append({
"key": b["key"], "label": b["label"], "kind": b["kind"],
"start": b["start"], "end": b["end"],
"capacity": cap,
"demandBefore": b.get("weightedDemand"),
"demandAfter": round(load, 1),
"loadBefore": b.get("loadRatio"),
"loadAfter": round(ratio, 3),
"statusBefore": b.get("status"),
"statusAfter": _status_for(ratio),
"delta": round(load - float(b.get("weightedDemand") or 0), 1),
})
over_before = sum(1 for b in buckets if b.get("status") == "OVER")
over_after = sum(1 for b in after_buckets if b["statusAfter"] == "OVER")
return {
"mode": mode,
"horizonDays": horizon_days,
"includeForecast": include_forecast,
"targetLoad": target,
"dailyCapacity": base["dailyCapacity"],
"before": {
"peakLoad": peak_b, "avgLoad": avg_b, "variance": var_b, "overCount": over_before,
},
"after": {
"peakLoad": peak_a, "avgLoad": avg_a, "variance": var_a, "overCount": over_after,
},
"improvement": {
"peakDelta": round(peak_b - peak_a, 3),
"varianceDelta": round(var_b - var_a, 4),
"overCountDelta": over_before - over_after,
"moveCount": len(moves),
"movedQty": round(sum(m["quantity"] for m in moves), 1),
},
"moves": moves[:40],
"buckets": after_buckets,
"hint": (
f"削峰目标负荷≤{int(target * 100)}%:优先提前到空档,其次延后;"
"本切片只给建议,不改订单交期。确认挪单请改交期或走产能均衡试排。"
),
}
# 产供建议相对成本指数(越小越优先;首切片启发式,非财务报价)
_SUPPLY_COST = {"LEVELING": 1.0, "OVERTIME": 1.35, "EXPAND": 2.1, "OUTSOURCE": 1.75}
_OVERTIME_RATIO = 0.25 # 加班最多再挖 25% 桶能力
_EXPAND_RATIO = 0.40 # 扩线/提效假设可再加 40% 桶能力
def _inactive_or_alt_lines(world: World) -> list[dict[str, Any]]:
"""可扩容线索:停用产线,或 ACTIVE 产线声明的替代产线。"""
lines = {ln["id"]: ln for ln in world.get("lines", [])}
hints: list[dict[str, Any]] = []
for ln in lines.values():
if ln.get("status") == "INACTIVE":
hints.append({
"code": ln.get("code"), "name": ln.get("name"),
"kind": "INACTIVE_LINE",
"capacityPerDay": float(ln.get("capacityPerDay") or 0),
})
for aid in ln.get("alternativeLineIds") or []:
alt = lines.get(aid)
if not alt:
continue
hints.append({
"code": alt.get("code"), "name": alt.get("name"),
"kind": "ALT_LINE",
"capacityPerDay": float(alt.get("capacityPerDay") or 0),
"forLine": ln.get("code"),
})
# 去重 by code
seen: set[str] = set()
uniq = []
for h in hints:
c = str(h.get("code") or "")
if c and c not in seen:
seen.add(c)
uniq.append(h)
return uniq
def build_supply_decisions(
world: World,
*,
mode: str = "WEEK",
start_date: str | None = None,
horizon_days: int = 90,
include_forecast: bool = True,
target_load: float = 0.85,
) -> dict[str, Any]:
"""
PL-06:产供方向决策(加班 / 扩线 / 外协)结构化建议,只读。
先跑削峰;对削峰后仍超载的桶,按 加班→扩线→外协 覆盖缺口并打相对成本分。
"""
leveling = build_leveling(
world, mode=mode, start_date=start_date, horizon_days=horizon_days,
include_forecast=include_forecast, target_load=target_load,
)
day_cap = float(leveling.get("dailyCapacity") or daily_capacity(world))
alt_hints = _inactive_or_alt_lines(world)
expand_pool = sum(h.get("capacityPerDay") or 0 for h in alt_hints)
gaps: list[dict[str, Any]] = []
options: list[dict[str, Any]] = []
total_residual = 0.0
for b in leveling["buckets"]:
if b.get("statusAfter") != "OVER":
continue
cap = float(b.get("capacity") or 0)
dem = float(b.get("demandAfter") or 0)
gap = round(dem - cap, 1)
if gap <= 0.5:
continue
total_residual = round(total_residual + gap, 1)
workdays = max(1, int((_as_date(b["end"]) - _as_date(b["start"])).days) + 1)
# 粗估工作日:用能力反推
if day_cap > 0 and cap > 0:
workdays = max(1, int(round(cap / day_cap)))
remaining = gap
bucket_opts: list[dict[str, Any]] = []
# ① 加班
ot_room = round(cap * _OVERTIME_RATIO, 1)
ot_cover = round(min(remaining, ot_room), 1)
if ot_cover >= 1:
bucket_opts.append({
"kind": "OVERTIME",
"label": "加班",
"bucketKey": b["key"], "bucketLabel": b["label"],
"coverQty": ot_cover,
"extraCapacity": ot_cover,
"costIndex": _SUPPLY_COST["OVERTIME"],
"leadDays": 0,
"detail": f"桶内加班约 +{int(_OVERTIME_RATIO * 100)}% 能力,覆盖 {ot_cover} 件",
})
remaining = round(remaining - ot_cover, 1)
# ② 扩线 / 启用替代产线
expand_room = round(cap * _EXPAND_RATIO + expand_pool * workdays * 0.15, 1)
expand_cover = round(min(remaining, expand_room), 1) if remaining >= 1 else 0.0
if expand_cover >= 1:
alt_txt = "、".join(f"{h['code']}" for h in alt_hints[:3]) or "提效/扩班"
bucket_opts.append({
"kind": "EXPAND",
"label": "扩线/启用替代",
"bucketKey": b["key"], "bucketLabel": b["label"],
"coverQty": expand_cover,
"extraCapacity": expand_cover,
"costIndex": _SUPPLY_COST["EXPAND"],
"leadDays": 14,
"detail": f"启用/加开 {alt_txt},覆盖 {expand_cover} 件(含约 2 周准备)",
"altLines": alt_hints[:5],
})
remaining = round(remaining - expand_cover, 1)
# ③ 外协吃掉剩余
if remaining >= 1:
bucket_opts.append({
"kind": "OUTSOURCE",
"label": "外协",
"bucketKey": b["key"], "bucketLabel": b["label"],
"coverQty": remaining,
"extraCapacity": remaining,
"costIndex": _SUPPLY_COST["OUTSOURCE"],
"leadDays": 7,
"detail": f"外协 {remaining} 件,约 7 天交期缓冲",
})
remaining = 0.0
gaps.append({
"key": b["key"], "label": b["label"], "start": b["start"], "end": b["end"],
"capacity": cap, "demandAfterLeveling": dem, "gap": gap,
"options": bucket_opts,
})
options.extend(bucket_opts)
# 组合推荐:按成本指数排序取覆盖路径摘要
ranked = sorted(options, key=lambda o: (o["costIndex"], -o["coverQty"]))
mix = {"OVERTIME": 0.0, "EXPAND": 0.0, "OUTSOURCE": 0.0, "LEVELING": float(leveling["improvement"].get("movedQty") or 0)}
for o in options:
mix[o["kind"]] = round(mix.get(o["kind"], 0) + o["coverQty"], 1)
if total_residual <= 0.5 and leveling["improvement"]["moveCount"] == 0:
verdict = "BALANCED"
message = "削峰后无残留超载,无需加班/扩线/外协。"
elif total_residual <= 0.5:
verdict = "LEVELING_ENOUGH"
message = f"削峰挪动 {mix['LEVELING']} 件后已消除超载;优先执行削峰建议。"
else:
verdict = "NEED_SUPPLY"
message = (
f"削峰后仍缺口约 {total_residual} 件:建议加班 {mix['OVERTIME']} / "
f"扩线 {mix['EXPAND']} / 外协 {mix['OUTSOURCE']}。"
)
return {
"mode": mode,
"horizonDays": horizon_days,
"includeForecast": include_forecast,
"targetLoad": leveling["targetLoad"],
"dailyCapacity": day_cap,
"verdict": verdict,
"message": message,
"leveling": {
"moveCount": leveling["improvement"]["moveCount"],
"movedQty": leveling["improvement"]["movedQty"],
"overBefore": leveling["before"]["overCount"],
"overAfter": leveling["after"]["overCount"],
"peakBefore": leveling["before"]["peakLoad"],
"peakAfter": leveling["after"]["peakLoad"],
},
"summary": {
"residualGap": total_residual,
"gapBucketCount": len(gaps),
"optionCount": len(options),
"mix": mix,
"altLineCount": len(alt_hints),
},
"gaps": gaps,
"options": ranked[:30],
"altLines": alt_hints,
"hint": (
"产供决策:先削峰,残留缺口按 加班→扩线→外协 覆盖;"
"costIndex 为相对成本启发式,非财务报价。本切片只读建议。"
),
}