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