"""PR2 (finance closed-loop) — budget lifecycle: plan vs actual vs forecast.""" from __future__ import annotations import uuid from decimal import Decimal import pytest import pytest_asyncio from app.domains.finance import budget_service from app.domains.finance.models import FinanceBudget, FinanceSpendFact FY = 2026 def _fact(period: str, usd: str, *, cost_type="cloud", dept="Eng", cc="C0906", basis="consumption"): return FinanceSpendFact( fact_key=f"f-{period}-{cost_type}-{cc}-{basis}-{usd}", period=period, cost_type=cost_type, basis=basis, sub_category="x", vendor="aws", department=dept, cost_center=cc, allocation_source="scope_owner", amount_native=Decimal(usd), currency_native="USD", amount_usd=Decimal(usd), fx_basis="native_usd", is_provisional=True, source_domain="cmdb", rollup_fingerprint="x", ) @pytest_asyncio.fixture async def seeded(db_session): db = db_session # $1200 annual budget for Eng/C0906. 5 months of $200 = $1000 YTD by May # → over the $500 YTD-prorated budget, forecast $2400 ≫ $1200 → "over". await budget_service.create_budget( db, fiscal_year=FY, amount_usd=Decimal("1200"), cost_type="cloud", department="Eng", cost_center="C0906", ) for mth in range(1, 6): db.add(_fact(f"{FY}-{mth:02d}", "200")) # A second budget with plenty of headroom → "ok". await budget_service.create_budget( db, fiscal_year=FY, amount_usd=Decimal("100000"), department="Sales", ) db.add(_fact(f"{FY}-05", "300", dept="Sales", cc="C0200", cost_type="software")) await db.flush() return db @pytest.mark.asyncio async def test_crud_roundtrip(db_session): db = db_session b = await budget_service.create_budget(db, fiscal_year=FY, amount_usd=Decimal("500"), department="Eng") assert b.id is not None rows = await budget_service.list_budgets(db, FY) assert len(rows) == 1 await budget_service.update_budget(db, b.id, amount_usd=Decimal("800"), note="bumped") refreshed = await db.get(FinanceBudget, b.id) assert refreshed.amount_usd == Decimal("800.00") assert refreshed.note == "bumped" await budget_service.delete_budget(db, b.id) assert await budget_service.list_budgets(db, FY) == [] @pytest.mark.asyncio async def test_budget_vs_actual_over_and_ok(seeded): db = seeded rep = await budget_service.budget_vs_actual(db, FY, f"{FY}-05", start_month=1) assert rep["months_elapsed"] == 5 assert rep["budget_count"] == 2 by_dept = {i["department"]: i for i in rep["items"]} eng = by_dept["Eng"] assert eng["ytd_actual_usd"] == "1000.00" assert eng["annual_budget_usd"] == "1200.00" # YTD-prorated budget = 1200 * 5/12 = 500 → variance 500-1000 = -500. assert eng["variance_ytd_usd"] == "-500.00" # Run-rate forecast = 1000/5*12 = 2400 → projected 1200-2400 = -1200. assert eng["forecast_year_end_usd"] == "2400.00" assert eng["status"] == "over" sales = by_dept["Sales"] assert sales["status"] == "ok" assert rep["over_count"] == 1 @pytest.mark.asyncio async def test_dimension_matching_null_is_wildcard(db_session): db = db_session # Budget with NO dims = company-wide; should catch facts of any shape. await budget_service.create_budget(db, fiscal_year=FY, amount_usd=Decimal("10000")) db.add(_fact(f"{FY}-03", "100", dept="Eng")) db.add(_fact(f"{FY}-03", "250", dept="Sales", cc="C0200", cost_type="ai")) await db.flush() rep = await budget_service.budget_vs_actual(db, FY, f"{FY}-03", start_month=1) assert rep["items"][0]["ytd_actual_usd"] == "350.00" @pytest.mark.asyncio async def test_alert_fires_once_to_owner(seeded, monkeypatch): db = seeded owner_id = uuid.uuid4() # Make the over-budget Eng pool owned by an active person. from app.identity.person_models import IdentityPerson db.add(IdentityPerson( id=owner_id, work_email="owner@test.com", display_name="Owner", employment_status="active", source_system="successfactors", hris_person_id="sf-owner", )) eng_budget = next(b for b in await budget_service.list_budgets(db, FY) if b.department == "Eng") eng_budget.owner_person_id = str(owner_id) await db.flush() sent = [] async def _fake_enqueue(self, env, **kw): sent.append(env) return None monkeypatch.setattr( "app.notifications.service.NotificationService.enqueue", _fake_enqueue, ) r1 = await budget_service.evaluate_and_alert(db, fiscal_year=FY, as_of_period=f"{FY}-05", start_month=1) assert r1["alerts_enqueued"] == 1 assert len(sent) == 1 # Dedup: second run does not re-fire (threshold already marked). r2 = await budget_service.evaluate_and_alert(db, fiscal_year=FY, as_of_period=f"{FY}-05", start_month=1) assert r2["alerts_enqueued"] == 0 @pytest.mark.asyncio async def test_alert_fires_on_prorated_pace_not_annual_burn(db_session, monkeypatch): """Regression: the alert must trigger on the YTD-PRORATED pace (ytd_actual / ytd_prorated_budget), not the raw annual burn (ytd_actual / annual). A budget pacing at 120% of plan in March has only ~30% annual burn — the old ``burn_pct`` trigger would stay silent until ~80% of the WHOLE year was consumed (well past the point the owner can correct course), yet the dashboard already flags it ``warning``. This wires the alert to the same prorated signal the docstring + dashboard promise. """ db = db_session from app.identity.person_models import IdentityPerson owner_id = uuid.uuid4() db.add(IdentityPerson( id=owner_id, work_email="pacer@test.com", display_name="Pacer", employment_status="active", source_system="successfactors", hris_person_id="sf-pacer", )) # $1200 annual; 3 months elapsed → prorated budget = 1200*3/12 = $300. b = await budget_service.create_budget( db, fiscal_year=FY, amount_usd=Decimal("1200"), cost_type="cloud", department="Eng", cost_center="C0906", ) b.owner_person_id = str(owner_id) # YTD actual $360 → annual burn 30% (< 80, old logic silent), # prorated pace 360/300 = 120% (>= 100, new logic alerts). for mth in range(1, 4): db.add(_fact(f"{FY}-{mth:02d}", "120")) await db.flush() # Sanity: the report itself confirms the burn/pace split the fix hinges on. rep = await budget_service.budget_vs_actual(db, FY, f"{FY}-03", start_month=1) item = rep["items"][0] assert item["burn_pct"] == 30.0 assert item["pace_pct"] == 120.0 sent = [] async def _fake_enqueue(self, env, **kw): sent.append(env) return None monkeypatch.setattr( "app.notifications.service.NotificationService.enqueue", _fake_enqueue, ) r = await budget_service.evaluate_and_alert(db, fiscal_year=FY, as_of_period=f"{FY}-03", start_month=1) assert r["alerts_enqueued"] == 1, "prorated 120% pace must alert despite 30% annual burn" # Crossed the 100% prorated threshold → "已超预算" severity. assert "已超预算" in sent[0].title refreshed = await db.get(FinanceBudget, b.id) assert refreshed.last_alerted_threshold == 100 @pytest.mark.asyncio async def test_budget_actuals_exclude_procurement_basis(db_session): """Regression: a finance budget is a CONSUMPTION plan; it must NOT sum in procurement-basis facts. The ledger's hard invariant is that the two bases describe the same money twice — a NULL-cost_type (all types) budget for a department that also has PO/procurement spend would otherwise double-count and fire false over-budget alerts.""" db = db_session await budget_service.create_budget( db, fiscal_year=FY, amount_usd=Decimal("10000"), department="Eng", ) db.add(_fact(f"{FY}-03", "100", dept="Eng", cost_type="cloud")) db.add(_fact(f"{FY}-03", "400", dept="Eng", cost_type="ai")) # Procurement-basis PO spend for the SAME department — must be ignored. db.add(_fact(f"{FY}-03", "5000", dept="Eng", cost_type="procurement", basis="procurement")) await db.flush() rep = await budget_service.budget_vs_actual(db, FY, f"{FY}-03", start_month=1) # Only the $500 of consumption counts, not the $5000 PO. assert rep["items"][0]["ytd_actual_usd"] == "500.00" @pytest.mark.asyncio async def test_budget_scope_conflict_is_409(db_session): """Creating / re-scoping a budget onto an existing scope tuple returns a clean 409, not a raw IntegrityError → 500 that also poisons the session.""" from app.core.exceptions import AppException db = db_session await budget_service.create_budget( db, fiscal_year=FY, amount_usd=Decimal("100"), cost_type="cloud", department="Eng", cost_center="C0906", ) with pytest.raises(AppException) as ei: await budget_service.create_budget( db, fiscal_year=FY, amount_usd=Decimal("200"), cost_type="cloud", department="Eng", cost_center="C0906", ) assert ei.value.status_code == 409 # Re-scoping a second budget ONTO the first's scope is also rejected. other = await budget_service.create_budget( db, fiscal_year=FY, amount_usd=Decimal("300"), department="Sales", ) with pytest.raises(AppException) as ei2: await budget_service.update_budget( db, other.id, cost_type="cloud", department="Eng", cost_center="C0906", ) assert ei2.value.status_code == 409 def test_default_as_of_period_does_not_over_elapse_current_year(): """A past FY defaults to its last month (fully elapsed); the current/future FY must NOT default to the last month — that collapses the run-rate forecast and pace. Validated for the calendar (Jan-start) config.""" from datetime import UTC, datetime from app.core import fiscal_year as fy now = datetime.now(UTC) cur = fy.default_as_of_period(now.year, 1) assert cur == f"{now.year:04d}-{now.month:02d}" assert fy.default_as_of_period(2000, 1) == "2000-12" # Future year clamps to the current month (→ months_elapsed 0). assert fy.default_as_of_period(now.year + 5, 1) == ( f"{now.year:04d}-{now.month:02d}" )