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