"""Finance domain HTTP surface. Mounted under ``/api/v1/finance``. PR1 ships the operational / ops-admin endpoints that make the unified spend ledger a usable, verifiable closed loop: * ``POST /spend/rollup/refresh`` — (re)materialise a period on demand * ``GET /spend/reconcile/internal`` — ledger-vs-source self-check * ``GET /period/status`` — period close state * ``POST /period/close`` / ``POST /period/reopen`` — accounting freeze * ``GET /fx/monthly`` / ``PUT /fx/monthly`` — month-FX inspect / pin PR2 adds the read surface (summary / pivot / timeseries / detail / movers / export) consumed by the finance dashboard. RBAC: reads = ``can_view_finance`` (finance / global admin / auditor / management); mutations = ``can_manage_finance`` (global admin only), mirroring every other monetary surface. """ from __future__ import annotations import logging import uuid from typing import Annotated from fastapi import APIRouter, Depends, Query, Request from fastapi.responses import StreamingResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.audit.service import AuditService from app.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import ( check_sensitive_endpoint_rate_limit, get_current_user, ) from app.core.dependencies import ( require_active_employee_for_mutations as _leaver_runtime_gate, ) from app.core.exceptions import AppException, ForbiddenError, NotFoundError from app.core.rbac import ( can_manage_finance, can_operate_finance, can_view_finance, can_view_invoice_measure, ) from app.core.response import success_response from app.domains.finance import read_service from app.domains.finance import service as finance_service from app.domains.finance.fx import get_monthly_row, upsert_monthly_rate from app.domains.finance.models import ( FinanceFxMonthly, FinancePeriodClose, FinanceSavedView, ) from app.domains.finance.schemas import ( BudgetCreateBody, BudgetUpdateBody, CloseBody, FindingAckBody, FiscalYearConfigBody, FxPinBody, NetSuiteAccountCostTypeBody, NetSuiteGLSyncBody, NetSuiteProbeBody, NetSuiteSubsidiaryMapBody, PeriodBody, SavedViewBody, ) logger = logging.getLogger(__name__) router = APIRouter( prefix="/api/v1/finance", tags=["finance"], dependencies=[Depends(_leaver_runtime_gate)], ) def _trace_id(request: Request) -> str: return getattr(request.state, "trace_id", None) or str(uuid.uuid4()) def _serialize_close(row: FinancePeriodClose | None, period: str) -> dict: if row is None: return {"period": period, "status": "open", "closed_at": None} return { "period": row.period, "status": row.status, "closed_total_usd": ( str(row.closed_total_usd) if row.closed_total_usd is not None else None ), "closed_at": row.closed_at.isoformat() if row.closed_at else None, "closed_by_id": row.closed_by_id, "reopened_at": row.reopened_at.isoformat() if row.reopened_at else None, "note": row.note, } # ─── Rollup + reconciliation ──────────────────────────────────── @router.post("/spend/rollup/refresh") async def refresh_rollup( body: PeriodBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): """Recompute the unified ledger for a period (global admin).""" if not can_manage_finance(user.role_names): raise ForbiddenError() result = await finance_service.refresh_period(db, body.period) await AuditService(db).log( trace_id=_trace_id(request), actor_type="user", actor_user_id=user.id, action="finance.spend_rollup.refresh", status="success", resource_type="finance.period", resource_id=body.period, detail=result, ) return success_response(data=result, request=request) @router.get("/spend/reconcile/internal") async def reconcile_internal( request: Request, period: Annotated[str, Query(description="Calendar month 'YYYY-MM'.")], user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Verify the ledger agrees with each source domain for the period. ``can_operate_finance``, NOT ``can_view_finance`` (2026-07-29). The payload's ``cloud.source_usd`` is ``SUM(cmdb_cost_facts.amount_usd)`` — the **实付** column — at (company × month) grain, and reconciliation cannot be restated in 折前 terms (a ledger is only reconcilable against what was actually billed). ``can_view_finance`` admits ``auditor`` and ``management``, who are outside the invoice circle (``cmdb.cost_view.can_view_invoice_measure`` = finance / cloud_admin / global_admin); either of them could divide this against the list-basis total the same gate hands them from ``/cost/breakdown`` or ``/cmdb/billing/monthly`` and recover the company discount rate. ``can_operate_finance`` (global_admin + finance) is a strict SUBSET of the invoice circle, so it cannot leak, and it is the predicate that already gates the sibling month-close operations this check belongs to. """ if not can_operate_finance(user.role_names): raise ForbiddenError() result = await finance_service.reconcile_internal(db, period) return success_response(data=result, request=request) # ─── Read surface (dashboard) ─────────────────────────────────── def _csv(value: str | None) -> list[str] | None: if not value: return None parts = [p.strip() for p in value.split(",") if p.strip()] return parts or None def _withhold_cloud_for(user: AuthUser) -> bool: """True when this reader must NOT see invoice-basis cloud money. The ledger materialises cloud rows straight off ``cmdb_cost_facts.amount_usd`` (实付). DECISIONS 2026-07-29 #1: no reader outside the invoice-measure circle may obtain any invoice figure from any surface, because dividing it by the list-basis total the same reader can already fetch (``/cost/breakdown``, ``/cmdb/billing/monthly``) recovers the company discount rate. ``/spend/reconcile/internal`` was narrowed for exactly this reason; the eight other reads of the same ledger — at a FINER grain — were not (#2121). Narrowing the whole surface to the circle would also take hardware / software / AI spend away from auditor and management, which they legitimately need, so this routes by MEASURE instead: everything except cloud stays visible, and the payload declares what was withheld. """ return not can_view_invoice_measure(user.role_names) # The vendor IN-list cap now lives in ``read_service.enforce_vendor_cap`` # and is enforced inside ``_apply_filters``, so HTTP and MCP share one gate # (#2238 — it used to live here, which left MCP's hand-built filter dict, # the surface the cap's own comment called out as unbounded, uncapped). # Still called eagerly here so an over-long list 400s before any DB work. def _filters_from_query( *, cost_type, basis, department, cost_center, vendor, owner, sub_category=None, allocation_source=None, withhold_cloud: bool = False, group_by: str | None = None, ) -> dict: read_service.enforce_vendor_cap(vendor) # ``vendor`` accepts a repeated query param (multi-select: "these three # vendors") or a single value. Deliberately NOT comma-separated like # ``cost_type`` — vendor names are free text and "Acme, Inc." would split # into two vendors that match nothing. if isinstance(vendor, (list, tuple)): vendors: list[str] | str | None = [v for v in vendor if v] or None else: vendors = vendor or None return { "cost_type": _csv(cost_type), "basis": basis or None, "department": department or None, "cost_center": cost_center or None, "vendor": vendors, "owner": owner or None, "sub_category": sub_category or None, "allocation_source": allocation_source or None, "withhold_cloud": withhold_cloud, # #2237: the pivot keeps the overlapping hardware-CASH rows when the # user splits BY basis; detail/export only ever received the filter # bar, so their unconditional exclude_cash made the xlsx total # disagree with the table it was exported from. "group_by_has_basis": "basis" in (_csv(group_by) or []), } def _periods_from_query( period: str | None, start: str | None, end: str | None, ) -> list[str]: """Resolve the month scope of a read: a range (``start``+``end``) or a single ``period``. ``period`` stays supported so every existing caller (MCP tools, bookmarked URLs, the single-month dashboards) keeps working unchanged; passing ``start``/``end`` opts into the range. Giving neither is a 400 rather than a silent "all of history" scan. """ if start or end: if not (start and end): raise AppException( code="INCOMPLETE_RANGE", message="start and end must be given together", status_code=400, ) return read_service.enumerate_periods( finance_service.validate_period(start), finance_service.validate_period(end), ) if period: return [finance_service.validate_period(period)] raise AppException( code="PERIOD_REQUIRED", message="pass either period=YYYY-MM or start=YYYY-MM&end=YYYY-MM", status_code=400, ) @router.get("/spend/summary") async def spend_summary( request: Request, period: Annotated[str, Query()], user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): if not can_view_finance(user.role_names): raise ForbiddenError() period = finance_service.validate_period(period) return success_response( data=await read_service.get_summary( db, period, withhold_cloud=_withhold_cloud_for(user), ), request=request, ) @router.get("/spend/pivot") async def spend_pivot( request: Request, period: Annotated[str | None, Query(description="Single month 'YYYY-MM'.")] = None, start: Annotated[str | None, Query(description="Range start 'YYYY-MM'.")] = None, end: Annotated[str | None, Query(description="Range end 'YYYY-MM'.")] = None, group_by: Annotated[str, Query(description="Comma-separated, 1-3 dims.")] = "cost_type", cost_type: Annotated[str | None, Query()] = None, basis: Annotated[str | None, Query()] = None, department: Annotated[str | None, Query()] = None, cost_center: Annotated[str | None, Query()] = None, vendor: Annotated[list[str] | None, Query(description="Repeatable.")] = None, owner: Annotated[str | None, Query()] = None, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Group spend by 1–3 dimensions over a single month (``period``) or a month RANGE (``start``+``end``, ≤36 months). Range mode answers "these vendors, this stretch of months, how much each" without opening a tab per month. Safe to sum here because every fact is already USD-normalised at its own month's frozen FX rate; the response's ``provisional_periods`` names any month in the range that isn't closed yet (its numbers can still be restated). """ if not can_view_finance(user.role_names): raise ForbiddenError() periods = _periods_from_query(period, start, end) filters = _filters_from_query( cost_type=cost_type, basis=basis, department=department, cost_center=cost_center, vendor=vendor, owner=owner, withhold_cloud=_withhold_cloud_for(user), ) data = await read_service.pivot( db, periods, _csv(group_by) or ["cost_type"], filters, ) return success_response(data=data, request=request) @router.get("/spend/vendors") async def spend_vendors( request: Request, period: Annotated[str | None, Query()] = None, start: Annotated[str | None, Query()] = None, end: Annotated[str | None, Query()] = None, cost_type: Annotated[str | None, Query()] = None, basis: Annotated[str | None, Query()] = None, department: Annotated[str | None, Query()] = None, cost_center: Annotated[str | None, Query()] = None, # #2190: the resolver's docstring promises "the same filters as the # pivot (minus vendor)" and ``owner`` is one of them. Without it the # candidate set is WIDER than the result set, so picking a vendor # offered by this list could match zero rows — the "chose it and got # nothing" failure #2114 set out to remove, returning on another axis. owner: Annotated[str | None, Query()] = None, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Vendors that actually spent in the month/range, biggest first — the option list behind the explorer's vendor multi-select. Honours the same filters as the pivot except ``vendor`` itself, so ``cost_type=software`` lists exactly the software vendors that billed. """ if not can_view_finance(user.role_names): raise ForbiddenError() periods = _periods_from_query(period, start, end) filters = _filters_from_query( cost_type=cost_type, basis=basis, department=department, cost_center=cost_center, vendor=None, owner=owner, withhold_cloud=_withhold_cloud_for(user), ) return success_response( data=await read_service.vendors_in_range(db, periods, filters), request=request, ) @router.get("/spend/timeseries") async def spend_timeseries( request: Request, start: Annotated[str, Query(description="Start period 'YYYY-MM'.")], end: Annotated[str, Query(description="End period 'YYYY-MM'.")], group_by: Annotated[str | None, Query(description="Single dim, optional.")] = None, cost_type: Annotated[str | None, Query()] = None, basis: Annotated[str | None, Query()] = None, department: Annotated[str | None, Query()] = None, cost_center: Annotated[str | None, Query()] = None, # Repeatable, like its pivot / detail / export siblings (#2183). # #2114 list-ified those three and left this one scalar; the shared # frontend filter type is already ``string[]``, so a multi-select # would serialise as ``vendor[]=A&vendor[]=B``, bind to nothing, and # the filter would vanish — the table narrowed to three vendors # while the trend line drew the whole company, two numbers # disagreeing on one screen with no error. vendor: Annotated[list[str] | None, Query(description="Repeatable.")] = None, owner: Annotated[str | None, Query()] = None, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): if not can_view_finance(user.role_names): raise ForbiddenError() start = finance_service.validate_period(start) end = finance_service.validate_period(end) filters = _filters_from_query( cost_type=cost_type, basis=basis, department=department, cost_center=cost_center, vendor=vendor, owner=owner, withhold_cloud=_withhold_cloud_for(user), ) data = await read_service.timeseries(db, start, end, group_by or None, filters) return success_response(data=data, request=request) @router.get("/spend/detail") async def spend_detail( request: Request, period: Annotated[str | None, Query()] = None, start: Annotated[str | None, Query()] = None, end: Annotated[str | None, Query()] = None, cost_type: Annotated[str | None, Query()] = None, basis: Annotated[str | None, Query()] = None, department: Annotated[str | None, Query()] = None, cost_center: Annotated[str | None, Query()] = None, vendor: Annotated[list[str] | None, Query(description="Repeatable.")] = None, owner: Annotated[str | None, Query()] = None, # Both are ``group_by`` dimensions, so a drill from a cell grouped by # either one must be able to narrow by it (#2148). sub_category: Annotated[str | None, Query()] = None, allocation_source: Annotated[str | None, Query()] = None, # The pivot's grouping, carried so the cash-lens rule matches the table # the drill/export came from (#2237). Filter-only clients may omit it. group_by: Annotated[str | None, Query()] = None, limit: Annotated[int, Query(ge=1, le=1000)] = 200, offset: Annotated[int, Query(ge=0)] = 0, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Raw ledger rows for a single month or a range — the drill-down behind any aggregate cell. Each row carries its own ``period``.""" if not can_view_finance(user.role_names): raise ForbiddenError() periods = _periods_from_query(period, start, end) filters = _filters_from_query( cost_type=cost_type, basis=basis, department=department, cost_center=cost_center, vendor=vendor, owner=owner, sub_category=sub_category, allocation_source=allocation_source, withhold_cloud=_withhold_cloud_for(user), group_by=group_by, ) data = await read_service.detail(db, periods, filters, limit=limit, offset=offset) return success_response( data=data["rows"], request=request, meta={"total": data["total"], "limit": limit, "offset": offset}, ) @router.get("/spend/movers") async def spend_movers( request: Request, period: Annotated[str, Query()], dim: Annotated[str, Query()] = "department", top: Annotated[int, Query(ge=1, le=50)] = 10, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): if not can_view_finance(user.role_names): raise ForbiddenError() period = finance_service.validate_period(period) return success_response( data=await read_service.movers( db, period, dim, top=top, withhold_cloud=_withhold_cloud_for(user), ), request=request, ) @router.get("/spend/completeness") async def spend_completeness( request: Request, period: Annotated[str, Query()], user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): if not can_view_finance(user.role_names): raise ForbiddenError() period = finance_service.validate_period(period) return success_response( data=await read_service.completeness( db, period, withhold_cloud=_withhold_cloud_for(user), ), request=request, ) @router.get("/spend/digest") async def spend_digest( request: Request, period: Annotated[str, Query()], user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): if not can_view_finance(user.role_names): raise ForbiddenError() period = finance_service.validate_period(period) return success_response( data=await read_service.digest( db, period, withhold_cloud=_withhold_cloud_for(user), ), request=request, ) @router.get("/spend/export.xlsx") async def spend_export( request: Request, period: Annotated[str | None, Query()] = None, start: Annotated[str | None, Query()] = None, end: Annotated[str | None, Query()] = None, cost_type: Annotated[str | None, Query()] = None, basis: Annotated[str | None, Query()] = None, department: Annotated[str | None, Query()] = None, cost_center: Annotated[str | None, Query()] = None, vendor: Annotated[list[str] | None, Query(description="Repeatable.")] = None, owner: Annotated[str | None, Query()] = None, # Mirror ``spend_detail`` — the export IS that query (#2148). sub_category: Annotated[str | None, Query()] = None, allocation_source: Annotated[str | None, Query()] = None, group_by: Annotated[str | None, Query()] = None, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Pivot-ready Excel dump of the filtered ledger rows for a month or a range. Rows carry their own ``period`` column so a multi-month dump still pivots by month in Excel.""" if not can_view_finance(user.role_names): raise ForbiddenError() periods = _periods_from_query(period, start, end) label = ( periods[0] if len(periods) == 1 else f"{periods[0]}_{periods[-1]}" ) filters = _filters_from_query( cost_type=cost_type, basis=basis, department=department, cost_center=cost_center, vendor=vendor, owner=owner, sub_category=sub_category, allocation_source=allocation_source, withhold_cloud=_withhold_cloud_for(user), group_by=group_by, ) # Pull the full filtered set. The ledger grain is monthly-aggregated # (a month is low thousands of rows), so 20k is a generous ceiling # that still bounds peak memory against a pathological filterless dump. # A wide range multiplies that grain, so the cap can now actually bite — # report it in the audit + response headers instead of handing over a # silently-short file. data = await read_service.detail(db, periods, filters, limit=20000, offset=0) truncated = data["total"] > len(data["rows"]) content = _build_export_xlsx(label, data["rows"]) await AuditService(db).log( trace_id=_trace_id(request), actor_type="user", actor_user_id=user.id, action="finance.spend.export", status="success", resource_type="finance.period", resource_id=label, detail={ "rows": data["total"], "returned": len(data["rows"]), "truncated": truncated, "periods": periods, }, ) return StreamingResponse( iter([content]), media_type=( "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ), headers={ "Content-Disposition": f'attachment; filename="finance_spend_{label}.xlsx"', "X-Export-Total-Matched": str(data["total"]), "X-Export-Returned": str(len(data["rows"])), "X-Export-Truncated": "true" if truncated else "false", }, ) def _csv_formula_safe(value): """Neutralise spreadsheet formula injection. A cell whose text starts with ``= + - @`` (or a leading tab/CR) is interpreted as a live formula by Excel/Sheets. Several exported fields (vendor / sub_category / owner_name / source_ref) originate from external billing / HR data, so a crafted value like ``=HYPERLINK(...)`` would execute on open. Prefix such strings with a single quote so they render literally. Non-strings pass through. """ if isinstance(value, str) and value and value[0] in ("=", "+", "-", "@", "\t", "\r"): return "'" + value return value def _build_export_xlsx(label: str, rows: list[dict]) -> bytes: import io from openpyxl import Workbook wb = Workbook() ws = wb.active # Excel caps sheet titles at 31 chars; "spend_2026-02_2026-07" fits, but # keep the slice so a future longer label can't raise on save. ws.title = f"spend_{label}"[:31] headers = [ # ``period`` first: a range dump is only pivotable by month if the # month is on the row. "period", "cost_type", "basis", "sub_category", "vendor", "department", "cost_center", "owner_name", "allocation_source", "amount_native", "currency_native", "amount_usd", "fx_rate_used", "fx_basis", "is_provisional", "source_domain", "source_ref", ] ws.append(headers) for r in rows: ws.append([_csv_formula_safe(r.get(h)) for h in headers]) buf = io.BytesIO() wb.save(buf) return buf.getvalue() # ─── Saved explorer views ─────────────────────────────────────── def _serialize_view(v: FinanceSavedView) -> dict: return { "id": v.id, "name": v.name, "config": v.config, "is_shared": v.is_shared, "owner_person_id": v.owner_person_id, "updated_at": v.updated_at.isoformat() if v.updated_at else None, } @router.get("/views") async def list_views( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): if not can_view_finance(user.role_names): raise ForbiddenError() # owner_person_id is a plain String(36); user.id is a uuid.UUID. Compare # as a canonical string — binding a raw uuid.UUID against the CHAR column # via asyncmy silently matches zero rows (a private view would never list). rows = (await db.execute( select(FinanceSavedView).where( (FinanceSavedView.owner_person_id == str(user.id)) | (FinanceSavedView.is_shared.is_(True)) ).order_by(FinanceSavedView.updated_at.desc()) )).scalars().all() return success_response( data=[_serialize_view(v) for v in rows], request=request, ) @router.post("/views") async def create_view( body: SavedViewBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): if not can_view_finance(user.role_names): raise ForbiddenError() view = FinanceSavedView( owner_person_id=str(user.id), name=body.name, config=body.config, is_shared=body.is_shared, ) db.add(view) await db.flush() await AuditService(db).log( trace_id=_trace_id(request), actor_type="user", actor_user_id=user.id, action="finance.saved_view.create", status="success", resource_type="finance.saved_view", resource_id=str(view.id), detail={"name": view.name, "is_shared": view.is_shared}, ) return success_response(data=_serialize_view(view), request=request) @router.put("/views/{view_id}") async def update_view( view_id: int, body: SavedViewBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): if not can_view_finance(user.role_names): raise ForbiddenError() view = await db.get(FinanceSavedView, view_id) if view is None: raise NotFoundError("FinanceSavedView", str(view_id)) # Only the owner edits; sharing doesn't grant write. Compare as strings — # owner_person_id loads as str, user.id is a uuid.UUID, so a raw `!=` # would always be True and 403 the genuine owner. if str(view.owner_person_id) != str(user.id) and not can_manage_finance( user.role_names ): raise ForbiddenError() view.name = body.name view.config = body.config view.is_shared = body.is_shared await db.flush() await AuditService(db).log( trace_id=_trace_id(request), actor_type="user", actor_user_id=user.id, action="finance.saved_view.update", status="success", resource_type="finance.saved_view", resource_id=str(view_id), detail={"name": view.name, "is_shared": view.is_shared}, ) return success_response(data=_serialize_view(view), request=request) @router.delete("/views/{view_id}") async def delete_view( view_id: int, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): if not can_view_finance(user.role_names): raise ForbiddenError() view = await db.get(FinanceSavedView, view_id) if view is None: raise NotFoundError("FinanceSavedView", str(view_id)) if str(view.owner_person_id) != str(user.id) and not can_manage_finance( user.role_names ): raise ForbiddenError() detail = {"name": view.name, "is_shared": view.is_shared} await db.delete(view) await AuditService(db).log( trace_id=_trace_id(request), actor_type="user", actor_user_id=user.id, action="finance.saved_view.delete", status="success", resource_type="finance.saved_view", resource_id=str(view_id), detail=detail, ) return success_response(data={"deleted": True}, request=request) # ─── Cost governance & vendor/contract lens (PR3) ─────────────── @router.get("/governance/savings") async def governance_savings( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Wasted + idle paid seats finance can stop paying for.""" if not can_view_finance(user.role_names): raise ForbiddenError() from app.core.coordinators import finance_governance_coordinator as gov return success_response(data=await gov.savings_opportunities(db), request=request) @router.get("/governance/commitments") async def governance_commitments( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Open PO commitments (approved/received, not fully paid) — forward spend exposure, per department/cost-centre (finance-view role).""" if not can_view_finance(user.role_names): raise ForbiddenError() from app.core.coordinators import finance_governance_coordinator as gov return success_response(data=await gov.open_commitments(db), request=request) @router.get("/governance/renewals") async def governance_renewals( request: Request, within_days: Annotated[int, Query(ge=1, le=730)] = 90, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Contracts approaching renewal within the horizon.""" if not can_view_finance(user.role_names): raise ForbiddenError() from app.core.coordinators import finance_governance_coordinator as gov return success_response( data=await gov.contract_renewals(db, within_days=within_days), request=request, ) @router.get("/governance/contract-actuals") async def governance_contract_actuals( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Contracted amount vs actual PO spend per active contract.""" if not can_view_finance(user.role_names): raise ForbiddenError() from app.core.coordinators import finance_governance_coordinator as gov return success_response(data=await gov.contract_vs_actual(db), request=request) # ─── External-actuals reconciliation (Phase 2 — vs 汇联易) ─────── @router.get("/reconcile/external") async def reconcile_external( request: Request, period: Annotated[str, Query()], dim: Annotated[str, Query()] = "cost_center", user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Internal computed spend vs external actual (汇联易) per dimension.""" if not can_view_finance(user.role_names): raise ForbiddenError() period = finance_service.validate_period(period) return success_response( data=await read_service.external_reconcile(db, period, dim), request=request, ) @router.get("/external/statements") async def external_statements( request: Request, period: Annotated[str, Query()], cost_center: Annotated[str | None, Query()] = None, vendor: Annotated[str | None, Query()] = None, promoted: Annotated[bool | None, Query()] = None, limit: Annotated[int, Query(ge=1, le=1000)] = 200, offset: Annotated[int, Query(ge=0)] = 0, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Browse the raw external-actual lines (汇联易) for a period.""" if not can_view_finance(user.role_names): raise ForbiddenError() period = finance_service.validate_period(period) data = await read_service.external_statements( db, period, cost_center=cost_center, vendor=vendor, promoted=promoted, limit=limit, offset=offset, ) return success_response( data=data["rows"], request=request, meta={"total": data["total"], "limit": limit, "offset": offset}, ) @router.get("/cost-centers/coverage") async def cost_center_coverage( request: Request, period: Annotated[str, Query()], user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Authoritative cost-centre coverage (orphans / unused / coverage%).""" if not can_view_finance(user.role_names): raise ForbiddenError() period = finance_service.validate_period(period) return success_response( data=await read_service.cost_center_coverage( db, period, withhold_cloud=_withhold_cloud_for(user), ), request=request, ) @router.post("/cost-centers/reconcile-master") async def reconcile_cost_center_master( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): """Rebuild the cost-centre master from the HRIS truth (global admin). Idempotent on-demand twin of the daily reconciler — lets finance fix a coverage gap immediately after an HRIS sync instead of waiting for the nightly rollup. 汇联易 enrichment rows are left untouched. """ if not can_manage_finance(user.role_names): raise ForbiddenError() from app.core.coordinators import finance_huilianyi_coordinator as hly result = await hly.reconcile_master_from_hris(db) await AuditService(db).log( trace_id=_trace_id(request), actor_type="user", actor_user_id=user.id, action="finance.cost_center.reconcile_master", status="success", resource_type="finance.cost_center_master", resource_id="hris", detail=result, ) await db.commit() return success_response(data=result, request=request) @router.get("/governance/expense-hygiene") async def expense_hygiene( request: Request, period: Annotated[str, Query()], user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Reimbursement-hygiene findings (duplicate invoices / paid-unverified).""" if not can_view_finance(user.role_names): raise ForbiddenError() period = finance_service.validate_period(period) return success_response( data=await read_service.expense_governance(db, period), request=request, ) @router.post("/findings/ack") async def acknowledge_finding( body: FindingAckBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): """Triage a cost-governance finding (finance controller / global admin). Records acknowledge / snooze / resolve for one ``(kind, ref)`` so the finding stops re-nagging on every recompute. Idempotent upsert + audit. """ if not can_operate_finance(user.role_names): raise ForbiddenError() from app.domains.finance import finding_service row = await finding_service.set_ack( db, kind=body.kind, ref=body.ref, status=body.status, snooze_until=body.snooze_until, note=body.note, actor_id=str(user.id), ) await AuditService(db).log( trace_id=_trace_id(request), actor_type="user", actor_user_id=user.id, action="finance.finding.ack", status="success", resource_type="finance.finding", resource_id=f"{body.kind}:{body.ref}", detail={"status": body.status}, ) await db.commit() return success_response(data={"id": row.id, "status": row.status}, request=request) # ─── Period close lifecycle ───────────────────────────────────── @router.get("/period/status") async def period_status( request: Request, period: Annotated[str, Query(description="Calendar month 'YYYY-MM'.")], user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): if not can_view_finance(user.role_names): raise ForbiddenError() period = finance_service.validate_period(period) row = await finance_service.get_close(db, period) return success_response(data=_serialize_close(row, period), request=request) @router.post("/period/close") async def close_period( body: CloseBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): if not can_operate_finance(user.role_names): raise ForbiddenError() result = await finance_service.close_period( db, body.period, actor_id=user.id, note=body.note, ) await AuditService(db).log( trace_id=_trace_id(request), actor_type="user", actor_user_id=user.id, action="finance.period.close", status="success", resource_type="finance.period", resource_id=body.period, detail=result, ) return success_response(data=result, request=request) @router.post("/period/reopen") async def reopen_period( body: CloseBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): if not can_operate_finance(user.role_names): raise ForbiddenError() result = await finance_service.reopen_period( db, body.period, actor_id=user.id, note=body.note, ) await AuditService(db).log( trace_id=_trace_id(request), actor_type="user", actor_user_id=user.id, action="finance.period.reopen", status="success", resource_type="finance.period", resource_id=body.period, detail=result, ) return success_response(data=result, request=request) # ─── Month FX inspect / pin ───────────────────────────────────── @router.get("/fx/monthly") async def list_monthly_fx( request: Request, period: Annotated[str, Query(description="Calendar month 'YYYY-MM'.")], user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): if not can_view_finance(user.role_names): raise ForbiddenError() period = finance_service.validate_period(period) rows = ( await db.execute( select(FinanceFxMonthly).where(FinanceFxMonthly.period == period) ) ).scalars().all() data = [ { "period": r.period, "currency": r.currency, "usd_per_unit": str(r.usd_per_unit), "source": r.source, "frozen": r.frozen, "note": r.note, } for r in rows ] return success_response(data=data, request=request) @router.put("/fx/monthly") async def pin_monthly_fx( body: FxPinBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): """Manually pin a month's FX rate (global admin), frozen + authoritative.""" if not can_manage_finance(user.role_names): raise ForbiddenError() period = finance_service.validate_period(body.period) await upsert_monthly_rate( db, body.currency, period, body.usd_per_unit, source="manual", frozen=True, note=body.note or "manual pin", ) # Existing facts keep the rate they were materialised with; an admin # who pins a corrected rate must refresh the period to apply it. await AuditService(db).log( trace_id=_trace_id(request), actor_type="user", actor_user_id=user.id, action="finance.fx.pin", status="success", resource_type="finance.fx_monthly", resource_id=f"{period}:{body.currency}", detail={"usd_per_unit": str(body.usd_per_unit)}, ) row = await get_monthly_row(db, body.currency, period) return success_response( data={ "period": period, "currency": body.currency, "usd_per_unit": str(row.usd_per_unit) if row else None, "frozen": row.frozen if row else None, }, request=request, ) # ─── BU fiscal-year cost view ─────────────────────────────────── @router.get("/bu-costs") async def bu_fiscal_year_costs( request: Request, fiscal_year: Annotated[int, Query(ge=2000, le=2100)], as_of_period: Annotated[str | None, Query(description="'YYYY-MM'; default = current month clamped to FY end")] = None, hardware_basis: Annotated[str, Query(description="depreciation (default) | cash")] = "depreciation", user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Per-BU fiscal-year cost across cloud / software / ai / hardware vs budget. Finance / leadership read surface (the 财务 + management audience). """ if not can_view_finance(user.role_names): raise ForbiddenError() as_of = finance_service.validate_period(as_of_period) if as_of_period else None data = await read_service.bu_fiscal_year_costs( db, fiscal_year, as_of_period=as_of, hardware_basis=hardware_basis, withhold_cloud=_withhold_cloud_for(user), ) return success_response(data=data, request=request) @router.get("/bu-costs/drill") async def bu_cost_drill( request: Request, fiscal_year: Annotated[int, Query(ge=2000, le=2100)], bu: Annotated[str, Query(min_length=1, max_length=128)], group_by: Annotated[str, Query(description="cost_type|vendor|sub_category|owner|cost_center")] = "vendor", cost_type: Annotated[str | None, Query()] = None, as_of_period: Annotated[str | None, Query()] = None, hardware_basis: Annotated[str, Query()] = "depreciation", user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Drill one BU's fiscal-year spend by vendor / product / person / cost-centre.""" if not can_view_finance(user.role_names): raise ForbiddenError() as_of = finance_service.validate_period(as_of_period) if as_of_period else None data = await read_service.bu_cost_drill( db, fiscal_year, bu, group_by=group_by, cost_type=cost_type, as_of_period=as_of, hardware_basis=hardware_basis, withhold_cloud=_withhold_cloud_for(user), ) return success_response(data=data, request=request) # ─── Budget lifecycle (PR2) ───────────────────────────────────── @router.get("/budgets") async def list_budgets_vs_actual( request: Request, fiscal_year: Annotated[int, Query(ge=2000, le=2100)], as_of_period: Annotated[str | None, Query(description="'YYYY-MM'; default = latest month of the year")] = None, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Every budget for a fiscal year, measured against materialised actuals.""" if not can_view_finance(user.role_names): raise ForbiddenError() from app.domains.finance import budget_service as_of = finance_service.validate_period(as_of_period) if as_of_period else None return success_response( data=await budget_service.budget_vs_actual( db, fiscal_year, as_of, withhold_cloud=_withhold_cloud_for(user), ), request=request, ) @router.get("/budgets/me") async def my_budgets_vs_actual( request: Request, fiscal_year: Annotated[int, Query(ge=2000, le=2100)], as_of_period: Annotated[str | None, Query()] = None, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Budget-vs-actual for budgets the CURRENT user owns — accountability down to the cost-centre / department owner. Self-scoped: any authenticated user, strictly filtered to budgets whose ``owner_person_id == user.id``. A budget owner is an arbitrary person (a manager / cost-centre owner who typically holds no finance role), so this must NOT carry the finance gate — it would 403 the exact people the accountability view is built for. No leak: the read only ever returns the caller's own budgets. """ from app.domains.finance import budget_service as_of = finance_service.validate_period(as_of_period) if as_of_period else None data = await budget_service.budget_vs_actual( db, fiscal_year, as_of, owner_person_id=str(user.id), # Owning a cloud-scoped budget does not put you inside the # invoice-measure circle (#2216) — the actuals behind it are the # same 实付 figures every other surface withholds. withhold_cloud=_withhold_cloud_for(user), ) return success_response(data=data, request=request) @router.get("/fiscal-year-config") async def get_fiscal_year_config( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Current fiscal-year calendar: start month + the FY that contains today. Readable by anyone who can view finance — the budget page needs it to label the year picker and default to the live fiscal year. """ if not can_view_finance(user.role_names): raise ForbiddenError() from app.core import fiscal_year as fy start_month = await fy.get_fiscal_year_start_month(db) cur = fy.current_fiscal_year(start_month) return success_response( data={ "start_month": start_month, "current_fiscal_year": cur, "current_fy_label": fy.fy_label(cur, start_month), }, request=request, ) @router.put("/fiscal-year-config") async def set_fiscal_year_config( body: FiscalYearConfigBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): """Set the fiscal-year start month (1..12). Global-admin / finance-manage. Changing this re-windows EVERY budget-vs-actual and run-rate forecast for all fiscal years — it does not migrate data, it reinterprets the same ledger against a different 12-month window. Audited. """ if not can_manage_finance(user.role_names): raise ForbiddenError() from app.core.fiscal_year import PLATFORM_SETTING_FY_START_MONTH from app.integrations.models import PlatformSetting await db.merge(PlatformSetting( key=PLATFORM_SETTING_FY_START_MONTH, value=body.start_month, description="Fiscal-year start month (1-12); 4 = April. " "Re-windows all finance budget/forecast reads.", updated_by=user.id, )) await AuditService(db).log( trace_id=_trace_id(request), actor_type="user", actor_user_id=user.id, action="finance.fiscal_year_config.update", status="success", resource_type="platform_setting", resource_id=PLATFORM_SETTING_FY_START_MONTH, detail={"start_month": body.start_month}, ) await db.commit() return success_response(data={"start_month": body.start_month}, request=request) @router.post("/budgets") async def create_budget( body: BudgetCreateBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): """Create an annual budget (global admin).""" if not can_manage_finance(user.role_names): raise ForbiddenError() from app.domains.finance import budget_service async def _audit(row): await AuditService(db).log( trace_id=_trace_id(request), actor_type="user", actor_user_id=user.id, action="finance.budget.create", status="success", resource_type="finance.budget", resource_id=str(row.id), detail={"fiscal_year": row.fiscal_year, "amount_usd": str(row.amount_usd)}, ) row = await budget_service.create_budget_atomic( db, fiscal_year=body.fiscal_year, amount_usd=body.amount_usd, cost_type=body.cost_type, department=body.department, cost_center=body.cost_center, owner_person_id=body.owner_person_id, note=body.note, before_commit=_audit, ) return success_response(data={"id": row.id}, request=request) @router.put("/budgets/{budget_id}") async def update_budget( budget_id: int, body: BudgetUpdateBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): """Update a budget (global admin). Raising the ceiling re-arms alerts.""" if not can_manage_finance(user.role_names): raise ForbiddenError() from app.domains.finance import budget_service async def _audit(row): await AuditService(db).log( trace_id=_trace_id(request), actor_type="user", actor_user_id=user.id, action="finance.budget.update", status="success", resource_type="finance.budget", resource_id=str(budget_id), detail={"amount_usd": str(row.amount_usd)}, ) row = await budget_service.update_budget_atomic( db, budget_id, before_commit=_audit, **body.model_dump(exclude_unset=True), ) return success_response(data={"id": row.id}, request=request) @router.delete("/budgets/{budget_id}") async def delete_budget( budget_id: int, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): """Delete a budget (global admin).""" if not can_manage_finance(user.role_names): raise ForbiddenError() from app.domains.finance import budget_service await budget_service.delete_budget(db, budget_id) await AuditService(db).log( trace_id=_trace_id(request), actor_type="user", actor_user_id=user.id, action="finance.budget.delete", status="success", resource_type="finance.budget", resource_id=str(budget_id), ) await db.commit() return success_response(data={"id": budget_id}, request=request) # ─── NetSuite GL actuals (book of record) ─────────────────────────── @router.post("/netsuite/gl-probe") async def netsuite_gl_probe( body: NetSuiteProbeBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): """Read-only diagnostic: run a minimal SuiteQL against NetSuite and return raw GL rows so the live column shape is confirmed before the sync cron is enabled. Never writes. Finance-operations role.""" if not can_operate_finance(user.role_names): raise ForbiddenError() from app.core.coordinators import finance_netsuite_coordinator as ns result = await ns.probe(db, period=body.period, limit=body.limit) await AuditService(db).log( trace_id=_trace_id(request), actor_type="user", actor_user_id=user.id, action="finance.netsuite.gl_probe", status="success" if result.get("ok") else "failed", resource_type="finance.netsuite", resource_id=body.period or "auto", detail={"ok": result.get("ok"), "reason": result.get("reason"), "gl_rows_seen": result.get("gl_rows_seen")}, ) return success_response(data=result, request=request) @router.post("/netsuite/gl/sync") async def netsuite_gl_sync( body: NetSuiteGLSyncBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): """Manually pull NetSuite GL actuals (global admin). Refreshes the masters, then pulls the given period — or previous + current month when no period is supplied. Idempotent replace per period.""" if not can_manage_finance(user.role_names): raise ForbiddenError() from app.core.coordinators import finance_netsuite_coordinator as ns masters = await ns.sync_masters(db) if body.period: periods = [finance_service.validate_period(body.period)] else: from app.scheduled_jobs.finance_handlers import ( _current_period, _shift_period, ) cur = _current_period() periods = [_shift_period(cur, 1), cur] results = [await ns.pull_gl_actuals(db, p) for p in periods] await AuditService(db).log( trace_id=_trace_id(request), actor_type="user", actor_user_id=user.id, action="finance.netsuite.gl_sync", status="success", resource_type="finance.netsuite", resource_id=body.period or "prev+cur", detail={"masters": masters, "results": results}, ) await db.commit() return success_response( data={"masters": masters, "results": results}, request=request, ) @router.get("/netsuite/gl/summary") async def netsuite_gl_summary( request: Request, period: Annotated[str, Query()], user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Summarized NetSuite GL for a month (finance-view role).""" if not can_view_finance(user.role_names): raise ForbiddenError() period = finance_service.validate_period(period) return success_response( data=await read_service.netsuite_gl_summary(db, period), request=request, ) @router.get("/netsuite/sync-status") async def netsuite_sync_status( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Data-presence + freshness status of the NetSuite GL mirror.""" if not can_view_finance(user.role_names): raise ForbiddenError() return success_response( data=await read_service.netsuite_sync_status(db), request=request, ) @router.get("/netsuite/periods") async def netsuite_periods( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """NetSuite accounting-period master (closed flags), newest first.""" if not can_view_finance(user.role_names): raise ForbiddenError() return success_response( data=await read_service.netsuite_periods(db), request=request, ) @router.get("/netsuite/subsidiaries") async def netsuite_subsidiaries( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """NetSuite subsidiary master + Helix legal-entity mapping.""" if not can_view_finance(user.role_names): raise ForbiddenError() return success_response( data=await read_service.netsuite_subsidiaries(db), request=request, ) @router.patch("/netsuite/subsidiaries/{subsidiary_id}/legal-entity") async def map_netsuite_subsidiary( subsidiary_id: str, body: NetSuiteSubsidiaryMapBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): """Map a NetSuite subsidiary to a Helix legal entity (global admin). The mapping is preserved across master re-syncs.""" if not can_manage_finance(user.role_names): raise ForbiddenError() from app.domains.finance.models import FinanceNetSuiteSubsidiary row = (await db.execute( select(FinanceNetSuiteSubsidiary).where( FinanceNetSuiteSubsidiary.subsidiary_id == subsidiary_id, ) )).scalar_one_or_none() if row is None: raise NotFoundError("NetSuiteSubsidiary", subsidiary_id) row.legal_entity_id = body.legal_entity_id or None await AuditService(db).log( trace_id=_trace_id(request), actor_type="user", actor_user_id=user.id, action="finance.netsuite.map_subsidiary", status="success", resource_type="finance.netsuite.subsidiary", resource_id=subsidiary_id, detail={"legal_entity_id": row.legal_entity_id}, ) await db.commit() return success_response( data={"subsidiary_id": subsidiary_id, "legal_entity_id": row.legal_entity_id}, request=request, ) # ─── GL reconciliation + Controller close cockpit ─────────────────── @router.get("/netsuite/reconcile") async def netsuite_reconcile( request: Request, period: Annotated[str, Query()], user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Per-cost-type reconciliation of Helix bottom-up spend vs NetSuite booked GL + the unclassified-GL exception list (finance-view role).""" if not can_view_finance(user.role_names): raise ForbiddenError() period = finance_service.validate_period(period) return success_response( data=await read_service.gl_reconcile(db, period), request=request, ) @router.get("/close-cockpit") async def close_cockpit( request: Request, period: Annotated[str, Query()], user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Controller close cockpit: data completeness + GL sync/reconciliation + period close state + a go/no-go checklist (finance-view role).""" if not can_view_finance(user.role_names): raise ForbiddenError() period = finance_service.validate_period(period) return success_response( data=await read_service.close_cockpit(db, period), request=request, ) @router.patch("/netsuite/accounts/{account_id}/cost-type") async def map_netsuite_account_cost_type( account_id: str, body: NetSuiteAccountCostTypeBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): """Classify a GL account's Helix cost_type for reconciliation (finance-operations role). NULL resets it to the sync heuristic.""" if not can_operate_finance(user.role_names): raise ForbiddenError() from app.domains.finance.models import FinanceNetSuiteAccount row = (await db.execute( select(FinanceNetSuiteAccount).where( FinanceNetSuiteAccount.account_id == account_id, ) )).scalar_one_or_none() if row is None: raise NotFoundError("NetSuiteAccount", account_id) row.mapped_cost_type = body.mapped_cost_type await AuditService(db).log( trace_id=_trace_id(request), actor_type="user", actor_user_id=user.id, action="finance.netsuite.map_account_cost_type", status="success", resource_type="finance.netsuite.account", resource_id=account_id, detail={"mapped_cost_type": row.mapped_cost_type}, ) await db.commit() return success_response( data={"account_id": account_id, "mapped_cost_type": row.mapped_cost_type}, request=request, ) # ─── Expense intelligence + employee self-service ─────────────────── @router.get("/me/expenses") async def my_expenses( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """The caller's own reimbursement/expense documents + status summary. Self-scope: any authenticated employee (owner resolved by email on ingest). Shows empty gracefully until 汇联易 actuals are flowing.""" return success_response( data=await read_service.my_expenses(db, str(user.id)), request=request, ) @router.get("/governance/expense-analytics") async def expense_analytics( request: Request, period: Annotated[str, Query()], user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """T&E / expense intelligence for a month: by category / department / payment status + top spenders (finance-view role).""" if not can_view_finance(user.role_names): raise ForbiddenError() period = finance_service.validate_period(period) return success_response( data=await read_service.expense_analytics(db, period), request=request, ) @router.get("/leadership-dashboard") async def leadership_dashboard( request: Request, period: Annotated[str, Query()], user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Company-wide leadership snapshot for the CFO 大屏 (finance / mgmt / auditor / admin via can_view_finance).""" if not can_view_finance(user.role_names): raise ForbiddenError() period = finance_service.validate_period(period) return success_response( data=await read_service.leadership_dashboard( db, period, withhold_cloud=_withhold_cloud_for(user), ), request=request, ) @router.get("/vendors/top") async def top_vendors( request: Request, period: Annotated[str, Query()], user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Largest vendors for a month (Vendor 360 picker).""" if not can_view_finance(user.role_names): raise ForbiddenError() period = finance_service.validate_period(period) return success_response( data=await read_service.top_vendors( db, period, withhold_cloud=_withhold_cloud_for(user), ), request=request, ) @router.get("/vendor-360") async def vendor_360( request: Request, period: Annotated[str, Query()], vendor: Annotated[str, Query(min_length=1)], user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """One vendor's cross-channel spend (by cost_type / department / trend + concentration) — the unified Vendor 360 (finance-view role).""" if not can_view_finance(user.role_names): raise ForbiddenError() period = finance_service.validate_period(period) return success_response( data=await read_service.vendor_360( db, period, vendor, withhold_cloud=_withhold_cloud_for(user), ), request=request, ) @router.get("/me/attributed-cost") async def my_attributed_cost( request: Request, period: Annotated[str, Query()], user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """The caller's own cost to the company for a month (unified ledger sliced to owner = me + their expenses) + a 12-month trend. Self-scope: any authenticated employee, no finance role.""" period = finance_service.validate_period(period) return success_response( data=await read_service.my_attributed_cost( db, str(user.id), period, withhold_cloud=_withhold_cloud_for(user), ), request=request, ) @router.get("/me/bu-costs") async def my_bu_costs( request: Request, fiscal_year: Annotated[int, Query(ge=2000, le=2100)], user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """The BU the caller leads/belongs to, scoped to just that BU (self-service slice of /bu-costs). Empty when the caller maps to no BU. Self-scope: any authenticated employee, no finance role.""" return success_response( data=await read_service.my_bu_costs( db, str(user.id), fiscal_year, withhold_cloud=_withhold_cloud_for(user), ), request=request, )