"""FastAPI router for the procurement domain. Mounted at ``/api/v1/procurement``. Endpoints are grouped by resource: * ``/categories`` — procurement_categories * ``/suppliers`` — procurement_suppliers (v2; ``asset_suppliers`` remains for legacy read-paths through PR 1) * ``/contracts`` — procurement_contracts (+ attachments, versions) * ``/orders`` — procurement_orders (+ items) * ``/payments`` — procurement_payments * ``/software//meta`` — software_procurement_meta sidecar Authorization: all writes require ``it_admin`` (global_admin role). Reads use ``require_software_read`` — the documented /help permission matrix row-3 gate (软件 / 许可 / 采购 / 供应商): global_admin + asset_admin + finance + auditor + management. This restores the read parity the legacy ``asset_*`` procurement endpoints had (they used ``require_software_read``); the v2 rework had narrowed it to ``require_admin_read`` and locked finance + asset_admin out of their own pages. ``/budgets/me`` is self-scoped instead (any authenticated user, filtered to ``owner_person_id == current.id``). Phase 2 may tighten with a dedicated ``procurement_admin`` role; not yet introduced. Audit: every write logs an ``audit_logs`` row via :class:`AuditService` with the actor + the resource + the operation. Sensitive fields (bank_account / SWIFT) appear in the payload — the audit middleware already masks them. """ from __future__ import annotations import uuid from datetime import date from typing import Annotated from fastapi import APIRouter, Depends, Header, Query, Request 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, require_admin, require_software_read, ) from app.core.dependencies import ( require_active_employee_for_mutations as _leaver_runtime_gate, ) from app.core.exceptions import ForbiddenError, NotFoundError from app.core.masking import mask_head_tail, mask_tail from app.core.rbac import ( FINANCE_ROLE, GLOBAL_ADMIN_ROLE, can_view_finance, has_role, ) from app.core.response import paginated_response, success_response from app.domains.procurement import service from app.domains.procurement.models import ( ProcurementCategory, ProcurementContract, ProcurementOrder, ProcurementSupplier, ) from app.domains.procurement.schemas import ( CategoryCreate, CategoryRead, CategoryUpdate, ContractAttachmentRead, ContractCreate, ContractRead, ContractUpdate, ContractVersionRead, OrderCreate, OrderItemCreate, OrderItemRead, OrderItemUpdate, OrderRead, OrderUpdate, PaymentCreate, PaymentRead, PaymentUpdate, SoftwareProcurementMetaRead, SoftwareProcurementMetaUpdate, SupplierCreate, SupplierRead, SupplierUpdate, ) router = APIRouter( prefix="/api/v1/procurement", tags=["procurement"], dependencies=[Depends(_leaver_runtime_gate)], ) async def _log_audit( db: AsyncSession, *, request: Request, current: AuthUser, action: str, resource_type: str, resource_id: str, details: dict | None = None, ) -> None: """Thin wrapper around AuditService.log with the boilerplate (trace_id resolution, actor_type fill-in) folded in.""" trace_id = getattr(request.state, "trace_id", str(uuid.uuid4())) audit = AuditService(db) await audit.log( trace_id=trace_id, actor_type="user", actor_user_id=current.id, action=action, status="success", resource_type=resource_type, resource_id=resource_id, detail=details, ip_address=getattr(request.client, "host", None) if request.client else None, user_agent=request.headers.get("user-agent"), ) # ────────────────────────────────────────────────────────────────────── # Categories # ────────────────────────────────────────────────────────────────────── @router.get("/categories", dependencies=[Depends(require_software_read())]) async def list_categories_route( db: Annotated[AsyncSession, Depends(get_db)], include_inactive: bool = Query(False), ): rows = await service.list_categories(db, include_inactive=include_inactive) person_map = await service._person_name_map( db, [r.default_owner_person_id for r in rows] ) out = [] for r in rows: item = CategoryRead.model_validate(r).model_dump(mode="json") item["default_owner_name"] = person_map.get(r.default_owner_person_id) out.append(item) return success_response(out) @router.post("/categories", dependencies=[Depends(require_admin())]) async def create_category_route( payload: CategoryCreate, db: Annotated[AsyncSession, Depends(get_db)], current: Annotated[AuthUser, Depends(get_current_user)], request: Request, ): obj = await service.create_category(db, payload, actor=current) await _log_audit( db, request=request, current=current, action="procurement.category.create", resource_type="procurement_category", resource_id=str(obj.id), details={"code": obj.code, "name_zh": obj.name_zh}, ) await db.commit() return success_response(CategoryRead.model_validate(obj).model_dump(mode="json")) @router.patch("/categories/{category_id}", dependencies=[Depends(require_admin())]) async def update_category_route( category_id: uuid.UUID, payload: CategoryUpdate, db: Annotated[AsyncSession, Depends(get_db)], current: Annotated[AuthUser, Depends(get_current_user)], request: Request, ): obj = await service.update_category(db, category_id, payload, actor=current) await _log_audit( db, request=request, current=current, action="procurement.category.update", resource_type="procurement_category", resource_id=str(obj.id), details=payload.model_dump(exclude_unset=True, mode="json"), ) await db.commit() return success_response(CategoryRead.model_validate(obj).model_dump(mode="json")) @router.delete("/categories/{category_id}", dependencies=[Depends(require_admin())]) async def delete_category_route( category_id: uuid.UUID, db: Annotated[AsyncSession, Depends(get_db)], current: Annotated[AuthUser, Depends(get_current_user)], request: Request, ): await service.delete_category(db, category_id, actor=current) await _log_audit( db, request=request, current=current, action="procurement.category.delete", resource_type="procurement_category", resource_id=str(category_id), ) await db.commit() return success_response({"deleted": True}) # ────────────────────────────────────────────────────────────────────── # Suppliers # ────────────────────────────────────────────────────────────────────── # Vendor 银行账号 / SWIFT / 税号 are sensitive enough to mask by default for # the broad ``require_software_read`` audience (global_admin + asset_admin + # finance + auditor + management). Only finance / global admin read plaintext # inline (they own AP / banking); everyone else gets masked values + an # audited, rate-limited reveal — mirroring the legal-entity 开票信息 pattern # (asset/router.py ``_legal_entity_response`` / ``reveal_legal_entity``). _SUPPLIER_SENSITIVE_FIELDS = ("tax_id", "bank_account", "swift_code") def _can_read_supplier_plaintext(role_names) -> bool: """Plaintext readers of vendor banking/tax data: finance (AP owns the bank details) + global admin (who create/edit the rows, so the edit form hydrates with real values). Deliberately NARROWER than ``can_view_finance`` — that also admits auditor + management, who have a read-only governance view but no operational need for cleartext bank account numbers, so they get the masked-default + audited-reveal treatment like every other reader in the ``require_software_read`` audience.""" return has_role(role_names, GLOBAL_ADMIN_ROLE, FINANCE_ROLE) def _mask_supplier_item(item: dict) -> dict: """Mask the sensitive banking/tax fields on a serialized supplier dict in place and flag ``sensitive_masked=True``.""" item["tax_id"] = mask_tail(item.get("tax_id")) item["bank_account"] = mask_tail(item.get("bank_account")) item["swift_code"] = mask_head_tail(item.get("swift_code")) item["sensitive_masked"] = True return item @router.get("/suppliers") async def list_suppliers_route( db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[AuthUser, Depends(require_software_read())], keyword: str | None = Query(None, max_length=128), status: str | None = Query(None, max_length=16), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), ): mask = not _can_read_supplier_plaintext(current_user.role_names) offset = (page - 1) * page_size rows, total = await service.list_suppliers( db, keyword=keyword, status=status, limit=page_size, offset=offset, ) counts_by_id = await service.supplier_derived_counts( db, [row.id for row in rows], ) out = [] for r in rows: counts = counts_by_id[r.id] item = SupplierRead.model_validate(r).model_dump(mode="json") item["contract_count"] = counts[0] item["active_contract_count"] = counts[1] item["po_count"] = counts[2] if mask: _mask_supplier_item(item) out.append(item) return paginated_response(out, page=page, page_size=page_size, total=total) @router.get("/suppliers/{supplier_id}") async def get_supplier_route( supplier_id: uuid.UUID, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[AuthUser, Depends(require_software_read())], ): obj = await service.get_supplier(db, supplier_id) counts = await service._supplier_derived_counts(db, obj.id) item = SupplierRead.model_validate(obj).model_dump(mode="json") item["contract_count"] = counts[0] item["active_contract_count"] = counts[1] item["po_count"] = counts[2] if not _can_read_supplier_plaintext(current_user.role_names): _mask_supplier_item(item) return success_response(item) @router.post("/suppliers/{supplier_id}/reveal") async def reveal_supplier_route( supplier_id: uuid.UUID, request: Request, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[AuthUser, Depends(require_software_read())], _rate: Annotated[None, Depends(check_sensitive_endpoint_rate_limit)] = None, ): """Return the unmasked sensitive 银行/税号 fields for one supplier. Open to the same ``require_software_read`` audience as the list/detail reads, but every call writes a ``procurement.supplier.sensitive_reveal`` audit row naming who disclosed it, and the per-user sensitive rate limit caps any single account at ``SENSITIVE_RATE_LIMIT_MAX`` reveals/hour — defeating a tight-loop scrape of every vendor's banking/tax plaintext. Mirrors ``reveal_legal_entity``. """ # The route-level ``require_software_read`` audience (asset_admin / # auditor / management) is broader than who may see vendor banking/tax # PLAINTEXT — the list/detail mask gate restricts cleartext to # global_admin + finance. Enforce the SAME gate here so a region-scoped # asset_admin (no AP remit) can't scrape every supplier's bank account / # tax id / SWIFT through the reveal endpoint. Matches the inline # ``_can_read_supplier_plaintext`` used on the read paths above. if not _can_read_supplier_plaintext(current_user.role_names): raise ForbiddenError() # This POST writes a disclosure audit row and returns bank/tax plaintext. # Revalidate under the same actor-row lock as the service mutation # primitives so an offboarding commit cannot race the reveal (#2424). await service.lock_active_actor_for_mutation(db, actor=current_user) obj = await service.get_supplier(db, supplier_id) await AuditService(db).log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="procurement.supplier.sensitive_reveal", status="success", resource_type="procurement_supplier", resource_id=str(obj.id), detail={"revealed_fields": list(_SUPPLIER_SENSITIVE_FIELDS)}, ) await db.commit() return success_response( { "id": str(obj.id), "tax_id": obj.tax_id, "bank_account": obj.bank_account, "swift_code": obj.swift_code, } ) @router.post("/suppliers", dependencies=[Depends(require_admin())]) async def create_supplier_route( payload: SupplierCreate, db: Annotated[AsyncSession, Depends(get_db)], current: Annotated[AuthUser, Depends(get_current_user)], request: Request, idempotency_key: Annotated[ str | None, Header(alias="Idempotency-Key", max_length=128) ] = None, ): obj, replayed = await service.create_supplier_idempotent( db, payload, actor_id=uuid.UUID(str(current.id)), actor=current, idempotency_key=idempotency_key, ) if not replayed: await _log_audit( db, request=request, current=current, action="procurement.supplier.create", resource_type="procurement_supplier", resource_id=str(obj.id), details={"name": obj.name, "supplier_code": obj.supplier_code}, ) await db.commit() return success_response(SupplierRead.model_validate(obj).model_dump(mode="json")) @router.patch("/suppliers/{supplier_id}", dependencies=[Depends(require_admin())]) async def update_supplier_route( supplier_id: uuid.UUID, payload: SupplierUpdate, db: Annotated[AsyncSession, Depends(get_db)], current: Annotated[AuthUser, Depends(get_current_user)], request: Request, ): obj = await service.update_supplier(db, supplier_id, payload, actor=current) await _log_audit( db, request=request, current=current, action="procurement.supplier.update", resource_type="procurement_supplier", resource_id=str(obj.id), details=payload.model_dump(exclude_unset=True, mode="json"), ) await db.commit() return success_response(SupplierRead.model_validate(obj).model_dump(mode="json")) # ────────────────────────────────────────────────────────────────────── # Contracts # ────────────────────────────────────────────────────────────────────── def _decorate_contract( obj: ProcurementContract, *, supplier_name: str | None, legal_entity_name: str | None, category_name: str | None, person_map: dict, attachment_count: int, po_count: int, ) -> dict: item = ContractRead.model_validate(obj).model_dump(mode="json") item["supplier_name"] = supplier_name item["legal_entity_name"] = legal_entity_name item["category_name"] = category_name item["business_owner_name"] = person_map.get(obj.business_owner_id) item["it_contact_name"] = person_map.get(obj.it_contact_id) item["finance_contact_name"] = person_map.get(obj.finance_contact_id) item["procurement_owner_name"] = person_map.get(obj.procurement_owner_id) if obj.expiry_date: delta = (obj.expiry_date - date.today()).days item["days_until_expiry"] = delta else: item["days_until_expiry"] = None item["attachment_count"] = attachment_count item["po_count"] = po_count return item @router.get("/contracts", dependencies=[Depends(require_software_read())]) async def list_contracts_route( db: Annotated[AsyncSession, Depends(get_db)], keyword: str | None = Query(None, max_length=128), supplier_id: uuid.UUID | None = Query(None), status: str | None = Query(None, max_length=16), category_id: uuid.UUID | None = Query(None), expiring_within_days: int | None = Query(None, ge=0, le=3650), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), ): offset = (page - 1) * page_size rows, total = await service.list_contracts( db, keyword=keyword, supplier_id=supplier_id, status=status, category_id=category_id, expiring_within_days=expiring_within_days, limit=page_size, offset=offset, ) # Bulk-resolve display joins to avoid N+1. from sqlalchemy import select as _sa_select # noqa: PLC0415 from app.domains.asset.models import ( # noqa: PLC0415 AssetLegalEntity, ) supplier_ids = {str(r.supplier_id) for r in rows if r.supplier_id} legal_ids = {str(r.legal_entity_id) for r in rows if r.legal_entity_id} cat_ids = {str(r.category_id) for r in rows if r.category_id} supplier_names: dict = {} if supplier_ids: result = await db.execute( _sa_select(ProcurementSupplier.id, ProcurementSupplier.name) .where(ProcurementSupplier.id.in_(supplier_ids)) ) supplier_names = {uuid.UUID(str(r[0])): r[1] for r in result} legal_names: dict = {} if legal_ids: result = await db.execute( _sa_select(AssetLegalEntity.id, AssetLegalEntity.name) .where(AssetLegalEntity.id.in_(legal_ids)) ) legal_names = {uuid.UUID(str(r[0])): r[1] for r in result} cat_names: dict = {} if cat_ids: result = await db.execute( _sa_select(ProcurementCategory.id, ProcurementCategory.name_zh) .where(ProcurementCategory.id.in_(cat_ids)) ) cat_names = {uuid.UUID(str(r[0])): r[1] for r in result} person_map = await service._person_name_map( db, [r.business_owner_id for r in rows] + [r.it_contact_id for r in rows] + [r.finance_contact_id for r in rows] + [r.procurement_owner_id for r in rows], ) counts_by_id = await service.contract_derived_counts( db, [row.id for row in rows], ) out = [] for r in rows: attachment_count, po_count = counts_by_id[r.id] out.append(_decorate_contract( r, supplier_name=supplier_names.get(r.supplier_id), legal_entity_name=legal_names.get(r.legal_entity_id) if r.legal_entity_id else None, category_name=cat_names.get(r.category_id) if r.category_id else None, person_map=person_map, attachment_count=int(attachment_count), po_count=int(po_count), )) return paginated_response(out, page=page, page_size=page_size, total=total) @router.get("/contracts/{contract_id}", dependencies=[Depends(require_software_read())]) async def get_contract_route( contract_id: uuid.UUID, db: Annotated[AsyncSession, Depends(get_db)], ): obj = await service.get_contract(db, contract_id) attachments = await service.list_contract_attachments(db, contract_id) versions = await service.list_contract_versions(db, contract_id) # Display-name joins. from sqlalchemy import select as _sa_select # noqa: PLC0415 from app.domains.asset.models import AssetLegalEntity # noqa: PLC0415 supplier_name = None if obj.supplier_id: supplier_name = await db.scalar( _sa_select(ProcurementSupplier.name).where( ProcurementSupplier.id == str(obj.supplier_id) ) ) legal_entity_name = None if obj.legal_entity_id: legal_entity_name = await db.scalar( _sa_select(AssetLegalEntity.name).where( AssetLegalEntity.id == str(obj.legal_entity_id) ) ) category_name = None if obj.category_id: category_name = await db.scalar( _sa_select(ProcurementCategory.name_zh).where( ProcurementCategory.id == str(obj.category_id) ) ) person_map = await service._person_name_map( db, [obj.business_owner_id, obj.it_contact_id, obj.finance_contact_id, obj.procurement_owner_id] + [v.changed_by_id for v in versions], ) payload = _decorate_contract( obj, supplier_name=supplier_name, legal_entity_name=legal_entity_name, category_name=category_name, person_map=person_map, attachment_count=len(attachments), po_count=0, # filled below ) from sqlalchemy import func as _sa_func # noqa: PLC0415 payload["po_count"] = ( await db.scalar( _sa_select(_sa_func.count(ProcurementOrder.id)) .where(ProcurementOrder.contract_id == str(obj.id)) ) ) or 0 payload["attachments"] = [ ContractAttachmentRead.model_validate(a).model_dump(mode="json") for a in attachments ] payload["versions"] = [ { **ContractVersionRead.model_validate(v).model_dump(mode="json"), "changed_by_name": person_map.get(v.changed_by_id), } for v in versions ] return success_response(payload) @router.post("/contracts", dependencies=[Depends(require_admin())]) async def create_contract_route( payload: ContractCreate, db: Annotated[AsyncSession, Depends(get_db)], current: Annotated[AuthUser, Depends(get_current_user)], request: Request, idempotency_key: Annotated[ str | None, Header(alias="Idempotency-Key", max_length=128) ] = None, ): obj, replayed = await service.create_contract_idempotent( db, payload, actor_id=uuid.UUID(str(current.id)), actor=current, idempotency_key=idempotency_key, ) if not replayed: await _log_audit( db, request=request, current=current, action="procurement.contract.create", resource_type="procurement_contract", resource_id=str(obj.id), details={"contract_number": obj.contract_number, "title": obj.title}, ) await db.commit() return success_response(ContractRead.model_validate(obj).model_dump(mode="json")) @router.patch("/contracts/{contract_id}", dependencies=[Depends(require_admin())]) async def update_contract_route( contract_id: uuid.UUID, payload: ContractUpdate, db: Annotated[AsyncSession, Depends(get_db)], current: Annotated[AuthUser, Depends(get_current_user)], request: Request, ): # AuthUser is now an alias for IdentityPerson — current.id IS the person id. actor_pid = uuid.UUID(str(current.id)) if current.id else None obj = await service.update_contract( db, contract_id, payload, actor_person_id=actor_pid, actor=current, ) await _log_audit( db, request=request, current=current, action="procurement.contract.update", resource_type="procurement_contract", resource_id=str(obj.id), details=payload.model_dump(exclude_unset=True, mode="json"), ) await db.commit() return success_response(ContractRead.model_validate(obj).model_dump(mode="json")) # ────────────────────────────────────────────────────────────────────── # Purchase orders # ────────────────────────────────────────────────────────────────────── def _decorate_order( obj: ProcurementOrder, *, supplier_name: str | None, contract_number: str | None, category_name: str | None, person_map: dict, item_count: int, ) -> dict: item = OrderRead.model_validate(obj).model_dump(mode="json") item["supplier_name"] = supplier_name item["contract_number"] = contract_number item["category_name"] = category_name item["applicant_name"] = person_map.get(obj.applicant_id) item["business_owner_name"] = person_map.get(obj.business_owner_id) item["procurement_owner_name"] = person_map.get(obj.procurement_owner_id) item["item_count"] = item_count return item async def _decorated_order_rows( db: AsyncSession, rows: list[ProcurementOrder], ) -> list[dict]: """Enrich raw order rows with display names for list + export. Shared by :func:`list_orders_route` and :func:`export_orders_route` so the two surfaces render identical rows and cannot drift — same convention as the unified export primitive's "reuse the list query path" rule (``app/core/exporting.py``). """ from sqlalchemy import select as _sa_select supplier_ids = {str(r.supplier_id) for r in rows if r.supplier_id} contract_ids = {str(r.contract_id) for r in rows if r.contract_id} cat_ids = {str(r.category_id) for r in rows if r.category_id} supplier_names: dict = {} if supplier_ids: result = await db.execute( _sa_select(ProcurementSupplier.id, ProcurementSupplier.name) .where(ProcurementSupplier.id.in_(supplier_ids)) ) supplier_names = {uuid.UUID(str(r[0])): r[1] for r in result} contract_numbers: dict = {} if contract_ids: result = await db.execute( _sa_select(ProcurementContract.id, ProcurementContract.contract_number) .where(ProcurementContract.id.in_(contract_ids)) ) contract_numbers = {uuid.UUID(str(r[0])): r[1] for r in result} cat_names: dict = {} if cat_ids: result = await db.execute( _sa_select(ProcurementCategory.id, ProcurementCategory.name_zh) .where(ProcurementCategory.id.in_(cat_ids)) ) cat_names = {uuid.UUID(str(r[0])): r[1] for r in result} person_map = await service._person_name_map( db, [r.applicant_id for r in rows] + [r.business_owner_id for r in rows] + [r.procurement_owner_id for r in rows], ) item_counts = await service.order_item_counts( db, [row.id for row in rows], ) out = [] for r in rows: out.append(_decorate_order( r, supplier_name=supplier_names.get(r.supplier_id) if r.supplier_id else None, contract_number=contract_numbers.get(r.contract_id) if r.contract_id else None, category_name=cat_names.get(r.category_id) if r.category_id else None, person_map=person_map, item_count=item_counts[r.id], )) return out @router.get("/orders", dependencies=[Depends(require_software_read())]) async def list_orders_route( db: Annotated[AsyncSession, Depends(get_db)], keyword: str | None = Query(None, max_length=128), supplier_id: uuid.UUID | None = Query(None), contract_id: uuid.UUID | None = Query(None), category_id: uuid.UUID | None = Query(None), status: str | None = Query(None, max_length=16), order_type: str | None = Query(None, max_length=16), applicant_id: uuid.UUID | None = Query(None), start_date: date | None = Query(None), end_date: date | None = Query(None), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), ): offset = (page - 1) * page_size rows, total = await service.list_orders( db, keyword=keyword, supplier_id=supplier_id, contract_id=contract_id, category_id=category_id, status=status, order_type=order_type, applicant_id=applicant_id, start_date=start_date, end_date=end_date, limit=page_size, offset=offset, ) out = await _decorated_order_rows(db, rows) return paginated_response(out, page=page, page_size=page_size, total=total) @router.get("/orders/export.xlsx") async def export_orders_route( request: Request, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[AuthUser, Depends(require_software_read())], keyword: str | None = Query(None, max_length=128), supplier_id: uuid.UUID | None = Query(None), contract_id: uuid.UUID | None = Query(None), category_id: uuid.UUID | None = Query(None), status: str | None = Query(None, max_length=16), order_type: str | None = Query(None, max_length=16), applicant_id: uuid.UUID | None = Query(None), start_date: date | None = Query(None), end_date: date | None = Query(None), ): """Export the filtered purchase-order list as XLSX. Filter signature mirrors :func:`list_orders_route` minus pagination — the UI passes the exact query params the orders page is showing, so the download matches the screen. RBAC mirrors the list endpoint (``require_software_read``); the gate runs as a parameter dependency here because the export audit row needs the actor. Reuses ``service.list_orders`` + :func:`_decorated_order_rows` so any filter / enrichment fix on the list applies here automatically. Capped at :data:`app.core.exporting.EXPORT_ROW_LIMIT` rows. """ from app.core.exporting import ( # noqa: PLC0415 EXPORT_ROW_LIMIT, xlsx_export_response, ) rows, total = await service.list_orders( db, keyword=keyword, supplier_id=supplier_id, contract_id=contract_id, category_id=category_id, status=status, order_type=order_type, applicant_id=applicant_id, start_date=start_date, end_date=end_date, limit=EXPORT_ROW_LIMIT, offset=0, ) out = await _decorated_order_rows(db, rows) columns = [ ("order_number", "订单号"), ("status", "状态"), ("order_type", "订单类型"), ("supplier_name", "供应商"), ("contract_number", "合同号"), ("category_name", "分类"), ("applicant_name", "申请人"), ("business_owner_name", "业务负责人"), ("procurement_owner_name", "采购负责人"), ("currency", "币种"), ("total_amount", "总金额"), ("notes", "描述"), ("created_at", "创建时间"), ("id", "ID"), ] return await xlsx_export_response( db, request, current_user, columns=columns, rows=out, total_matched=total, filename_stem="procurement_orders", sheet_title="Procurement Orders", audit_action="procurement.orders.export", audit_resource_type="procurement_order", filters={ "keyword": keyword, "supplier_id": supplier_id, "contract_id": contract_id, "category_id": category_id, "status": status, "order_type": order_type, "applicant_id": applicant_id, "start_date": start_date, "end_date": end_date, }, ) @router.get("/orders/{order_id}", dependencies=[Depends(require_software_read())]) async def get_order_route( order_id: uuid.UUID, db: Annotated[AsyncSession, Depends(get_db)], ): from sqlalchemy import select as _sa_select obj = await service.get_order(db, order_id) items = await service.list_order_items(db, order_id) supplier_name = None contract_number = None category_name = None if obj.supplier_id: supplier_name = await db.scalar( _sa_select(ProcurementSupplier.name).where( ProcurementSupplier.id == str(obj.supplier_id) ) ) if obj.contract_id: contract_number = await db.scalar( _sa_select(ProcurementContract.contract_number).where( ProcurementContract.id == str(obj.contract_id) ) ) if obj.category_id: category_name = await db.scalar( _sa_select(ProcurementCategory.name_zh).where( ProcurementCategory.id == str(obj.category_id) ) ) person_map = await service._person_name_map( db, [obj.applicant_id, obj.business_owner_id, obj.it_contact_id, obj.finance_contact_id, obj.procurement_owner_id, obj.approved_by_id], ) payload = _decorate_order( obj, supplier_name=supplier_name, contract_number=contract_number, category_name=category_name, person_map=person_map, item_count=len(items), ) payload["items"] = [ OrderItemRead.model_validate(i).model_dump(mode="json") for i in items ] return success_response(payload) @router.post("/orders", dependencies=[Depends(require_admin())]) async def create_order_route( payload: OrderCreate, db: Annotated[AsyncSession, Depends(get_db)], current: Annotated[AuthUser, Depends(get_current_user)], request: Request, idempotency_key: Annotated[ str | None, Header(alias="Idempotency-Key", max_length=128) ] = None, ): applicant_id = payload.applicant_id or ( uuid.UUID(str(current.id)) if current.id else None ) if applicant_id is None: from app.core.exceptions import ValidationError as _VE # noqa: PLC0415 raise _VE("applicant_id is required (or caller must have a person_id)") obj, replayed = await service.create_order_idempotent( db, payload, applicant_id=applicant_id, actor_id=uuid.UUID(str(current.id)), actor=current, idempotency_key=idempotency_key, ) if not replayed: await _log_audit( db, request=request, current=current, action="procurement.order.create", resource_type="procurement_order", resource_id=str(obj.id), details={ "order_number": obj.order_number, "order_type": obj.order_type, "total_amount": ( str(obj.total_amount) if obj.total_amount is not None else None ), "currency": obj.currency, }, ) await db.commit() return success_response(OrderRead.model_validate(obj).model_dump(mode="json")) @router.patch("/orders/{order_id}", dependencies=[Depends(require_admin())]) async def update_order_route( order_id: uuid.UUID, payload: OrderUpdate, db: Annotated[AsyncSession, Depends(get_db)], current: Annotated[AuthUser, Depends(get_current_user)], request: Request, ): obj = await service.update_order(db, order_id, payload, actor=current) await _log_audit( db, request=request, current=current, action="procurement.order.update", resource_type="procurement_order", resource_id=str(obj.id), details=payload.model_dump(exclude_unset=True, mode="json"), ) await db.commit() return success_response(OrderRead.model_validate(obj).model_dump(mode="json")) @router.post("/orders/{order_id}/items", dependencies=[Depends(require_admin())]) async def add_order_item_route( order_id: uuid.UUID, payload: OrderItemCreate, db: Annotated[AsyncSession, Depends(get_db)], current: Annotated[AuthUser, Depends(get_current_user)], request: Request, ): line = await service.add_order_item(db, order_id, payload, actor=current) await _log_audit( db, request=request, current=current, action="procurement.order.item.create", resource_type="procurement_order_item", resource_id=str(line.id), details={"order_id": str(order_id), "line_no": line.line_no}, ) await db.commit() return success_response(OrderItemRead.model_validate(line).model_dump(mode="json")) @router.patch( "/orders/{order_id}/items/{item_id}", dependencies=[Depends(require_admin())], ) async def update_order_item_route( order_id: uuid.UUID, item_id: uuid.UUID, payload: OrderItemUpdate, db: Annotated[AsyncSession, Depends(get_db)], current: Annotated[AuthUser, Depends(get_current_user)], request: Request, ): line = await service.update_order_item(db, item_id, payload, actor=current) await _log_audit( db, request=request, current=current, action="procurement.order.item.update", resource_type="procurement_order_item", resource_id=str(line.id), details=payload.model_dump(exclude_unset=True, mode="json"), ) await db.commit() return success_response(OrderItemRead.model_validate(line).model_dump(mode="json")) @router.delete( "/orders/{order_id}/items/{item_id}", dependencies=[Depends(require_admin())], ) async def delete_order_item_route( order_id: uuid.UUID, item_id: uuid.UUID, db: Annotated[AsyncSession, Depends(get_db)], current: Annotated[AuthUser, Depends(get_current_user)], request: Request, ): snapshot = await service.delete_order_item(db, item_id, actor=current) await _log_audit( db, request=request, current=current, action="procurement.order.item.delete", resource_type="procurement_order_item", resource_id=str(item_id), # Order items have no version table — the deleted line's full # content (incl. receipt history) lives only in this audit row. details=snapshot, ) await db.commit() return success_response({"deleted": True}) # ────────────────────────────────────────────────────────────────────── # Payments # ────────────────────────────────────────────────────────────────────── @router.get("/payments", dependencies=[Depends(require_software_read())]) async def list_payments_route( db: Annotated[AsyncSession, Depends(get_db)], keyword: str | None = Query(None, max_length=128), supplier_id: uuid.UUID | None = Query(None), order_id: uuid.UUID | None = Query(None), software_catalog_id: uuid.UUID | None = Query(None), status: str | None = Query(None, max_length=16), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), ): offset = (page - 1) * page_size rows, total = await service.list_payments( db, keyword=keyword, supplier_id=supplier_id, order_id=order_id, software_catalog_id=software_catalog_id, status=status, limit=page_size, offset=offset, ) from sqlalchemy import select as _sa_select # noqa: PLC0415 from app.domains.asset.models import ( # noqa: PLC0415 AssetLegalEntity, AssetSoftwareCatalog, ) supplier_ids = {str(r.supplier_id) for r in rows if r.supplier_id} legal_ids = {str(r.legal_entity_id) for r in rows if r.legal_entity_id} order_ids = {str(r.order_id) for r in rows if r.order_id} software_ids = {str(r.software_catalog_id) for r in rows if r.software_catalog_id} supplier_names: dict = {} if supplier_ids: result = await db.execute( _sa_select(ProcurementSupplier.id, ProcurementSupplier.name) .where(ProcurementSupplier.id.in_(supplier_ids)) ) supplier_names = {uuid.UUID(str(r[0])): r[1] for r in result} legal_names: dict = {} if legal_ids: result = await db.execute( _sa_select(AssetLegalEntity.id, AssetLegalEntity.name) .where(AssetLegalEntity.id.in_(legal_ids)) ) legal_names = {uuid.UUID(str(r[0])): r[1] for r in result} order_numbers: dict = {} if order_ids: result = await db.execute( _sa_select(ProcurementOrder.id, ProcurementOrder.order_number) .where(ProcurementOrder.id.in_(order_ids)) ) order_numbers = {uuid.UUID(str(r[0])): r[1] for r in result} software_names: dict = {} if software_ids: result = await db.execute( _sa_select(AssetSoftwareCatalog.id, AssetSoftwareCatalog.name) .where(AssetSoftwareCatalog.id.in_(software_ids)) ) software_names = {uuid.UUID(str(r[0])): r[1] for r in result} out = [] for r in rows: item = PaymentRead.model_validate(r).model_dump(mode="json") item["supplier_name"] = supplier_names.get(r.supplier_id) if r.supplier_id else None item["legal_entity_name"] = legal_names.get(r.legal_entity_id) if r.legal_entity_id else None item["order_number"] = order_numbers.get(r.order_id) if r.order_id else None item["software_name"] = software_names.get(r.software_catalog_id) if r.software_catalog_id else None out.append(item) return paginated_response(out, page=page, page_size=page_size, total=total) @router.post("/payments", dependencies=[Depends(require_admin())]) async def create_payment_route( payload: PaymentCreate, db: Annotated[AsyncSession, Depends(get_db)], current: Annotated[AuthUser, Depends(get_current_user)], request: Request, idempotency_key: Annotated[ str | None, Header(alias="Idempotency-Key", max_length=128) ] = None, ): async def _audit_new_payment(obj): await _log_audit( db, request=request, current=current, action="procurement.payment.create", resource_type="procurement_payment", resource_id=str(obj.id), details={"payment_number": obj.payment_number, "amount": str(obj.amount) if obj.amount is not None else None, "currency": obj.currency, "invoice_number": obj.invoice_number, "input_values": { "amount": ( str(payload.amount) if payload.amount is not None else None ), "currency": payload.currency, "invoice_number": payload.invoice_number, "payment_number": payload.payment_number, }, "input_was_normalized": { "amount": payload.amount != obj.amount, "currency": payload.currency != obj.currency, "invoice_number": payload.invoice_number != obj.invoice_number, "payment_number": ( payload.payment_number is not None and payload.payment_number != obj.payment_number ), }}, ) obj, _replayed = await service.create_payment_idempotent( db, payload, actor_id=uuid.UUID(str(current.id)), actor=current, idempotency_key=idempotency_key, before_commit=_audit_new_payment, ) return success_response(PaymentRead.model_validate(obj).model_dump(mode="json")) @router.patch("/payments/{payment_id}", dependencies=[Depends(require_admin())]) async def update_payment_route( payment_id: uuid.UUID, payload: PaymentUpdate, db: Annotated[AsyncSession, Depends(get_db)], current: Annotated[AuthUser, Depends(get_current_user)], request: Request, ): obj = await service.update_payment(db, payment_id, payload, actor=current) await _log_audit( db, request=request, current=current, action="procurement.payment.update", resource_type="procurement_payment", resource_id=str(obj.id), details=payload.model_dump(exclude_unset=True, mode="json"), ) await db.commit() return success_response(PaymentRead.model_validate(obj).model_dump(mode="json")) # ────────────────────────────────────────────────────────────────────── # Software procurement meta sidecar # ────────────────────────────────────────────────────────────────────── @router.get( "/software/{software_id}/meta", dependencies=[Depends(require_software_read())], ) async def get_software_meta_route( software_id: uuid.UUID, db: Annotated[AsyncSession, Depends(get_db)], ): obj = await service.get_software_meta(db, software_id) payload = SoftwareProcurementMetaRead.model_validate(obj).model_dump(mode="json") # Display joins. from sqlalchemy import select as _sa_select # noqa: PLC0415 if obj.current_contract_id: row = (await db.execute( _sa_select( ProcurementContract.contract_number, ProcurementContract.title, ).where(ProcurementContract.id == str(obj.current_contract_id)) )).first() if row: payload["current_contract_number"] = row[0] payload["current_contract_title"] = row[1] if obj.current_supplier_id: sup = await db.scalar( _sa_select(ProcurementSupplier.name).where( ProcurementSupplier.id == str(obj.current_supplier_id) ) ) payload["current_supplier_name"] = sup person_map = await service._person_name_map( db, [obj.business_owner_id], ) payload["business_owner_name"] = person_map.get(obj.business_owner_id) return success_response(payload) @router.patch( "/software/{software_id}/meta", dependencies=[Depends(require_admin())], ) async def update_software_meta_route( software_id: uuid.UUID, payload: SoftwareProcurementMetaUpdate, db: Annotated[AsyncSession, Depends(get_db)], current: Annotated[AuthUser, Depends(get_current_user)], request: Request, ): obj = await service.update_software_meta( db, software_id, payload, actor=current, ) await _log_audit( db, request=request, current=current, action="procurement.software_meta.update", resource_type="software_procurement_meta", resource_id=str(obj.id), details=payload.model_dump(exclude_unset=True, mode="json"), ) await db.commit() return success_response( SoftwareProcurementMetaRead.model_validate(obj).model_dump(mode="json") ) # ────────────────────────────────────────────────────────────────────── # Overview / dashboard (PR 2) # ────────────────────────────────────────────────────────────────────── @router.get("/overview", dependencies=[Depends(require_software_read())]) async def overview_route( db: Annotated[AsyncSession, Depends(get_db)], ): """Top-of-page widgets for /admin/procurement. Returns a small bag of counts + most-recent rows so the dashboard can render P2P stage chips + KPI cards + activity strip without issuing a dozen separate requests. """ from datetime import timedelta from sqlalchemy import func as _f from sqlalchemy import select as _s today = date.today() soon = today + timedelta(days=90) # Contracts expiring in next 90 days. expiring = await db.scalar( _s(_f.count(ProcurementContract.id)).where( ProcurementContract.expiry_date.is_not(None), ProcurementContract.expiry_date <= soon, ProcurementContract.expiry_date >= today, ProcurementContract.status.in_(["active", "signed"]), ) ) or 0 expired = await db.scalar( _s(_f.count(ProcurementContract.id)).where( ProcurementContract.expiry_date.is_not(None), ProcurementContract.expiry_date < today, ProcurementContract.status != "expired", ProcurementContract.status != "terminated", ) ) or 0 # Active suppliers. suppliers_active = await db.scalar( _s(_f.count(ProcurementSupplier.id)).where( ProcurementSupplier.status == "active", ) ) or 0 # PO status histogram. po_status_rows = await db.execute( _s(ProcurementOrder.status, _f.count(ProcurementOrder.id)) .group_by(ProcurementOrder.status) ) po_status: dict[str, int] = {r[0]: int(r[1]) for r in po_status_rows} # Recent activity. recent_orders_q = await db.execute( _s(ProcurementOrder) .order_by(ProcurementOrder.created_at.desc()) .limit(5) ) recent_orders = list(recent_orders_q.scalars()) recent_contracts_q = await db.execute( _s(ProcurementContract) .order_by(ProcurementContract.created_at.desc()) .limit(5) ) recent_contracts = list(recent_contracts_q.scalars()) # Supplier name JOIN for the recent-orders chip. supplier_ids = {str(o.supplier_id) for o in recent_orders if o.supplier_id} supplier_names: dict = {} if supplier_ids: rows = await db.execute( _s(ProcurementSupplier.id, ProcurementSupplier.name) .where(ProcurementSupplier.id.in_(supplier_ids)) ) supplier_names = {uuid.UUID(str(r[0])): r[1] for r in rows} return success_response({ "contracts": { "expiring_within_90d": int(expiring), "overdue_not_marked": int(expired), }, "suppliers": { "active": int(suppliers_active), }, "orders": { "by_status": po_status, "total": sum(po_status.values()), }, "recent_orders": [ { "id": str(o.id), "order_number": o.order_number, "status": o.status, "total_amount": str(o.total_amount) if o.total_amount is not None else None, "currency": o.currency, "supplier_name": supplier_names.get(o.supplier_id) if o.supplier_id else None, "created_at": o.created_at.isoformat(), } for o in recent_orders ], "recent_contracts": [ { "id": str(c.id), "contract_number": c.contract_number, "title": c.title, "status": c.status, "expiry_date": c.expiry_date.isoformat() if c.expiry_date else None, } for c in recent_contracts ], }) # ────────────────────────────────────────────────────────────────────── # Budgets (PR 3) # ────────────────────────────────────────────────────────────────────── @router.get("/budgets") async def list_budgets_route( db: Annotated[AsyncSession, Depends(get_db)], current: Annotated[AuthUser, Depends(get_current_user)], year: int | None = Query(None, ge=2020, le=2100), department: str | None = Query(None, max_length=128), owner_person_id: uuid.UUID | None = Query(None), include_inactive: bool = Query(False), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), ): # Finance-governance data (every department's budget, owner identity, # balances) in bulk. The coarse require_software_read() also admits # region-scoped asset_admin — an even better enumeration vector than the # detail endpoint #1249 already closed. Gate on the finance-view tier; # budget OWNERS who hold no finance role read their own via /budgets/me. if not can_view_finance(current.role_names): raise ForbiddenError() from app.domains.procurement.budget_service import compute_budget_balance offset = (page - 1) * page_size rows, total = await service.list_budgets( db, year=year, department=department, owner_person_id=owner_person_id, include_inactive=include_inactive, limit=page_size, offset=offset, ) person_map = await service._person_name_map( db, [r.owner_person_id for r in rows], ) out = [] for r in rows: bal = await compute_budget_balance(db, r.id) out.append({ "id": str(r.id), "year": r.year, "department": r.department, "cost_center": r.cost_center, "category_id": str(r.category_id) if r.category_id else None, "currency": r.currency, "total_amount": str(r.total_amount), "owner_person_id": str(r.owner_person_id) if r.owner_person_id else None, "owner_name": person_map.get(r.owner_person_id), "notes": r.notes, "is_active": bool(r.is_active), "locked": bal["locked"], "remaining": bal["remaining"], "pct_used": bal["pct_used"], "is_over_80": bal["is_over_80"], "is_over_95": bal["is_over_95"], "is_over_total": bal["is_over_total"], }) return paginated_response(out, page=page, page_size=page_size, total=total) @router.get("/budgets/me") async def list_my_budgets_route( db: Annotated[AsyncSession, Depends(get_db)], current: Annotated[AuthUser, Depends(get_current_user)], ): """Budgets owned by the current user — backs /me/budget. Self-scoped: any authenticated user, strictly filtered to ``owner_person_id == current.id`` below. A budget owner is an arbitrary person FK (a department head / cost-centre owner who may hold no DB admin role), so this must NOT carry an admin gate — it would 403 the exact users the /me/budget page is built for. No leak: the query only ever returns the caller's own budgets. """ from app.domains.procurement.budget_service import compute_budget_balance rows, _ = await service.list_budgets( db, owner_person_id=uuid.UUID(str(current.id)), limit=200, ) out = [] for r in rows: bal = await compute_budget_balance(db, r.id) out.append({ "id": str(r.id), "year": r.year, "department": r.department, "cost_center": r.cost_center, "currency": r.currency, "total_amount": str(r.total_amount), "locked": bal["locked"], "remaining": bal["remaining"], "pct_used": bal["pct_used"], "is_over_80": bal["is_over_80"], "is_over_95": bal["is_over_95"], "is_over_total": bal["is_over_total"], }) return success_response(out) @router.get( "/budgets/{budget_id}", dependencies=[Depends(require_software_read())], ) async def get_budget_route( budget_id: uuid.UUID, db: Annotated[AsyncSession, Depends(get_db)], current: Annotated[AuthUser, Depends(get_current_user)], ): from app.domains.procurement.budget_service import compute_budget_balance obj = await service.get_budget(db, budget_id) # Scope: budget detail (owner identity, cost-centre, full per-actor # consumption history) is finance-governance data. The coarse # require_software_read() gate also admits asset_admin — a region- # scoped role with no procurement-budget remit — which would let it # enumerate every department's budget. Restrict to the finance-view # tier (global_admin / finance / auditor / management) OR the budget's # own owner; anyone else gets 404 (not 403) so id-enumeration can't be # used as an existence oracle. Mirrors /budgets/me's owner-scoping. if not can_view_finance(current.role_names) and str( obj.owner_person_id or "" ) != str(current.id): raise NotFoundError("procurement_budget", str(budget_id)) consumptions = await service.list_budget_consumptions(db, budget_id) bal = await compute_budget_balance(db, budget_id) person_map = await service._person_name_map( db, [obj.owner_person_id] + [c.actor_person_id for c in consumptions], ) return success_response({ "id": str(obj.id), "year": obj.year, "department": obj.department, "cost_center": obj.cost_center, "category_id": str(obj.category_id) if obj.category_id else None, "currency": obj.currency, "total_amount": str(obj.total_amount), "owner_person_id": str(obj.owner_person_id) if obj.owner_person_id else None, "owner_name": person_map.get(obj.owner_person_id), "notes": obj.notes, "is_active": bool(obj.is_active), "balance": bal, "consumptions": [ { "id": str(c.id), "event": c.event, "amount": str(c.amount), "currency": c.currency, "amount_cny": str(c.amount_cny) if c.amount_cny is not None else None, "po_id": str(c.po_id) if c.po_id else None, "notes": c.notes, "actor_name": person_map.get(c.actor_person_id), "occurred_at": c.occurred_at.isoformat(), } for c in consumptions ], }) @router.post("/budgets", dependencies=[Depends(require_admin())]) async def create_budget_route( payload: dict, db: Annotated[AsyncSession, Depends(get_db)], current: Annotated[AuthUser, Depends(get_current_user)], request: Request, ): obj = await service.create_budget(db, payload, actor=current) await _log_audit( db, request=request, current=current, action="procurement.budget.create", resource_type="procurement_budget", resource_id=str(obj.id), details={ "year": obj.year, "department": obj.department, "total_amount": str(obj.total_amount), "currency": obj.currency, }, ) await db.commit() return success_response({"id": str(obj.id)}) @router.patch( "/budgets/{budget_id}", dependencies=[Depends(require_admin())], ) async def update_budget_route( budget_id: uuid.UUID, payload: dict, db: Annotated[AsyncSession, Depends(get_db)], current: Annotated[AuthUser, Depends(get_current_user)], request: Request, ): obj = await service.update_budget(db, budget_id, payload, actor=current) await _log_audit( db, request=request, current=current, action="procurement.budget.update", resource_type="procurement_budget", resource_id=str(obj.id), details=payload, ) await db.commit() return success_response({"id": str(obj.id)})