aps-agent/tests/golden/test_planning.py

144 lines
6.3 KiB
Python

# ============================================================
# PL-01 时间分桶计划黄金测试
# ============================================================
from __future__ import annotations
from server.aps_domain.planning import build_bucket_skeleton, build_plan_buckets, daily_capacity
from server.state.seed import seed_world
from server.timeutil import add_minutes, fmt_date, today0
def test_hybrid_skeleton_has_day_week_month():
start = fmt_date(today0())
buckets = build_bucket_skeleton(mode="HYBRID", start_date=start, horizon_days=90)
kinds = {b["kind"] for b in buckets}
assert kinds == {"DAY", "WEEK", "MONTH"}
assert sum(1 for b in buckets if b["kind"] == "DAY") == 7
def test_plan_buckets_seed_summary():
world = seed_world()
report = build_plan_buckets(world, mode="WEEK", horizon_days=30, include_forecast=True)
assert report["mode"] == "WEEK"
assert report["dailyCapacity"] == daily_capacity(world)
assert report["summary"]["firmQty"] > 0
assert report["summary"]["forecastQty"] > 0 # ACTIVE forecasts in horizon
assert report["summary"]["bucketCount"] == len(report["buckets"])
assert all("loadRatio" in b and "status" in b for b in report["buckets"])
def test_plan_buckets_exclude_forecast():
world = seed_world()
with_fc = build_plan_buckets(world, mode="MONTH", horizon_days=90, include_forecast=True)
no_fc = build_plan_buckets(world, mode="MONTH", horizon_days=90, include_forecast=False)
assert with_fc["summary"]["forecastQty"] >= no_fc["summary"]["forecastQty"]
assert no_fc["summary"]["forecastQty"] == 0
assert no_fc["summary"]["firmQty"] == with_fc["summary"]["firmQty"]
def test_day_mode_bucket_count():
start = fmt_date(add_minutes(today0(), 0))
report = build_plan_buckets(seed_world(), mode="DAY", start_date=start, horizon_days=14,
include_forecast=False)
assert len(report["buckets"]) == 14
assert all(b["kind"] == "DAY" for b in report["buckets"])
def test_infinite_capacity_no_over_status():
world = seed_world()
finite = build_plan_buckets(world, mode="WEEK", horizon_days=30, capacity_mode="FINITE")
infinite = build_plan_buckets(world, mode="WEEK", horizon_days=30, capacity_mode="INFINITE")
assert infinite["capacityMode"] == "INFINITE"
assert infinite["summary"]["overCount"] == 0
assert all(b["capacity"] is None and b["capacityUnlimited"] for b in infinite["buckets"])
assert infinite["summary"]["peakRequiredDaily"] >= 0
# 有限参照应保留缺口信息
assert infinite["summary"]["finiteOverCount"] == finite["summary"]["overCount"]
def test_rccp_compare_delta():
from server.aps_domain.planning import build_rccp_compare
cmp_ = build_rccp_compare(seed_world(), mode="HYBRID", horizon_days=90)
assert "finite" in cmp_ and "infinite" in cmp_ and "delta" in cmp_
assert cmp_["finite"]["weightedDemand"] == cmp_["infinite"]["weightedDemand"]
assert cmp_["delta"]["capacityGapDaily"] >= 0
assert cmp_["report"]["capacityMode"] == "FINITE"
def test_feasibility_portfolio_and_orders():
from server.aps_domain.planning import build_feasibility
world = seed_world()
report = build_feasibility(world, horizon_days=90, include_forecast=True)
assert report["verdict"] in ("FEASIBLE", "AT_RISK", "INFEASIBLE")
assert report["summary"]["all"] == len(report["orders"])
assert report["summary"]["all"] >= 7 # firm SOs
# 累计需求单调不减
prev = 0.0
for row in report["orders"]:
assert row["cumulativeDemand"] >= prev
prev = row["cumulativeDemand"]
assert row["status"] in ("FEASIBLE", "AT_RISK", "INFEASIBLE", "LATE")
def test_feasibility_exclude_forecast_fewer_rows():
from server.aps_domain.planning import build_feasibility
world = seed_world()
with_fc = build_feasibility(world, include_forecast=True)
no_fc = build_feasibility(world, include_forecast=False)
assert no_fc["summary"]["all"] < with_fc["summary"]["all"]
assert all(o["source"] == "FIRM" for o in no_fc["orders"])
def test_leveling_reduces_peak_or_moves():
from server.aps_domain.planning import build_leveling, build_plan_buckets
world = seed_world()
# 压低日产能制造超载,便于验证削峰
for line in world["lines"]:
line["capacityPerDay"] = 50
base = build_plan_buckets(world, mode="WEEK", horizon_days=60, capacity_mode="FINITE")
assert base["summary"]["overCount"] >= 1
report = build_leveling(world, mode="WEEK", horizon_days=60, target_load=0.85)
assert report["before"]["overCount"] >= 1
assert report["improvement"]["moveCount"] >= 1
assert report["after"]["peakLoad"] <= report["before"]["peakLoad"] + 1e-6
assert len(report["buckets"]) == len(base["buckets"])
def test_leveling_no_overload_empty_moves():
from server.aps_domain.planning import build_leveling
world = seed_world()
# 放大产能 → 无超载
for line in world["lines"]:
line["capacityPerDay"] = 100000
report = build_leveling(world, mode="WEEK", horizon_days=30)
assert report["before"]["overCount"] == 0
assert report["moves"] == []
assert report["improvement"]["movedQty"] == 0
def test_supply_need_when_capacity_crushed():
from server.aps_domain.planning import build_supply_decisions
world = seed_world()
# 极低压产能:削峰也吃不完 → NEED_SUPPLY
for line in world["lines"]:
line["capacityPerDay"] = 20
report = build_supply_decisions(world, mode="WEEK", horizon_days=60, target_load=0.85)
assert report["verdict"] == "NEED_SUPPLY"
assert report["summary"]["residualGap"] > 0
assert report["summary"]["optionCount"] >= 1
kinds = {o["kind"] for o in report["options"]}
assert "OVERTIME" in kinds or "OUTSOURCE" in kinds or "EXPAND" in kinds
assert report["options"][0]["costIndex"] <= max(o["costIndex"] for o in report["options"])
def test_supply_balanced_when_capacity_ample():
from server.aps_domain.planning import build_supply_decisions
world = seed_world()
for line in world["lines"]:
line["capacityPerDay"] = 100000
report = build_supply_decisions(world, mode="WEEK", horizon_days=30)
assert report["verdict"] == "BALANCED"
assert report["summary"]["residualGap"] == 0
assert report["options"] == []
assert report["gaps"] == []