"""Expense intelligence — 汇联易 owner-email resolver + employee self-service + per-month analytics. Pins: the OID→Helix-person resolution by email (which unblocks attribution), the employee "我的报销" self-service summary, and the per-category / top-spender analytics. The live 汇联易 API isn't exercised in CI; these run against representative rows. """ from __future__ import annotations from decimal import Decimal import pytest import pytest_asyncio from app.core.coordinators import finance_huilianyi_coordinator as hly from app.domains.finance import read_service from app.domains.finance.fx import upsert_monthly_rate from app.domains.finance.models import FinanceExternalStatement PERIOD = "2026-05" # Valid UUIDs — owner_person_id is a person id; the analytics name-resolver # parses it as a UUID (no person rows here → names resolve to None). P1 = "11111111-1111-4111-8111-111111111111" P2 = "22222222-2222-4222-8222-222222222222" # ─── Owner resolution (pure) ──────────────────────────────────────── def test_resolve_owner_by_email(): index = {"shuai.feng@pingcap.com": "p-123"} doc = {"documentCode": "BX-1", "employeeEmail": "Shuai.Feng@PingCAP.com"} assert hly._resolve_owner(doc, index) == "p-123" # No email field / no match → None (row still reconciles by cost-centre). assert hly._resolve_owner({"documentCode": "BX-2"}, index) is None assert hly._resolve_owner({"email": "nobody@x.com"}, index) is None @pytest_asyncio.fixture async def fx(db_session): await upsert_monthly_rate(db_session, "CNY", PERIOD, Decimal("0.14"), source="manual", frozen=True) await db_session.flush() return db_session @pytest.mark.asyncio async def test_map_sets_owner_from_resolved_email(fx, monkeypatch): # Avoid creating IdentityPerson rows: stub the email index. async def _index(_db): return {"alice@pingcap.com": "person-alice"} monkeypatch.setattr(hly, "_build_person_email_index", _index) doc = { "documentCode": "BX-100", "accountingDate": "2026-05-10", "employeeEmail": "alice@pingcap.com", "currency": "CNY", "apportionments": [ {"expenseType": "差旅费", "amount": "700", "costCenterCode": "CC-1"}, ], } rows = await hly.map_documents_to_statements(fx, [doc]) assert len(rows) == 1 assert rows[0].owner_person_id == "person-alice" # ─── Employee self-service + analytics (DB-backed) ────────────────── def _stmt(owner, category, amount, *, status="PAID", dept="研发"): return FinanceExternalStatement( statement_key=f"k|{owner}|{category}|{amount}|{status}", source="huilianyi", external_doc_no=f"BX-{amount}", line_no=0, period=PERIOD, expense_category=category, cost_center="CC-1", department=dept, owner_person_id=owner, amount_native=Decimal(str(amount)), currency="CNY", amount_usd=Decimal(str(amount)), payment_status=status, ) @pytest.mark.asyncio async def test_my_expenses_summary(db_session): db = db_session db.add(_stmt(P1, "差旅费", 100, status="PAID")) db.add(_stmt(P1, "餐饮费", 50, status="PENDING")) db.add(_stmt(P2, "差旅费", 999, status="PAID")) # other person await db.flush() res = await read_service.my_expenses(db, P1) assert res["count"] == 2 assert res["total_usd"] == "150.00" cats = {c["category"]: c["amount_usd"] for c in res["by_category"]} assert cats["差旅费"] == "100.00" and cats["餐饮费"] == "50.00" statuses = {s["status"] for s in res["by_status"]} assert statuses == {"paid", "pending"} @pytest.mark.asyncio async def test_expense_analytics_top_spenders(db_session): db = db_session db.add(_stmt(P1, "差旅费", 300)) db.add(_stmt(P2, "餐饮费", 1200)) db.add(_stmt(None, "办公费", 80)) # unattributed → not a top spender await db.flush() res = await read_service.expense_analytics(db, PERIOD) assert res["row_count"] == 3 assert res["total_usd"] == "1580.00" assert res["attributed_count"] == 2 # Top spender is p-2 ($1200), then p-1 ($300). spenders = res["top_spenders"] assert spenders[0]["owner_person_id"] == P2 assert spenders[0]["amount_usd"] == "1200.00" cats = {c["key"]: c["amount_usd"] for c in res["by_category"]} assert cats["办公费"] == "80.00"