"""GET /approval/asset/requests/summary — scoped request analytics. Pins: - Employee (non-admin) sees ONLY their own requests (force_self), so the chart population matches their /my/requests list. - Admin sees the whole population. - by_status / by_request_type distributions + windowed daily trend. """ from __future__ import annotations import uuid from datetime import UTC, datetime import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from app.domains.asset.models import EmployeeRequest from app.identity.person_models import IdentityPerson URL = "/api/v1/approval/asset/requests/summary" def _req(requester_id, *, request_type: str, status: str) -> EmployeeRequest: return EmployeeRequest( id=uuid.uuid4(), request_number=f"REQ-{uuid.uuid4().hex[:10]}", request_type=request_type, status=status, requester_id=requester_id, created_at=datetime.now(UTC), ) async def _stranger(db: AsyncSession) -> IdentityPerson: p = IdentityPerson( id=uuid.uuid4(), work_email=f"{uuid.uuid4().hex[:10]}@x.test", display_name="Stranger", employment_status="active", source_system="successfactors", hris_person_id=f"sf-{uuid.uuid4().hex[:8]}", ) db.add(p) await db.flush() return p @pytest.mark.asyncio async def test_summary_scopes_to_self_for_employee_and_all_for_admin( client: AsyncClient, db_session: AsyncSession, employee_user, employee_headers, admin_headers, ) -> None: emp, _ = employee_user stranger = await _stranger(db_session) # 3 for the employee (2 pending + 1 approved; 2 laptop + 1 buyback), # 1 for a stranger (must be invisible to the employee). db_session.add_all([ _req(emp.id, request_type="laptop_replacement", status="pending"), _req(emp.id, request_type="laptop_replacement", status="pending"), _req(emp.id, request_type="offboarding_buyback", status="approved"), _req(stranger.id, request_type="laptop_replacement", status="pending"), ]) await db_session.commit() # Employee: own slice only. r_emp = await client.get(URL, headers=employee_headers) assert r_emp.status_code == 200, r_emp.text emp_data = r_emp.json()["data"] assert emp_data["total"] == 3 assert emp_data["by_status"] == {"pending": 2, "approved": 1} assert emp_data["by_request_type"] == { "laptop_replacement": 2, "offboarding_buyback": 1, } assert emp_data["daily"][-1]["count"] == 3 # all created today assert len(emp_data["daily"]) == 30 # Admin: whole population (employee 3 + stranger 1). r_admin = await client.get(URL, headers=admin_headers) assert r_admin.status_code == 200, r_admin.text admin_data = r_admin.json()["data"] assert admin_data["total"] == 4 assert admin_data["by_status"] == {"pending": 3, "approved": 1} @pytest.mark.asyncio async def test_summary_buyback_totals_by_currency( client: AsyncClient, db_session: AsyncSession, employee_user, admin_headers, ) -> None: """回购汇总 — pending vs approved totals grouped by currency. Reads the first-class ``detail.buyback_amount`` key (stamped at submit since 2026-07-03; migration 379 backfilled history). Rows without the key (borrow / return / no-buyback replacement) and terminal-dead rows (rejected / cancelled) must not count. """ emp, _ = employee_user def _buyback(status: str, amount: str, currency: str, rtype: str = "offboarding_buyback"): row = _req(emp.id, request_type=rtype, status=status) row.detail = {"buyback_amount": {"amount": amount, "currency": currency}} return row db_session.add_all([ _buyback("approved", "1000.00", "CNY"), _buyback("approved", "2500.50", "CNY", rtype="laptop_replacement"), _buyback("pending", "300.00", "CNY"), _buyback("approved", "99.99", "USD"), _buyback("rejected", "7777.00", "CNY"), # dead — excluded _req(emp.id, request_type="computer_borrow", status="pending"), # no amount ]) await db_session.commit() r = await client.get(URL, headers=admin_headers) assert r.status_code == 200, r.text buckets = { b["currency"]: b for b in r.json()["data"]["buyback"]["by_currency"] } assert buckets["CNY"]["approved_total"] == pytest.approx(3500.50) assert buckets["CNY"]["approved_count"] == 2 assert buckets["CNY"]["pending_total"] == pytest.approx(300.00) assert buckets["CNY"]["pending_count"] == 1 assert buckets["USD"]["approved_total"] == pytest.approx(99.99) assert buckets["USD"]["pending_count"] == 0 # request_type pin that excludes both buyback types skips the # aggregate entirely (software-mgmt view contract). r_sw = await client.get( URL, headers=admin_headers, params={"request_type": "software_seat"}, ) assert r_sw.status_code == 200 assert r_sw.json()["data"]["buyback"]["by_currency"] == [] def test_extract_buyback_amount_fallback_chain() -> None: """Unit-pin the legacy detail shapes the extractor must survive.""" from app.approval.service import ApprovalService extract = ApprovalService.extract_buyback_amount # 1. First-class key wins. assert extract("offboarding_buyback", { "buyback_amount": {"amount": "12.30", "currency": "usd"}, }) == {"amount": "12.30", "currency": "usd"} # 2. form_snapshot amount widget. assert extract("laptop_replacement", { "feishu": {"form_snapshot": [ {"id": "w1", "type": "date", "answer": "2026-01-01"}, {"id": "w2", "type": "amount", "answer": {"amount": "88", "currency": "JPY"}}, ]}, }) == {"amount": "88", "currency": "JPY"} # 3. submitted_form JSON string with stringified value. assert extract("offboarding_buyback", { "feishu": {"submitted_form": '[{"id": "w", "type": "amount", "value": "{\\"amount\\": \\"5\\", \\"currency\\": \\"CNY\\"}"}]'}, }) == {"amount": "5", "currency": "CNY"} # 4. Non-buyback types never report an amount. assert extract("computer_borrow", { "buyback_amount": {"amount": "1", "currency": "CNY"}, }) is None # 5. Missing / malformed shapes degrade to None, never raise. assert extract("offboarding_buyback", None) is None assert extract("offboarding_buyback", {"feishu": {"form_snapshot": "junk"}}) is None