aps-agent/server/aps_domain/planning.py

976 lines
36 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.

# ============================================================
# 计划层 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 为相对成本启发式,非财务报价。本切片只读建议。"
),
}