"""Employee "my money" + BU-owner self-service scoping. my_attributed_cost consolidates a person's own cost (unified ledger sliced to owner=me + their expenses) + a 12-month trend; my_bu_costs scopes the per-BU view to just the caller's BU. """ from __future__ import annotations from decimal import Decimal import pytest from app.domains.finance import read_service from app.domains.finance.models import FinanceExternalStatement, FinanceSpendFact PERIOD = "2026-05" ME = "aaaaaaaa-0000-4000-8000-000000000001" OTHER = "bbbbbbbb-0000-4000-8000-000000000002" def _fact(owner, cost_type, usd, *, basis="consumption", period=PERIOD): return FinanceSpendFact( fact_key=f"k|{owner}|{cost_type}|{usd}|{period}", period=period, cost_type=cost_type, basis=basis, amount_native=Decimal(str(usd)), currency_native="USD", amount_usd=Decimal(str(usd)), fx_basis="native_usd", allocation_source="derived", owner_person_id=owner, source_domain=cost_type, rollup_fingerprint="t", is_provisional=False, ) def _expense(owner, usd, *, period=PERIOD, promoted=True): return FinanceExternalStatement( statement_key=f"e|{owner}|{usd}|{period}", source="huilianyi", external_doc_no=f"BX-{usd}", line_no=0, period=period, expense_category="差旅费", owner_person_id=owner, amount_native=Decimal(str(usd)), currency="USD", amount_usd=Decimal(str(usd)), payment_status="PAID", promote_to_ledger=promoted, ) @pytest.mark.asyncio async def test_my_attributed_cost_consolidates(db_session): """Expenses are read from the LEDGER (a promoted 汇联易 reimbursement is materialised as a ``cost_type='expense'`` spend_fact by the rollup), NOT from finance_external_statements directly. The reconciliation external row co-exists but must NOT be re-added — that would double-count every promoted reimbursement (regression guard for the my-money double-count bug).""" db = db_session db.add(_fact(ME, "cloud", 300)) db.add(_fact(ME, "software", 120)) db.add(_fact(ME, "expense", 80)) # promoted → in ledger db.add(_fact(ME, "cloud", 999, basis="procurement")) # wrong basis → excluded db.add(_fact(OTHER, "cloud", 5000)) # other person → excluded db.add(_expense(ME, 80, promoted=True)) # same money, recon row — must NOT re-add await db.flush() res = await read_service.my_attributed_cost(db, ME, PERIOD) by = {r["cost_type"]: r["amount_usd"] for r in res["by_cost_type"]} assert by["cloud"] == "300.00" assert by["software"] == "120.00" assert by["expense"] == "80.00" # counted ONCE (ledger), not 160 assert res["expense_usd"] == "80.00" assert res["total_usd"] == "500.00" # 300 + 120 + 80, NOT 580 # 12-month trend, newest bucket == PERIOD with the in-period ledger total. assert len(res["trend"]) == 12 assert res["trend"][-1] == {"period": PERIOD, "usd": "500.00"} @pytest.mark.asyncio async def test_my_bu_costs_scopes_to_owner_bu(db_session, monkeypatch): async def _resolver(_db): return ({ME: "研发"}, {}) async def _full(_db, _fy, **_kw): return { "fiscal_year": 2026, "fy_label": "FY2026", "as_of_period": PERIOD, "hardware_basis": "depreciation", "axes": ["cloud", "software", "ai", "hardware"], # #2216: every BU payload declares what (if anything) the # invoice-measure gate withheld; my_bu_costs passes it through. "withheld_cost_types": [], "items": [ {"bu": "研发", "ytd_actual_usd": "1000"}, {"bu": "销售", "ytd_actual_usd": "9999"}, ], } monkeypatch.setattr(read_service, "_build_bu_resolver", _resolver) monkeypatch.setattr(read_service, "bu_fiscal_year_costs", _full) res = await read_service.my_bu_costs(db_session, ME, 2026) assert res["bu"] == "研发" assert len(res["items"]) == 1 and res["items"][0]["bu"] == "研发" # A person mapped to no BU → empty, not an error. async def _empty(_db): return ({}, {}) monkeypatch.setattr(read_service, "_build_bu_resolver", _empty) res2 = await read_service.my_bu_costs(db_session, OTHER, 2026) assert res2["bu"] is None and res2["items"] == []