import io import logging from datetime import UTC from datetime import date as _date_cls from datetime import datetime as _dt from datetime import timedelta as _timedelta from decimal import Decimal from typing import Any, Literal from uuid import UUID from fastapi import APIRouter, Depends, File, Header, Query, Request, UploadFile from fastapi.responses import StreamingResponse from sqlalchemy import and_, func, or_, select from sqlalchemy.exc import IntegrityError 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 ( assert_can_manage_asset, check_directory_read_rate_limit, check_sensitive_endpoint_rate_limit, get_current_user, get_real_ip, require_admin, require_admin_read, require_asset_admin, require_asset_read, require_foundation_admin, require_global_asset_admin, require_software_read, ) from app.core.dependencies import ( # noqa: E402 require_active_employee_for_mutations as _leaver_runtime_gate, ) from app.core.exceptions import ( AppException, ForbiddenError, NotFoundError, ValidationError, ) from app.core.exporting import to_cell from app.core.fixed_currency import to_cny from app.core.grant_eligibility import is_blocked_from_new_grant from app.core.idempotency import IdempotencyService from app.core.idempotent_route import idempotent from app.core.masking import mask_head_tail, mask_phone, mask_tail from app.core.person_scope import SubordinateScope, subordinate_person_ids from app.core.rbac import ( ASSET_ADMIN_ROLE, GLOBAL_ADMIN_ROLE, HR_ROLE, can_manage_assets, can_read_all_assets, has_role, ) from app.core.response import paginated_response, success_response from app.core.safe_logging import get_safe_logger from app.core.txn import best_effort from app.domains.asset.brand_model_service import AssetBrandModelService from app.domains.asset.bulk_import import validate_bulk_upload_contract from app.domains.asset.models import ( AssetBusinessPayment, AssetItem, AssetLegalEntity, AssetLocation, AssetLocationAdmin, AssetOfficeAddress, AssetOfficeLocalAdmin, AssetOperation, AssetProcurementOrder, AssetSoftwareCatalog, AssetSupplier, SoftwareAddonCharge, SoftwareAddonItem, SoftwareLicensePlan, SoftwareSeatAllocation, ) from app.domains.asset.office_local_admins import ( list_office_local_admin_ids_bulk, replace_office_local_admins, ) # The region gates live in their own module so the MCP tool surface can # apply the identical rule instead of keeping a second copy that drifts. from app.domains.asset.region_gates import ( assert_target_office_in_region as _assert_target_office_in_region, ) from app.domains.asset.region_gates import ( assert_target_person_in_region as _assert_target_person_in_region, ) from app.domains.asset.region_gates import ( enforce_asset_region as _enforce_asset_region, ) from app.domains.asset.region_gates import ( enforce_asset_region_bulk as _enforce_asset_region_bulk, ) from app.domains.asset.region_gates import ( enforce_intake_region as _enforce_intake_region, ) from app.domains.asset.schemas import ( AddonChargeRequestCreate, AddonChargeResponse, AddonItemCreateRequest, AddonItemResponse, AddonItemUpdateRequest, AdminSeatChangePlanRequest, AdminSeatGrantRequest, AdminSeatRevokeRequest, AssetAssignRequest, AssetBorrowExtensionRequest, AssetBorrowRequest, AssetBorrowReturnRequest, AssetBrandCreateRequest, AssetBrandUpdateRequest, AssetBulkRecycleToOfficeRequest, AssetCategoryConfigCreateRequest, AssetCategoryConfigResponse, AssetCategoryConfigUpdateRequest, AssetChangeAssigneeRequest, AssetClaimRequest, AssetCreateRequest, AssetDetailResponse, AssetDisposeRequest, AssetFinancialProfile, AssetIntakeRequest, AssetListResponse, AssetLocationAdminsRequest, AssetLocationCreateRequest, AssetLocationResponse, AssetLocationUpdateRequest, AssetMarkHeldByLeaverRequest, AssetModelCreateRequest, AssetModelUpdateRequest, AssetRecoverFromLeaverRequest, AssetRecycleToOfficeRequest, AssetReleaseFromOfficeRequest, AssetRepairCompleteRequest, AssetRepairRequest, AssetResponse, AssetReturnRequest, AssetTransferRequest, AssetUpdateRequest, BookValueTrendResponse, BulkAssetMutationResponse, BulkBorrowRequest, BulkClaimRequest, BulkRestoreRequest, BusinessPaymentCreateRequest, BusinessPaymentResponse, BusinessPaymentUpdateRequest, CloseMonthRequest, CostSummaryResponse, DepreciationPolicyRead, DepreciationPolicyUpsert, EmployeeRequestCreateRequest, EmployeeRequestProcessRequest, EmployeeRequestResponse, EmployeeRequestSupplementUploadsRequest, ExternalAccountClassifyRequest, FinancialStatsResponse, GenerateAssetTagRequest, LegalEntityCreateRequest, LegalEntityResponse, LegalEntityRevealResponse, LegalEntityUpdateRequest, LicensePlanCreateRequest, LicensePlanResponse, LicensePlanUpdateRequest, MdmDeviceCleanupConfirmRequest, MdmDeviceMetaResponse, MdmDeviceResponse, MdmDeviceSummary, OfficeAddressCreateRequest, OfficeAddressResponse, OfficeAddressUpdateRequest, OperationConfirmRequest, ProcurementOrderCreateRequest, ProcurementOrderResponse, ProcurementOrderUpdateRequest, SeatAllocationResponse, SeatCancelNextMonthRequest, SeatKeepRequest, SeatRequestCreate, SeatRevokePreviewResponse, SoftwareCatalogCreateRequest, SoftwareCatalogResponse, SoftwareCatalogUpdateRequest, SupplierCreateRequest, SupplierResponse, SupplierUpdateRequest, ) from app.domains.asset.service import ( AssetService, serialize_operation, serialize_operations, ) router = APIRouter( prefix="/api/v1/assets", tags=["assets"], dependencies=[Depends(_leaver_runtime_gate)], ) logger = get_safe_logger(__name__) async def _cancel_parallel_feishu_instance_for_seat( db: AsyncSession, *, seat_allocation_id: UUID, actor_work_email: str, ) -> None: """Best-effort: cancel the Feishu approval instance that's running in parallel with a seat-allocation that an admin just approved via the Helix UI override path. Today only ``approve_seat`` calls this; the helper is intentionally generic so a future ``reject_seat`` admin endpoint can reuse it without code duplication, but no caller exists yet at the reject site. Why this exists: ``FeishuApprovalClient`` has no ``approve_task`` endpoint, so an admin override of a Feishu-backed seat in the Helix UI leaves the approver's Feishu inbox sitting on a stale "pending" card forever. Cancelling the instance is the only way to get the inboxes in agreement. Cancel failure does NOT bubble — the local seat decision is already durable and the orphan is cleanable from the Feishu admin UI. Same contract as :func:`CloudScopeRequestService._cancel_feishu_instance_on_admin_decision`. Callers MUST commit the local decision before calling this; the helper does not retry the local write on Feishu failure. """ from app.approval.models import ApprovalRequest from app.approval.service import ApprovalService # Step 1: SELECT — wrap in its own try so a DB error here doesn't # leave a half-initialised ``approval_row`` referenced in the # outer except. # savepoint-exempt: read-only SELECT and the handler returns immediately; # nothing runs on this session afterwards. try: approval_row = ( await db.execute( select(ApprovalRequest).where( func.json_unquote( func.json_extract( ApprovalRequest.source_payload, "$.seat_allocation_id", ) ) == str(seat_allocation_id), # Only cancel a Feishu instance that's still live — # earlier rejected/withdrawn/cancelled rows from a # re-request cycle carry their terminal-state # external_id, and hitting Feishu with a terminal # instance_code raises 502. # ``ApprovalRequest.status`` is only ever written # via ``_map_feishu_status`` (returning # approved/rejected/cancelled/completed/pending) # or the initial "pending" insert at submit time — # no "submitted" / "in_progress" intermediate state # exists, so "pending" is the complete in-flight set. ApprovalRequest.status == "pending", ).order_by(ApprovalRequest.created_at.desc()).limit(1) ) ).scalar_one_or_none() except Exception as e: # noqa: BLE001 logger.event( "APPROVAL_LOOKUP_FAILED", { "seat_allocation_id": seat_allocation_id, "error_type": type(e).__name__, }, level=logging.WARNING, ) return if approval_row is None or not approval_row.external_id: return approval_code = ( (approval_row.source_payload or {}).get("approval_code") ) if not approval_code: return # Step 2: Feishu cancel call — instance code is known, so a failure # here leaves an actionable orphan. Log the instance code so ops # can clean it manually from the Feishu admin UI. instance_code = approval_row.external_id try: svc = ApprovalService(db) client = await svc._get_client() feishu_user_id = await client.resolve_user_id_by_email( actor_work_email, ) await client.cancel_instance( instance_code=instance_code, user_id=feishu_user_id, approval_code=approval_code, ) logger.info( "seat-allocation %s admin override cancelled feishu instance %s", seat_allocation_id, instance_code, ) except Exception as e: # noqa: BLE001 logger.event( "APPROVAL_CANCEL_FAILED", { "seat_allocation_id": seat_allocation_id, "instance_code": instance_code, "approval_code": approval_code, "error_type": type(e).__name__, }, level=logging.WARNING, ) async def _require_supplier(db: AsyncSession, supplier_id: UUID) -> AssetSupplier: supplier = await db.get(AssetSupplier, supplier_id) if not supplier: raise AppException( code="SUPPLIER_NOT_FOUND", message=f"Supplier '{supplier_id}' not found", status_code=404, ) return supplier async def _require_procurement_order(db: AsyncSession, order_id: UUID) -> AssetProcurementOrder: order = await db.get(AssetProcurementOrder, order_id) if not order: raise AppException( code="PROCUREMENT_ORDER_NOT_FOUND", message=f"Procurement order '{order_id}' not found", status_code=404, ) return order async def _resolve_subordinate_ids( db: AsyncSession, root_user_id: UUID, ) -> set[UUID]: """BFS the manager-report tree rooted at ``root_user_id``. Returns ``{root}`` ∪ every direct/transitive report whose ``employment_status NOT IN ('left', 'leaver_pending')`` AND ``deleted_at IS NULL``. Single source of truth for subordinate scope across the asset domain — list, export, /team and cost-stats endpoints all use this helper so a manager always sees the same population in every view. Round-51 R1-P0-1 hardening: previously the filter was just ``employment_status != 'left'`` which still surfaced leavers in the ``leaver_pending`` offboarding window AND any soft-deleted person (``deleted_at IS NOT NULL``). The cost subordinates breakdown blocked the *caller* if they were leaving but did NOT filter the *set*, so a manager (who is fine) saw a leaving direct-report's spend and PII (work_email, display_name) inside the offboarding lockout window. Tighten the set filter to match the rest of the platform's leaver-lockout invariant. Bug context: a previous implementation in three places did ``WHERE manager_person_id == current_user.id`` directly, which matched only direct reports. Skip-level reports (A → B → C: A asking for "team" got only B, not C) were silently invisible. Delegates to the canonical :func:`app.core.person_scope.subordinate_person_ids` with ``ACTIVE_ONLY`` scope (the asset domain's leaver-lockout invariant), then re-adds ``root_user_id`` so the returned set keeps the asset convention "manager scope = self ∪ reports". One behavioural refinement vs the old inline BFS: the canonical resolver walks *through* a ``leaver_pending`` intermediate manager, so a still-active skip-level report under someone in the offboarding window is no longer silently dropped from the manager's team scope. """ subs = await subordinate_person_ids( db, root_user_id, scope=SubordinateScope.ACTIVE_ONLY, ) return {root_user_id, *subs} # --------------------------------------------------------------------------- # Existing endpoints (backward compatible) # --------------------------------------------------------------------------- @router.get("") async def list_assets( request: Request, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), status: list[str] | None = Query(None), category: list[str] | None = Query(None), location: list[str] | None = Query(None), usage_company: list[str] | None = Query(None), purchase_entity: list[str] | None = Query(None), assigned_to: UUID | None = Query(None), holder_kind: list[str] | None = Query(None), holder_office_id: list[UUID] | None = Query(None), search: str | None = Query(None), sort_by: str = Query("created_at"), sort_order: Literal["asc", "desc"] = Query("desc"), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List assets with filters (IT Admin sees all, Employee sees only their own).""" service = AssetService(db) # Validate sort field against whitelist if sort_by not in AssetService.ALLOWED_SORT_FIELDS: raise AppException( code="INVALID_SORT_FIELD", message=f"Sort field '{sort_by}' is not allowed. Allowed fields: {', '.join(sorted(AssetService.ALLOWED_SORT_FIELDS.keys()))}", status_code=400, ) sort_params = dict(sort_by=sort_by, sort_order=sort_order) # Role-based filtering # Round 6 audit fix-up 2026-05-04: track ``viewer_scope`` per branch # so the enrichment pass can redact cross-scope user PII. viewer_scope: set[UUID] | None = None if can_read_all_assets(current_user.role_names): # Global admin, asset admin, and auditor can see all assets + # all PII (no redaction). viewer_scope = None assets, total = await service.list_assets( page=page, page_size=page_size, status=status, category=category, location=location, usage_company=usage_company, purchase_entity=purchase_entity, assigned_to=assigned_to, holder_kind=holder_kind, holder_office_id=holder_office_id, search=search, **sort_params, ) elif current_user.is_manager: # Managers see themselves + every direct/transitive report. # Skip-level reports (A → B → C — A querying for "team" must # see C, not just B) require a BFS walk; the previous # ``manager_person_id == current_user.id`` query missed them. allowed_assignee_ids = list( await _resolve_subordinate_ids(db, current_user.id) ) viewer_scope = set(allowed_assignee_ids) | {current_user.id} if assigned_to and assigned_to not in allowed_assignee_ids: raise ForbiddenError("You can only view your own or your subordinates' assets") assets, total = await service.list_assets( page=page, page_size=page_size, status=status, category=category, location=location, usage_company=usage_company, purchase_entity=purchase_entity, assigned_to=assigned_to, assigned_to_ids=None if assigned_to else allowed_assignee_ids, holder_kind=holder_kind, holder_office_id=holder_office_id, search=search, **sort_params, ) else: # Regular employee can only see their own assets if assigned_to and assigned_to != current_user.id: raise ForbiddenError("You can only view your own assets") viewer_scope = {current_user.id} assets, total = await service.list_assets( page=page, page_size=page_size, status=status, category=category, location=location, usage_company=usage_company, purchase_entity=purchase_entity, assigned_to=current_user.id, holder_kind=holder_kind, holder_office_id=holder_office_id, search=search, **sort_params, ) # ``viewer_scope`` was set in the role-based branch above so the # enrichment pass redacts cross-scope user PII (display_name + # work email). enriched = await service.enrich_with_users( assets, viewer_scope_user_ids=viewer_scope, ) return paginated_response( data=[AssetListResponse(**item) for item in enriched], total=total, page=page, page_size=page_size, request=request, ) @router.get("/workbench/financial-summary") async def workbench_financial_summary( request: Request, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """CNY-equivalent workbench totals plus native currency audit buckets.""" twelve_months_ago = _date_cls.today() - _timedelta(days=365) disposal_rows = (await db.execute( select(AssetOperation.detail).where( AssetOperation.operation_type == "disposal", AssetOperation.status == "confirmed", AssetOperation.operator_id == current_user.id, ) )).scalars().all() disposal_native: dict[str, Decimal] = {c: Decimal("0") for c in ("CNY", "SGD", "JPY", "USD")} for detail in disposal_rows: financials = (detail or {}).get("disposal_financials", {}) if isinstance(detail, dict) else {} amount = financials.get("disposal_proceeds") if isinstance(financials, dict) else None currency = str(financials.get("disposal_currency") or "CNY").upper() if isinstance(financials, dict) else "CNY" if amount is not None and currency in disposal_native: disposal_native[currency] += Decimal(str(amount)) purchase_rows = (await db.execute( select(AssetItem.purchase_cost, AssetItem.currency).where( AssetItem.deleted_at.is_(None), AssetItem.category.in_(("laptop", "desktop", "computer")), AssetItem.purchase_date.is_not(None), AssetItem.purchase_date >= twelve_months_ago, ) )).all() purchase_native: dict[str, Decimal] = {c: Decimal("0") for c in disposal_native} for amount, currency in purchase_rows: cur = (currency or "CNY").upper() if amount is not None and cur in purchase_native: purchase_native[cur] += amount def summary(native: dict[str, Decimal]) -> dict[str, Any]: return {"total_cny": sum((to_cny(amount, currency) for currency, amount in native.items()), Decimal("0")), "by_currency": native} return success_response(data={"disposal": summary(disposal_native), "purchases_12m": summary(purchase_native)}, request=request) @router.get("/recycle-bin") async def list_recycle_bin( request: Request, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), search: str | None = Query(None), current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """List soft-deleted assets — the admin recycle bin (Asset Admin only). Newest-deleted first. Each row can be brought back via ``POST /assets/{asset_id}/restore``. Declared before ``GET /{asset_id}`` so the literal ``recycle-bin`` path isn't parsed as an asset UUID. """ service = AssetService(db) assets, total = await service.list_deleted_assets( page=page, page_size=page_size, search=search, ) enriched = await service.enrich_with_users( assets, viewer_scope_user_ids=None, ) return paginated_response( data=[AssetListResponse(**item) for item in enriched], total=total, page=page, page_size=page_size, request=request, ) # Hard ceiling on a single export. Twenty thousand rows is enough to # cover this org's full inventory many times over (current count: ~1k), # but keeps a runaway request from materialising the entire table into # memory if a future filter combination matches everything. Past this # point the admin should narrow the filter — there is no value in a # 100k-row spreadsheet. ASSET_EXPORT_ROW_LIMIT = 20000 @router.get("/export.xlsx") async def export_assets( request: Request, status: list[str] | None = Query(None), category: list[str] | None = Query(None), location: list[str] | None = Query(None), usage_company: list[str] | None = Query(None), purchase_entity: list[str] | None = Query(None), assigned_to: UUID | None = Query(None), holder_kind: list[str] | None = Query(None), holder_office_id: list[UUID] | None = Query(None), search: str | None = Query(None), sort_by: str = Query("created_at"), sort_order: Literal["asc", "desc"] = Query("desc"), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Export filtered assets as XLSX. Filter signature is a strict superset of :func:`list_assets` — the UI passes the same query params it uses for the list, so the download contains exactly the rows the admin is currently looking at (no pagination). RBAC mirrors the list endpoint: * IT-admin / Asset-admin / Auditor → company-wide. * Manager → caller's own assets + direct reports' assets. * Employee → caller's own assets only. Capped at :data:`ASSET_EXPORT_ROW_LIMIT` rows. We reuse the same ``service.list_assets`` query path so any filter or sort behaviour fix on the list endpoint applies here automatically. """ from openpyxl import Workbook from openpyxl.styles import Alignment, Font, PatternFill service = AssetService(db) if sort_by not in AssetService.ALLOWED_SORT_FIELDS: raise AppException( code="INVALID_SORT_FIELD", message=( f"Sort field '{sort_by}' is not allowed. Allowed fields: " f"{', '.join(sorted(AssetService.ALLOWED_SORT_FIELDS.keys()))}" ), status_code=400, ) sort_params = dict(sort_by=sort_by, sort_order=sort_order) # Round 8 audit fix-up 2026-05-04: track ``viewer_scope`` per # branch so the export's enrichment redacts cross-scope user PII # (display_name + work email) on the downloaded spreadsheet. viewer_scope: set[UUID] | None = None if can_read_all_assets(current_user.role_names): viewer_scope = None assets, total = await service.list_assets( page=1, page_size=ASSET_EXPORT_ROW_LIMIT, status=status, category=category, location=location, usage_company=usage_company, purchase_entity=purchase_entity, assigned_to=assigned_to, holder_kind=holder_kind, holder_office_id=holder_office_id, search=search, **sort_params, ) elif current_user.is_manager: # Transitive subordinate scope — see ``_resolve_subordinate_ids`` # for the rationale (skip-level reports must be reachable). allowed_assignee_ids = list( await _resolve_subordinate_ids(db, current_user.id) ) viewer_scope = set(allowed_assignee_ids) | {current_user.id} if assigned_to and assigned_to not in allowed_assignee_ids: raise ForbiddenError( "You can only export your own or your subordinates' assets", ) assets, total = await service.list_assets( page=1, page_size=ASSET_EXPORT_ROW_LIMIT, status=status, category=category, location=location, usage_company=usage_company, purchase_entity=purchase_entity, assigned_to=assigned_to, assigned_to_ids=None if assigned_to else allowed_assignee_ids, holder_kind=holder_kind, holder_office_id=holder_office_id, search=search, **sort_params, ) else: if assigned_to and assigned_to != current_user.id: raise ForbiddenError("You can only export your own assets") viewer_scope = {current_user.id} assets, total = await service.list_assets( page=1, page_size=ASSET_EXPORT_ROW_LIMIT, status=status, category=category, location=location, usage_company=usage_company, purchase_entity=purchase_entity, assigned_to=current_user.id, holder_kind=holder_kind, holder_office_id=holder_office_id, search=search, **sort_params, ) enriched = await service.enrich_with_users( assets, viewer_scope_user_ids=viewer_scope, ) # Audit the export. Asset records carry employee names + usage # locations — admins downloading the full inventory leaves a trail. audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.export", resource_type="asset", resource_id=None, status="success", detail={ "row_count": len(enriched), "total_matched": total, "truncated": total > len(enriched), "filters": { "status": status, "category": category, "location": location, "usage_company": usage_company, "purchase_entity": purchase_entity, "assigned_to": str(assigned_to) if assigned_to else None, # PR 2026-05-26 — include the holder filters in the audit # detail too, so "admin exported only 办公室持有 assets" is # discoverable from the log. Without these fields the # detail dict said "no filter applied" while the actual # query was narrowed to a single office. "holder_kind": holder_kind, "holder_office_id": ( [str(x) for x in holder_office_id] if holder_office_id else None ), "search": search, }, }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() wb = Workbook() ws = wb.active ws.title = "Assets" # Column order mirrors the list page's visible columns, then trails # off into the high-cardinality fields the bulk-update template # expects so this export round-trips through the bulk-update flow. headers = [ ("asset_tag", "资产编码"), ("asset_name", "资产名称"), ("status", "流转阶段"), ("device_status", "健康状态"), ("category", "类别"), ("brand", "品牌"), ("model", "型号"), ("serial_number", "序列号"), ("location", "位置"), ("assigned_to_name", "所属人"), ("assigned_to_email", "所属人邮箱"), # Round 6 audit fix-up 2026-05-04: surface the borrower for # status='borrowed' rows. Pre-fix the export attributed # borrowed assets to the original owner under "使用人" — the # admin couldn't see who actually held the asset on loan. ("borrowed_by_name", "当前借用人"), ("borrowed_by_email", "借用人邮箱"), ("borrow_started_at", "借出时间"), ("department", "部门"), ("user_entity", "使用人主体"), ("usage_company", "使用主体"), ("owner_company", "所有主体"), ("purchase_entity", "采购主体"), ("source_type", "来源类型"), ("order_number", "采购单号"), ("purchase_date", "采购日期"), ("purchase_cost", "采购成本"), ("currency", "币种"), ("warranty_until", "保修截止"), ("usage_limit_months", "使用月限"), ("supplier_name", "供应商"), ("expected_return_date", "预计归还日期"), ("assigned_at", "领用时间"), ("disposed_at", "处置时间"), ("disposal_type", "处置方式"), ("last_inventory_at", "最近盘点"), ("last_inventory_status", "盘点状态"), ("notes", "备注"), ] field_keys = [k for k, _ in headers] label_row = [label for _, label in headers] machine_row = [k for k, _ in headers] header_fill = PatternFill( start_color="4472C4", end_color="4472C4", fill_type="solid", ) header_font = Font(color="FFFFFF", bold=True, size=11) machine_fill = PatternFill( start_color="D9E2F3", end_color="D9E2F3", fill_type="solid", ) machine_font = Font(color="555555", italic=True, size=9) for col_idx, label in enumerate(label_row, 1): cell = ws.cell(row=1, column=col_idx, value=label) cell.fill = header_fill cell.font = header_font cell.alignment = Alignment(horizontal="center") for col_idx, key in enumerate(machine_row, 1): cell = ws.cell(row=2, column=col_idx, value=key) cell.fill = machine_fill cell.font = machine_font cell.alignment = Alignment(horizontal="center") # CSV / Excel formula-injection defense + type coercion: route every # cell through the shared ``app.core.exporting.to_cell``. It is the # single source of truth for the neutralised prefix set — crucially # ``\t`` and ``\r`` (the historical Excel DDE vector) in addition to # ``= + - @`` — and also guards naive datetimes (the prior hand-rolled # copy here called ``astimezone`` unconditionally, which raises on a # tz-naive value). Department / display name / company_name flow in # from HRIS where a malicious upstream record could plant a formula. # See OWASP "CSV injection". for row_idx, item in enumerate(enriched, start=3): for col_idx, key in enumerate(field_keys, start=1): ws.cell(row=row_idx, column=col_idx, value=to_cell(item.get(key))) for col_idx in range(1, len(field_keys) + 1): ws.column_dimensions[ws.cell(row=1, column=col_idx).column_letter].width = 18 buf = io.BytesIO() wb.save(buf) buf.seek(0) filename = f"assets_{_dt.now(UTC).strftime('%Y%m%d_%H%M%S')}.xlsx" return StreamingResponse( buf, media_type=( "application/vnd.openxmlformats-officedocument." "spreadsheetml.sheet" ), headers={ "Content-Disposition": f'attachment; filename="{filename}"', "X-Asset-Export-Total-Matched": str(total), "X-Asset-Export-Returned": str(len(enriched)), "X-Asset-Export-Truncated": ( "true" if total > len(enriched) else "false" ), }, ) @router.post("") async def create_asset( request: Request, body: AssetCreateRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Create a new asset (IT Admin only).""" await _enforce_intake_region( db, current_user, holder_office_id=body.holder_office_id, location=body.location, ) service = AssetService(db) asset = await service.create_asset(body, current_user.id) # Audit log audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.create", resource_type="asset", resource_id=str(asset.id), status="success", detail={"asset_tag": asset.asset_tag, "category": asset.category}, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() # Enrich with user info enriched = await service.enrich_with_users([asset]) return success_response( data=AssetResponse(**enriched[0]), request=request, ) @router.get("/my") async def get_my_assets( request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Get current user's assigned assets.""" service = AssetService(db) assets = await service.get_user_assets(current_user.id) # Round 8 audit fix-up 2026-05-04: redact cross-scope user PII. # ``get_user_assets`` returns rows where the user is owner OR # borrower; for borrower-side rows the owner's display_name + # work_email would otherwise leak. Employee scope = self only. enriched = await service.enrich_with_users( assets, viewer_scope_user_ids={current_user.id}, ) return success_response( data=[AssetListResponse(**item) for item in enriched], request=request, ) @router.get("/team") async def get_team_assets( request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Assets assigned to the current user's direct/transitive reports. "团队资产" is *strictly* manager-scope — the page label and the ``团队负责人`` view-role users switch into both promise "your team's", not "everyone's". An admin who actually wants the company- wide view uses ``/admin/assets`` (the admin asset list endpoint at ``GET /``); calling ``/team`` from the admin role does NOT escalate to global scope. Bug context (2026-05-03): previous implementation had two flaws — (1) admin / asset-admin role bypassed the scope filter and got every assigned asset (~600 in this org), which a 500-employee rollout discovered immediately by switching view-role to 团队负责人 and seeing 401 distinct assignees; (2) the manager branch matched only direct reports, dropping skip-level subs. """ # Only managers (users with at least one report) can view this # page. Admins without subordinates also see empty — they should # use the admin asset list for global scope. if not current_user.is_manager: if can_manage_assets(current_user.role_names): return success_response(data=[], request=request) raise ForbiddenError("Only managers can view team assets") visited = await _resolve_subordinate_ids(db, current_user.id) visited.discard(current_user.id) # team scope = reports, not self if not visited: return success_response(data=[], request=request) service = AssetService(db) assets, _ = await service.list_assets( page=1, page_size=10000, assigned_to_ids=list(visited), sort_by="assigned_at", sort_order="desc", ) # Round 8 audit fix-up 2026-05-04: redact cross-team PII. Manager # scope = subordinates + self; any borrower / owner OUTSIDE that # set has display_name + work_email redacted on the response. enriched = await service.enrich_with_users( assets, viewer_scope_user_ids=visited | {current_user.id}, ) return success_response( data=[AssetListResponse(**item) for item in enriched], request=request, ) @router.get("/stats") async def get_asset_stats( request: Request, current_user: AuthUser = Depends(require_asset_read()), db: AsyncSession = Depends(get_db), ): """Get asset statistics (admin-tier + management read-only).""" service = AssetService(db) stats = await service.get_stats() return success_response( data=stats, request=request, ) @router.get("/history") async def get_asset_history( request: Request, asset_id: UUID | None = Query(None), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), current_user: AuthUser = Depends(require_asset_read()), db: AsyncSession = Depends(get_db), ): """Query asset history (admin-tier + management read-only).""" service = AssetService(db) history, total = await service.get_asset_history( asset_id=asset_id, page=page, page_size=page_size, ) # Enrich with user info enriched = await service.enrich_history_with_users(history) return paginated_response( data=enriched, total=total, page=page, page_size=page_size, request=request, ) # --------------------------------------------------------------------------- # New lifecycle endpoints # --------------------------------------------------------------------------- @router.post("/intake") async def intake_asset( request: Request, body: AssetIntakeRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Register a new asset via intake (IT Admin only).""" await _enforce_intake_region( db, current_user, holder_office_id=body.holder_office_id, location=body.location, ) service = AssetService(db) asset, operation = await service.intake_asset(body, current_user.id) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.intake", resource_type="asset", resource_id=str(asset.id), status="success", detail={"asset_tag": asset.asset_tag, "operation_number": operation.operation_number}, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() enriched = await service.enrich_with_users([asset]) return success_response( data={ "asset": AssetResponse(**enriched[0]), "operation": await serialize_operation(db, operation), }, request=request, ) @router.get("/intake/template") async def download_intake_template( current_user: AuthUser = Depends(require_asset_read()), ): """Download an XLSX template for bulk asset intake.""" from openpyxl import Workbook from openpyxl.styles import Alignment, Font, PatternFill wb = Workbook() ws = wb.active ws.title = "Asset Intake Template" headers = [ "asset_tag", "asset_name", "category", "brand", "model", "serial_number", "source_type", "device_status", "order_number", "purchase_date", "purchase_cost", "currency", "invoice_number", "warranty_until", "usage_limit_months", "supplier_name", "supplier_contact", "supplier_phone", "maintenance_note", "location", "owner_company", "usage_company", "purchase_entity", "expected_return_date", "print_status", "rfid", "notes", ] hints = [ "IT-BJ-LT-202603-001", "MacBook Pro 14", "laptop", "Apple", "MacBook Pro 14 M3", "C02XX123XXXX", "purchased", "normal", "PO-2026-001", "2026-03-01", "12999.00", "CNY", "INV-2026-001", "2029-03-01", "36", "Apple Store", "张三", "13800138000", "", "北京", "平凯星辰(北京)科技有限公司", "平凯星辰(北京)科技有限公司", "平凯星辰(北京)科技有限公司", "", "", "", "", ] notes_row = [ "*必填", "", f"*必填: {', '.join(['laptop','desktop','monitor','printer','server','network_device','accessory','conference_display'])}", "", "", "", "purchased/leased/other", "normal/faulty/under_repair/pending_repair", "", "YYYY-MM-DD", "数字", "CNY/USD/EUR/JPY/SGD/MYR/THB", "", "YYYY-MM-DD", "整数(月)", "", "", "", "", "", "", "", "", "YYYY-MM-DD", "", "", "", ] header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") header_font = Font(color="FFFFFF", bold=True, size=11) hint_fill = PatternFill(start_color="D9E2F3", end_color="D9E2F3", fill_type="solid") note_font = Font(color="808080", italic=True, size=9) for col_idx, h in enumerate(headers, 1): cell = ws.cell(row=1, column=col_idx, value=h) cell.fill = header_fill cell.font = header_font cell.alignment = Alignment(horizontal="center") for col_idx, v in enumerate(hints, 1): cell = ws.cell(row=2, column=col_idx, value=v) cell.fill = hint_fill for col_idx, v in enumerate(notes_row, 1): cell = ws.cell(row=3, column=col_idx, value=v) cell.font = note_font for col_idx in range(1, len(headers) + 1): ws.column_dimensions[ws.cell(row=1, column=col_idx).column_letter].width = 18 buf = io.BytesIO() wb.save(buf) buf.seek(0) return StreamingResponse( buf, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": "attachment; filename=asset_intake_template.xlsx"}, ) @router.get("/bulk-update/template") async def download_update_template( current_user: AuthUser = Depends(require_asset_read()), ): """Download an XLSX template for bulk asset update.""" from openpyxl import Workbook from openpyxl.styles import Alignment, Font, PatternFill wb = Workbook() ws = wb.active ws.title = "Asset Update Template" # #1797: ``status`` was removed from AssetUpdateRequest on 2026-05-04 # but the template kept advertising it (with an enum hint). Pydantic's # default ``extra='ignore'`` then silently dropped the column, so a # bulk status change reported "updated" per row while nothing changed. # Drop the un-updatable column from the template so the surface only # advertises fields the bulk-update schema actually accepts. (Asset # status transitions go through the dedicated lifecycle endpoints — # claim / return / dispose / repair — not free-form bulk edit.) headers = [ "asset_tag", "asset_name", "category", "brand", "model", "serial_number", "device_status", "source_type", "order_number", "purchase_date", "purchase_cost", "currency", "invoice_number", "warranty_until", "usage_limit_months", "supplier_name", "supplier_contact", "supplier_phone", "maintenance_note", "location", "owner_company", "usage_company", "purchase_entity", "expected_return_date", "print_status", "rfid", "notes", ] hints = [ "IT-BJ-LT-202603-001", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "上海", "", "PingCAP Pte. Ltd.", "", "", "", "", "更新备注示例", ] notes_row = [ "*必填(用于匹配资产)", "", f"{', '.join(['laptop','desktop','monitor','printer','server','network_device','accessory','conference_display'])}", "", "", "", "normal/faulty/under_repair/pending_repair", "purchased/leased/other", "", "YYYY-MM-DD", "数字", "CNY/USD/EUR/JPY/SGD/MYR/THB", "", "YYYY-MM-DD", "整数(月)", "", "", "", "", "", "", "", "", "YYYY-MM-DD", "", "", "只填需要更新的列", ] header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") header_font = Font(color="FFFFFF", bold=True, size=11) hint_fill = PatternFill(start_color="D9E2F3", end_color="D9E2F3", fill_type="solid") note_font = Font(color="808080", italic=True, size=9) for col_idx, h in enumerate(headers, 1): cell = ws.cell(row=1, column=col_idx, value=h) cell.fill = header_fill cell.font = header_font cell.alignment = Alignment(horizontal="center") for col_idx, v in enumerate(hints, 1): cell = ws.cell(row=2, column=col_idx, value=v) cell.fill = hint_fill for col_idx, v in enumerate(notes_row, 1): cell = ws.cell(row=3, column=col_idx, value=v) cell.font = note_font for col_idx in range(1, len(headers) + 1): ws.column_dimensions[ws.cell(row=1, column=col_idx).column_letter].width = 18 buf = io.BytesIO() wb.save(buf) buf.seek(0) return StreamingResponse( buf, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": "attachment; filename=asset_update_template.xlsx"}, ) @router.post("/intake/bulk") async def bulk_intake_assets( request: Request, file: UploadFile = File(...), current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): """Bulk intake assets from uploaded CSV/XLSX (global admin only). Same restriction as bulk_update_assets: the Excel rows determine the target region, so region-scoped enforcement can't run before parsing the file. Excel mass-intake is an IT-admin power tool. """ validate_bulk_upload_contract(file) service = AssetService(db) result = await service.bulk_intake_assets(file, current_user.id) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.bulk_intake", resource_type="asset", resource_id=file.filename or "bulk", status="success", detail={ "file_name": file.filename, "total_rows": result.total_rows, "successful_rows": result.successful_rows, "failed_rows": result.failed_rows, }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() return success_response( data=BulkAssetMutationResponse.model_validate(result), request=request, ) @router.post("/bulk-update") async def bulk_update_assets( request: Request, file: UploadFile = File(...), current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): """Bulk update assets from uploaded CSV/XLSX (global admin only). Region-scoped asset_admins cannot perform Excel bulk updates — the file's contents determine which assets get touched, and the region gate can't be applied row-by-row without parsing the Excel first. Bulk Excel import is an IT-admin power tool by design (cross-region transfers, mass tag re-assignment); narrow it to global_admin to match. asset_admin still gets every in-app mutation path. """ validate_bulk_upload_contract(file) service = AssetService(db) result = await service.bulk_update_assets(file, current_user.id) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.bulk_update", resource_type="asset", resource_id=file.filename or "bulk", status="success", detail={ "file_name": file.filename, "total_rows": result.total_rows, "successful_rows": result.successful_rows, "failed_rows": result.failed_rows, }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() return success_response( data=BulkAssetMutationResponse.model_validate(result), request=request, ) @router.post("/bulk-claim") async def bulk_claim_assets( request: Request, body: BulkClaimRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Batch claim/dispatch multiple assets to the same user.""" await _enforce_asset_region_bulk(db, current_user, list(body.asset_ids)) await _assert_target_person_in_region(db, current_user, body.to_user_id) service = AssetService(db) result = await service.bulk_claim_assets(body, current_user.id) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.bulk_claim", resource_type="asset", resource_id=str(body.to_user_id), status="success", detail={ "to_user_id": str(body.to_user_id), "total_rows": result.total_rows, "successful_rows": result.successful_rows, "failed_rows": result.failed_rows, "idempotency_key": body.idempotency_key, }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() return success_response( data=BulkAssetMutationResponse.model_validate(result), request=request, ) @router.post("/bulk-borrow") async def bulk_borrow_assets( request: Request, body: BulkBorrowRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Batch borrow multiple assets to the same user.""" await _enforce_asset_region_bulk(db, current_user, list(body.asset_ids)) await _assert_target_person_in_region(db, current_user, body.to_user_id) service = AssetService(db) result = await service.bulk_borrow_assets(body, current_user.id) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.bulk_borrow", resource_type="asset", resource_id=str(body.to_user_id), status="success", detail={ "to_user_id": str(body.to_user_id), "total_rows": result.total_rows, "successful_rows": result.successful_rows, "failed_rows": result.failed_rows, "idempotency_key": body.idempotency_key, }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() return success_response( data=BulkAssetMutationResponse.model_validate(result), request=request, ) @router.post("/bulk-restore") async def bulk_restore_assets( request: Request, body: BulkRestoreRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Batch restore soft-deleted assets from the recycle bin. Per-row semantics (platform batch convention): * each ``restore_asset`` call runs inside its own SAVEPOINT — one bad row never poisons the rest of the batch; * rows that are not soft-deleted count as ``skipped`` (idempotent — a replayed selection of already-restored assets is a no-op); * missing ids and out-of-region rows count as ``failed`` with a per-row error message; * one aggregate audit row (``asset.bulk_restore``) for the whole call. Idempotency: the REQUIRED ``idempotency_key`` goes through ``app.core.idempotency.IdempotencyService`` (scoped per operator) — a replay with the same key returns the cached response verbatim and re-executes nothing. RBAC matches the single-row restore exactly: ``require_asset_admin`` at the route plus per-asset ``assert_can_manage_asset`` region scoping (checked on the directly loaded row so soft-deleted assets pass the lookup, same as the single-row endpoint). """ idem = IdempotencyService( db, scope=f"asset.bulk_restore:{current_user.id}", key=body.idempotency_key, ) cached = await idem.lookup() if cached is not None: return cached service = AssetService(db) results: list[dict[str, Any]] = [] succeeded = skipped = failed = 0 try: for asset_id in body.asset_ids: asset = await db.get(AssetItem, asset_id) if asset is None: failed += 1 results.append({ "id": str(asset_id), "status": "failed", "error": "asset not found", }) continue if asset.deleted_at is None: skipped += 1 results.append({"id": str(asset_id), "status": "skipped"}) continue try: await assert_can_manage_asset(db, current_user, asset) async with db.begin_nested(): await service.restore_asset(asset_id, current_user.id) succeeded += 1 results.append({"id": str(asset_id), "status": "ok"}) except (AppException, ValueError) as exc: failed += 1 results.append({ "id": str(asset_id), "status": "failed", "error": str(getattr(exc, "message", None) or exc), }) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.bulk_restore", resource_type="asset", resource_id=f"batch:{len(body.asset_ids)}", status="success", detail={ "total": len(body.asset_ids), "succeeded": succeeded, "skipped": skipped, "failed": failed, "ids": [str(i) for i in body.asset_ids[:50]], "idempotency_key": body.idempotency_key, }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() except Exception: await idem.mark_failed() raise response = success_response( data={ "succeeded": succeeded, "skipped": skipped, "failed": failed, "results": results, }, request=request, ) await idem.record(response) return response # =========================================================================== # Software Catalog CRUD (must be before {asset_id} routes) # =========================================================================== SOFTWARE_DEPARTMENT_FALLBACK = "IT" # The IT team whose ownership / support footprint the "IT 服务架构" tab # visualises. Members are resolved by primary org-membership against this # org-node name and every descendant (subtree semantics — a sub-team like # "IT Helpdesk" under "IT Services & Security" still counts as IT). Exposed # as a query-param default so the lens can be re-pointed without a code # change if the org renames the team. IT_TEAM_DEPARTMENT = "IT Services & Security" async def _resolve_software_department( db: AsyncSession, owner_person_id: UUID | None, ) -> str: """Derive a software-catalog row's ``department`` from its owner. Rule (2026-05-02): the department field is a derived attribute — it always mirrors the owner's primary org membership name. If the row has no owner, or the owner has no active primary membership, we fall back to ``"IT"`` so every row stays bucketable. """ if owner_person_id is None: return SOFTWARE_DEPARTMENT_FALLBACK from app.identity.person_models import IdentityOrgMembership row = ( await db.execute( select(IdentityOrgMembership.org_node_name) .where( IdentityOrgMembership.person_id == owner_person_id, IdentityOrgMembership.is_primary.is_(True), IdentityOrgMembership.deleted_at.is_(None), ) .limit(1) ) ).scalar_one_or_none() return row or SOFTWARE_DEPARTMENT_FALLBACK async def _enrich_software_catalog(db: AsyncSession, rows: list) -> list[dict]: """Enrich software catalog rows with owner/it_contact person name + email. The frontend renders these via the shared which uses email as a tooltip + secondary fallback for the label. """ from app.identity.person_models import IdentityPerson person_ids = set() for r in rows: if r.owner_person_id: person_ids.add(r.owner_person_id) if r.it_contact_person_id: person_ids.add(r.it_contact_person_id) # (display_name, work_email, employee_no, employment_status) — 工号 + # 在职状态让前端 PersonTag 把离职 owner / IT 联系人标灰显示。 person_map: dict[str, tuple[str | None, str | None, str | None, str | None]] = {} if person_ids: result = await db.execute( select( IdentityPerson.id, IdentityPerson.display_name, IdentityPerson.work_email, IdentityPerson.employee_no, IdentityPerson.employment_status, ).where(IdentityPerson.id.in_(person_ids)) ) person_map = { str(r.id): (r.display_name, r.work_email, r.employee_no, r.employment_status) for r in result } enriched = [] for r in rows: d = SoftwareCatalogResponse.model_validate(r).model_dump() owner = person_map.get(str(r.owner_person_id)) if r.owner_person_id else None contact = person_map.get(str(r.it_contact_person_id)) if r.it_contact_person_id else None d["owner_person_name"] = owner[0] if owner else None d["owner_person_email"] = owner[1] if owner else None d["owner_person_employee_no"] = owner[2] if owner else None d["owner_person_employment_status"] = owner[3] if owner else None d["it_contact_person_name"] = contact[0] if contact else None d["it_contact_person_email"] = contact[1] if contact else None d["it_contact_person_employee_no"] = contact[2] if contact else None d["it_contact_person_employment_status"] = contact[3] if contact else None enriched.append(d) return enriched async def _enrich_business_payments(db: AsyncSession, rows: list) -> list[dict]: """Enrich business payment rows with requester person name + email.""" from app.identity.person_models import IdentityPerson person_ids = {r.requester_person_id for r in rows if r.requester_person_id} person_map: dict[str, tuple[str | None, str | None]] = {} if person_ids: result = await db.execute( select( IdentityPerson.id, IdentityPerson.display_name, IdentityPerson.work_email, ).where(IdentityPerson.id.in_(person_ids)) ) person_map = {str(r.id): (r.display_name, r.work_email) for r in result} enriched = [] for r in rows: d = BusinessPaymentResponse.model_validate(r).model_dump() d["software_type"] = _normalize_software_type(d.get("software_type")) person = person_map.get(str(r.requester_person_id)) if r.requester_person_id else None d["requester_person_name"] = person[0] if person else None d["requester_person_email"] = person[1] if person else None enriched.append(d) return enriched def _normalize_software_type(value: str | None) -> str | None: if value == "infrna": return "infra" return value @router.get( "/reference-data/people-picker", # Pre-launch hardening 2026-05-16 follow-up (SS1 P2) + # Round-2 review (SS1 P2-3): rate-limit the org-chart enumeration. # Any authenticated employee can call this — without a cap, a # script could mass-enumerate the entire active-person set every # second. The dedicated ``check_directory_read_rate_limit`` (600 # calls/hour) is wide enough that legitimate multi-tab interactive # use (SoftwareCatalogPage / RelationshipGraphPage / # EmailGroupDirectoryPage / inline pickers) never 429s, while # script-based harvesting of a 5k-person org would need 8+ hours # under the cap. The earlier ``check_sensitive_endpoint_rate_limit`` # (10/h) was too tight for normal interactive use. dependencies=[Depends(check_directory_read_rate_limit)], ) async def list_people_picker( request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Lightweight active-person directory for any authenticated user. Powers the owner / IT-contact dropdowns on the software catalog edit form (and any future self-service form that needs a person picker). We intentionally expose only ``id`` and ``display_name`` so an employee can hand their software row over to a named successor — no email, no department, nothing that would constitute a PII leak beyond what the org chart already shows. Admin-only endpoints (``/users``, ``/reference-data/business-payment-requesters``) remain for pages that need the full user record. """ from app.identity.person_models import IdentityPerson result = await db.execute( select(IdentityPerson.id, IdentityPerson.display_name) .where( IdentityPerson.employment_status == "active", IdentityPerson.deleted_at.is_(None), ) .order_by( func.coalesce(IdentityPerson.display_name, ""), IdentityPerson.id, ) ) data = [ {"id": str(row.id), "display_name": row.display_name or ""} for row in result ] return success_response(data=data, request=request) @router.get("/reference-data/business-payment-requesters") async def list_business_payment_requesters( request: Request, current_user: AuthUser = Depends(require_software_read()), db: AsyncSession = Depends(get_db), ): from app.identity.person_models import IdentityPerson result = await db.execute( select(IdentityPerson.id, IdentityPerson.display_name, IdentityPerson.work_email) .where(IdentityPerson.employment_status == "active") .order_by( func.coalesce(IdentityPerson.display_name, IdentityPerson.work_email), IdentityPerson.id, ) ) data = [ { "id": str(row.id), "display_name": row.display_name or row.work_email, } for row in result ] return success_response(data=data, request=request) @router.get("/software-catalog") async def list_software_catalog( request: Request, page: int = Query(1, ge=1), # Upper bound raised to 1000 (2026-04-21) so the software- # connector binding dropdown in /admin/integrations can load the # full catalog in one request — typical orgs have 50-300 entries, # not paginating that dropdown is better UX. Frontend uses 500. page_size: int = Query(50, ge=1, le=1000), status: str | None = None, keyword: str | None = None, department: str | None = None, asset_type: str | None = None, owner_person_id: UUID | None = None, offboarding_policy: str | None = None, expiring_within_days: int | None = None, current_user: AuthUser = Depends(require_software_read()), db: AsyncSession = Depends(get_db), ): query = select(AssetSoftwareCatalog).order_by(AssetSoftwareCatalog.name) if status: query = query.where(AssetSoftwareCatalog.status == status) if keyword: # Search across name, version, remark, department, and license_info # so the admin's "Slack" / "Enterprise" / "Sales" query hits # whatever field has the text rather than only matching the name. pattern = f"%{keyword}%" from sqlalchemy import or_ query = query.where( or_( AssetSoftwareCatalog.name.ilike(pattern), AssetSoftwareCatalog.version.ilike(pattern), AssetSoftwareCatalog.remark.ilike(pattern), AssetSoftwareCatalog.department.ilike(pattern), AssetSoftwareCatalog.license_info.ilike(pattern), AssetSoftwareCatalog.owner.ilike(pattern), ) ) if department: query = query.where(AssetSoftwareCatalog.department == department) if asset_type: query = query.where(AssetSoftwareCatalog.asset_type == asset_type) if owner_person_id: query = query.where(AssetSoftwareCatalog.owner_person_id == owner_person_id) if offboarding_policy: query = query.where(AssetSoftwareCatalog.offboarding_policy == offboarding_policy) if expiring_within_days is not None and expiring_within_days >= 0: from datetime import date as _date from datetime import timedelta as _td cutoff = _date.today() + _td(days=expiring_within_days) query = query.where( AssetSoftwareCatalog.expiry_date.is_not(None), AssetSoftwareCatalog.expiry_date <= cutoff, ) from sqlalchemy import func as sa_func count_q = select(sa_func.count()).select_from(query.subquery()) total = (await db.execute(count_q)).scalar() or 0 rows = (await db.execute(query.offset((page - 1) * page_size).limit(page_size))).scalars().all() enriched = await _enrich_software_catalog(db, list(rows)) return paginated_response( data=[SoftwareCatalogResponse(**d) for d in enriched], total=total, page=page, page_size=page_size, request=request, ) @router.get("/software-catalog/it-service-architecture") async def software_it_service_architecture( request: Request, team: str = Query(IT_TEAM_DEPARTMENT, max_length=128), current_user: AuthUser = Depends(require_software_read()), db: AsyncSession = Depends(get_db), ): """Aggregate the software catalog into an "IT service architecture" view: how many catalog entries the IT team *owns* (software ``owner`` is an IT-team member) versus *supports* (software ``it_contact`` is an IT-team member), broken down by software category, down to the named software in each bucket. "IT team" = everyone whose primary org membership is ``team`` (default ``IT Services & Security``) or any descendant — reusing the shared subtree primitive so this lens agrees with the software-cost / department filters on what the team contains. Owned and supported sets overlap (a row can be both); the headline ``both_count`` / ``covered_count`` make the overlap explicit, and each leaf carries ``also_owned`` / ``also_supported`` so the diagram can flag dual rows. Read-gated to the software surface (global_admin / asset_admin / finance / auditor / management) — same matrix as the catalog list. """ from app.core.org_scope import department_membership_subtree_clause from app.identity.person_models import IdentityOrgMembership, IdentityPerson # 1. Resolve IT-team member person_ids (primary membership, subtree). # Exclude leavers (employment_status == 'left') — a departed person is # no longer a team member, so they must not inflate the headcount nor # be credited with owning/supporting software. (leaver_pending stays: # still employed, mid-offboarding.) Mirrors the active-staff convention # used by the dashboard headcount cards and bu_helpers. member_rows = ( await db.execute( select(IdentityOrgMembership.person_id) .join(IdentityPerson, IdentityPerson.id == IdentityOrgMembership.person_id) .where( IdentityOrgMembership.is_primary.is_(True), IdentityOrgMembership.deleted_at.is_(None), IdentityPerson.employment_status != "left", department_membership_subtree_clause(team), ) ) ).all() member_ids: set[UUID] = {r.person_id for r in member_rows} member_count = len(member_ids) # 2. Load the full catalog (the catalog is the inventory — no status # filter so nothing is silently hidden from the coverage ratio). rows = ( await db.execute( select(AssetSoftwareCatalog).order_by(AssetSoftwareCatalog.name) ) ).scalars().all() # 3. Resolve owner / it_contact display names in one batch. person_ids: set[UUID] = set() for r in rows: if r.owner_person_id: person_ids.add(r.owner_person_id) if r.it_contact_person_id: person_ids.add(r.it_contact_person_id) name_map: dict[UUID, str | None] = {} if person_ids: for pid, dn in ( await db.execute( select(IdentityPerson.id, IdentityPerson.display_name) .where(IdentityPerson.id.in_(person_ids)) ) ).all(): name_map[pid] = dn # 4. Classify every row against the IT-team set. def _leaf(r: AssetSoftwareCatalog, owned: bool, supported: bool) -> dict: return { "id": str(r.id), "name": r.name, "category": r.category or "other", "status": r.status, "owner_person_name": name_map.get(r.owner_person_id) if r.owner_person_id else None, "it_contact_person_name": ( name_map.get(r.it_contact_person_id) if r.it_contact_person_id else None ), "also_owned": owned, "also_supported": supported, } def _person_leaf(r: AssetSoftwareCatalog, dual: bool) -> dict: """Compact software descriptor for the per-person ("人的视角") view.""" return { "id": str(r.id), "name": r.name, "category": r.category or "other", "status": r.status, "dual": dual, } owned_leaves: list[dict] = [] supported_leaves: list[dict] = [] # Per-person rollup: {person_id: {"owned": [...], "supported": [...]}}. # Keyed off member_ids, so leavers are already excluded (step 1). person_owned: dict[UUID, list[dict]] = {} person_supported: dict[UUID, list[dict]] = {} both_count = 0 covered_count = 0 for r in rows: is_owned = r.owner_person_id is not None and r.owner_person_id in member_ids is_supported = ( r.it_contact_person_id is not None and r.it_contact_person_id in member_ids ) dual = is_owned and is_supported if is_owned: owned_leaves.append(_leaf(r, is_owned, is_supported)) person_owned.setdefault(r.owner_person_id, []).append(_person_leaf(r, dual)) if is_supported: supported_leaves.append(_leaf(r, is_owned, is_supported)) person_supported.setdefault(r.it_contact_person_id, []).append( _person_leaf(r, dual) ) if dual: both_count += 1 if is_owned or is_supported: covered_count += 1 # Build the per-person branch: every IT-team member who owns or supports # at least one catalog entry, with the software grouped by relationship. # Sorted by breadth of responsibility (distinct software) then name. by_person: list[dict] = [] for pid in set(person_owned) | set(person_supported): owned = sorted(person_owned.get(pid, []), key=lambda x: x["name"]) supported = sorted(person_supported.get(pid, []), key=lambda x: x["name"]) distinct_ids = {x["id"] for x in owned} | {x["id"] for x in supported} by_person.append({ "person_id": str(pid), "person_name": name_map.get(pid) or "—", "owned": owned, "supported": supported, "owned_count": len(owned), "supported_count": len(supported), "total_count": len(distinct_ids), }) by_person.sort(key=lambda p: (-p["total_count"], p["person_name"])) def _group_by_category(leaves: list[dict]) -> list[dict]: buckets: dict[str, list[dict]] = {} for leaf in leaves: buckets.setdefault(leaf["category"], []).append(leaf) # Largest category first; ties broken by name for stable rendering. return [ {"category": cat, "count": len(items), "items": items} for cat, items in sorted( buckets.items(), key=lambda kv: (-len(kv[1]), kv[0]) ) ] total_software = len(rows) payload = { "team": {"name": team, "member_count": member_count}, "summary": { "total_software": total_software, "owned_count": len(owned_leaves), "supported_count": len(supported_leaves), "both_count": both_count, "covered_count": covered_count, "uncovered_count": total_software - covered_count, "coverage_pct": ( round(covered_count / total_software * 100, 1) if total_software else 0.0 ), }, "branches": [ {"key": "owned", "count": len(owned_leaves), "categories": _group_by_category(owned_leaves)}, {"key": "supported", "count": len(supported_leaves), "categories": _group_by_category(supported_leaves)}, ], "by_person": by_person, } return success_response(data=payload, request=request) @router.post("/software-catalog") async def create_software_catalog( request: Request, body: SoftwareCatalogCreateRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): payload = body.model_dump(exclude_none=True) # ``department`` is derived from the owner's primary org membership; # any client-supplied value is ignored so the field can't drift. payload.pop("department", None) payload["department"] = await _resolve_software_department( db, payload.get("owner_person_id"), ) item = AssetSoftwareCatalog(**payload) db.add(item) await db.commit() await db.refresh(item) enriched = await _enrich_software_catalog(db, [item]) return success_response(data=SoftwareCatalogResponse(**enriched[0]), request=request) @router.get("/software-catalog/public") async def list_software_catalog_public( request: Request, page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=1000), status: str | None = None, keyword: str | None = None, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List software catalog — accessible to all authenticated users. Page-size upper bound matches the admin endpoint (1000) so employee flows like the 软件坐席申请 dropdown can load the full active catalog in one request without truncation. """ query = select(AssetSoftwareCatalog).order_by(AssetSoftwareCatalog.name) if status: query = query.where(AssetSoftwareCatalog.status == status) if keyword: query = query.where(AssetSoftwareCatalog.name.ilike(f"%{keyword}%")) from sqlalchemy import func as sa_func count_q = select(sa_func.count()).select_from(query.subquery()) total = (await db.execute(count_q)).scalar() or 0 rows = (await db.execute(query.offset((page - 1) * page_size).limit(page_size))).scalars().all() enriched = await _enrich_software_catalog(db, list(rows)) return paginated_response( data=[SoftwareCatalogResponse(**d) for d in enriched], total=total, page=page, page_size=page_size, request=request, ) @router.get("/software-catalog/public/{item_id}") async def get_software_catalog_public( item_id: UUID, request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Get software detail — accessible to all authenticated users.""" row = (await db.execute( select(AssetSoftwareCatalog).where(AssetSoftwareCatalog.id == item_id) )).scalar_one_or_none() if not row: raise AppException(code="NOT_FOUND", message="Software not found", status_code=404) enriched = await _enrich_software_catalog(db, [row]) return success_response(data=SoftwareCatalogResponse(**enriched[0]), request=request) # Hard ceiling for the software-catalog export. Production currently holds # ~50 rows; 5k is a healthy headroom that still keeps the worst-case xlsx # under a few hundred KB. SOFTWARE_CATALOG_EXPORT_ROW_LIMIT = 5000 @router.get("/software-catalog/export.xlsx") async def export_software_catalog( request: Request, status: str | None = None, keyword: str | None = None, department: str | None = None, asset_type: str | None = None, owner_person_id: UUID | None = None, offboarding_policy: str | None = None, expiring_within_days: int | None = None, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Export the software catalog (filtered) as XLSX — asset/IT admin only. Filter signature is a strict superset of :func:`list_software_catalog` so the download mirrors whatever the admin is looking at on screen (no pagination). Hard-capped at :data:`SOFTWARE_CATALOG_EXPORT_ROW_LIMIT` rows. """ from openpyxl import Workbook from openpyxl.styles import Alignment, Font, PatternFill query = select(AssetSoftwareCatalog).order_by(AssetSoftwareCatalog.name) if status: query = query.where(AssetSoftwareCatalog.status == status) if keyword: pattern = f"%{keyword}%" query = query.where( or_( AssetSoftwareCatalog.name.ilike(pattern), AssetSoftwareCatalog.version.ilike(pattern), AssetSoftwareCatalog.remark.ilike(pattern), AssetSoftwareCatalog.department.ilike(pattern), AssetSoftwareCatalog.license_info.ilike(pattern), AssetSoftwareCatalog.owner.ilike(pattern), ) ) if department: query = query.where(AssetSoftwareCatalog.department == department) if asset_type: query = query.where(AssetSoftwareCatalog.asset_type == asset_type) if owner_person_id: query = query.where(AssetSoftwareCatalog.owner_person_id == owner_person_id) if offboarding_policy: query = query.where( AssetSoftwareCatalog.offboarding_policy == offboarding_policy, ) if expiring_within_days is not None and expiring_within_days >= 0: from datetime import date as _date from datetime import timedelta as _td cutoff = _date.today() + _td(days=expiring_within_days) query = query.where( AssetSoftwareCatalog.expiry_date.is_not(None), AssetSoftwareCatalog.expiry_date <= cutoff, ) total = (await db.execute( select(func.count()).select_from(query.subquery()), )).scalar() or 0 rows = (await db.execute( query.limit(SOFTWARE_CATALOG_EXPORT_ROW_LIMIT), )).scalars().all() enriched = await _enrich_software_catalog(db, list(rows)) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.software_catalog.export", resource_type="asset_software_catalog", resource_id=None, status="success", detail={ "row_count": len(enriched), "total_matched": total, "truncated": total > len(enriched), "filters": { "status": status, "keyword": keyword, "department": department, "asset_type": asset_type, "owner_person_id": ( str(owner_person_id) if owner_person_id else None ), "offboarding_policy": offboarding_policy, "expiring_within_days": expiring_within_days, }, }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() headers: list[tuple[str, str]] = [ ("name", "软件名称"), ("version", "版本"), ("asset_type", "类型"), ("category", "分类"), ("status", "状态"), ("owner_person_name", "负责人"), ("owner_person_email", "负责人邮箱"), ("it_contact_person_name", "IT 联系人"), ("it_contact_person_email", "IT 联系人邮箱"), ("department", "部门"), ("login_url", "登录地址"), ("expiry_date", "到期日期"), ("license_info", "License 信息"), ("offboarding_policy", "离职策略"), ("remark", "备注"), ("description", "详细介绍"), ("created_at", "创建时间"), ("updated_at", "更新时间"), ] field_keys = [k for k, _ in headers] label_row = [label for _, label in headers] machine_row = [k for k, _ in headers] wb = Workbook() ws = wb.active ws.title = "SoftwareCatalog" header_fill = PatternFill( start_color="4472C4", end_color="4472C4", fill_type="solid", ) header_font = Font(color="FFFFFF", bold=True, size=11) machine_fill = PatternFill( start_color="D9E2F3", end_color="D9E2F3", fill_type="solid", ) machine_font = Font(color="555555", italic=True, size=9) for col_idx, label in enumerate(label_row, 1): cell = ws.cell(row=1, column=col_idx, value=label) cell.fill = header_fill cell.font = header_font cell.alignment = Alignment(horizontal="center") for col_idx, key in enumerate(machine_row, 1): cell = ws.cell(row=2, column=col_idx, value=key) cell.fill = machine_fill cell.font = machine_font cell.alignment = Alignment(horizontal="center") # CSV / Excel formula-injection defense — route through the shared # ``to_cell`` (single source of truth for the neutralised prefix set, # incl. ``\t``/``\r`` DDE vectors, plus naive-datetime-safe coercion). # Catalog rows include admin-typed free-text and HRIS-sourced names. for row_idx, item in enumerate(enriched, start=3): for col_idx, key in enumerate(field_keys, start=1): ws.cell(row=row_idx, column=col_idx, value=to_cell(item.get(key))) for col_idx in range(1, len(field_keys) + 1): ws.column_dimensions[ ws.cell(row=1, column=col_idx).column_letter ].width = 20 buf = io.BytesIO() wb.save(buf) buf.seek(0) filename = ( f"software_catalog_{_dt.now(UTC).strftime('%Y%m%d_%H%M%S')}.xlsx" ) return StreamingResponse( buf, media_type=( "application/vnd.openxmlformats-officedocument." "spreadsheetml.sheet" ), headers={ "Content-Disposition": f'attachment; filename="{filename}"', "X-Software-Catalog-Export-Total-Matched": str(total), "X-Software-Catalog-Export-Returned": str(len(enriched)), "X-Software-Catalog-Export-Truncated": ( "true" if total > len(enriched) else "false" ), }, ) @router.get("/software-catalog/{item_id}") async def get_software_catalog( request: Request, item_id: UUID, current_user: AuthUser = Depends(require_software_read()), db: AsyncSession = Depends(get_db), ): item = await db.get(AssetSoftwareCatalog, item_id) if not item: from app.core.exceptions import NotFoundError raise NotFoundError("Software catalog item not found") enriched = await _enrich_software_catalog(db, [item]) return success_response(data=SoftwareCatalogResponse(**enriched[0]), request=request) @router.put("/software-catalog/{item_id}") async def update_software_catalog( request: Request, item_id: UUID, body: SoftwareCatalogUpdateRequest, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Update software catalog metadata. Permission model: * Asset/global admins can edit any row. * Any non-admin can edit a row they are listed as owner or IT contact of — ``owner_person_id`` / ``it_contact_person_id`` match the caller. This gives the "software owner" role the ability to self-service their own SaaS listing (e.g. update the login URL, the remark, hand the row over to a successor) without asking IT to do it. Non-admin callers cannot change the ``name`` (that's the stable identifier we key seats / connectors off of) or ``status`` — those stay behind the admin gate. """ item = await db.get(AssetSoftwareCatalog, item_id) if not item: from app.core.exceptions import NotFoundError raise NotFoundError("Software catalog item not found") is_admin = can_manage_assets(current_user.role_names) is_owner = ( item.owner_person_id == current_user.id or item.it_contact_person_id == current_user.id ) if not is_admin and not is_owner: raise AppException( code="FORBIDDEN", message="Only asset admins or the software's listed owner/IT contact can edit.", status_code=403, ) patch = body.model_dump(exclude_none=True) if not is_admin: # Non-admins may not change identity / ownership fields — doing so # would let an owner grant edit rights to an arbitrary third party # (it_contact_person_id is also an edit bypass) or transfer # ownership without admin involvement. for locked in ("name", "status", "owner_person_id", "it_contact_person_id"): patch.pop(locked, None) # ``department`` is derived from the owner — drop any client-supplied # value, then recompute whenever the owner is being changed. patch.pop("department", None) prev_owner_id = item.owner_person_id prev_it_contact_id = item.it_contact_person_id for k, v in patch.items(): setattr(item, k, v) if "owner_person_id" in patch or item.department in (None, ""): item.department = await _resolve_software_department( db, item.owner_person_id, ) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action="asset.software_catalog.update", resource_type="asset_software_catalog", resource_id=str(item_id), status="success", detail={ "name": item.name, "changed_fields": sorted(patch.keys()), "by_admin": is_admin, "owner_changed": ( "owner_person_id" in patch and item.owner_person_id != prev_owner_id ), "it_contact_changed": ( "it_contact_person_id" in patch and item.it_contact_person_id != prev_it_contact_id ), }, ip_address=get_real_ip(request), ) await db.commit() await db.refresh(item) enriched = await _enrich_software_catalog(db, [item]) return success_response(data=SoftwareCatalogResponse(**enriched[0]), request=request) @router.delete("/software-catalog/{item_id}") async def delete_software_catalog( request: Request, item_id: UUID, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Delete a software catalog entry — admin only. Refuses with 409 when ANY of the following exists for the catalog: * Active or revoked ``SoftwareLicensePlan`` rows — hard-delete would have silently CASCADE-wiped every plan (the FK was CASCADE before migration 119). Closed bills carry ``plan_name`` but the ``plan_id`` linkage to the live plan is the only handle for "show me this plan's seat history" queries; losing the plan breaks audit-quality finance reads. * Any ``SoftwareSeatAllocation`` (active OR revoked) referencing a plan attached to this software — even revoked seats are part of the historical record. * Any closed ``SoftwareMonthlyBill`` rows for any plan attached to this software. Pre-launch hardening 2026-05-05 (P0 #8): the previous shape did a hard ``await db.delete(item)`` with no usage check. """ from app.core.exceptions import NotFoundError as _NotFound from app.domains.asset.models import ( SoftwareAddonItem, SoftwareLicensePlan, SoftwareMonthlyBill, SoftwareSeatAllocation, ) item = await db.get(AssetSoftwareCatalog, item_id) if not item: raise _NotFound("Software catalog item not found") # ── Usage guard ─────────────────────────────────────────── plan_count = (await db.execute( select(func.count(SoftwareLicensePlan.id)).where( SoftwareLicensePlan.software_id == item_id, ), )).scalar_one() # One-time add-on items FK this software (RESTRICT). Without this # check a software carrying ONLY add-on items (no license plans) # would skip the plan guard below and hit a raw FK violation (500) # on delete, and any frozen addon bill rows would be orphaned. addon_item_count = (await db.execute( select(func.count(SoftwareAddonItem.id)).where( SoftwareAddonItem.software_id == item_id, ), )).scalar_one() if addon_item_count > 0: addon_bill_count = (await db.execute( select(func.count(SoftwareMonthlyBill.id)).where( SoftwareMonthlyBill.software_id == item_id, SoftwareMonthlyBill.line_type == "addon", ), )).scalar_one() raise AppException( code="SOFTWARE_HAS_DEPENDENT_ADDONS", message=( f"Cannot delete software {item.name!r}: it has " f"{addon_item_count} one-time add-on item(s) and " f"{addon_bill_count} historical add-on bill row(s). " "Set the add-on items inactive and archive the software " "instead." ), status_code=409, ) if plan_count > 0: # Find related rows for the error message so the admin sees # exactly what's blocking the delete. plan_ids = [ row.id for row in (await db.execute( select(SoftwareLicensePlan.id).where( SoftwareLicensePlan.software_id == item_id, ).limit(50), )).all() ] seat_count = 0 bill_count = 0 if plan_ids: seat_count = (await db.execute( select(func.count(SoftwareSeatAllocation.id)).where( SoftwareSeatAllocation.plan_id.in_(plan_ids), ), )).scalar_one() bill_count = (await db.execute( select(func.count(SoftwareMonthlyBill.id)).where( SoftwareMonthlyBill.plan_id.in_(plan_ids), ), )).scalar_one() raise AppException( code="SOFTWARE_HAS_DEPENDENT_PLANS", message=( f"Cannot delete software {item.name!r}: it has " f"{plan_count} license plan(s), {seat_count} seat " f"allocation(s), and {bill_count} historical bill row(s). " "Delete the plans first (which itself requires zero " "active seats), or archive the software instead." ), status_code=409, ) snapshot = { "name": item.name, "asset_type": item.asset_type, "category": item.category, "vendor": item.vendor, } await db.delete(item) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action="asset.software_catalog.delete", resource_type="asset_software_catalog", resource_id=str(item_id), status="success", detail=snapshot, ip_address=get_real_ip(request), ) await db.commit() return success_response(data={"deleted": True}, request=request) # =========================================================================== # Business Payment CRUD # =========================================================================== @router.get("/business-payments") async def list_business_payments( request: Request, page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), software_type: str | None = None, software_catalog_id: UUID | None = Query( default=None, description="按软件目录 ID 过滤,用于在软件详情里看某个软件的所有账单。", ), keyword: str | None = None, current_user: AuthUser = Depends(require_software_read()), db: AsyncSession = Depends(get_db), ): query = select(AssetBusinessPayment).order_by(AssetBusinessPayment.software_name) if software_type: normalized_type = _normalize_software_type(software_type) if normalized_type == "infra": query = query.where(AssetBusinessPayment.software_type.in_(["infra", "infrna"])) else: query = query.where(AssetBusinessPayment.software_type == normalized_type) if software_catalog_id is not None: query = query.where(AssetBusinessPayment.software_catalog_id == software_catalog_id) if keyword: query = query.where(AssetBusinessPayment.software_name.ilike(f"%{keyword}%")) from sqlalchemy import func count_q = select(func.count()).select_from(query.subquery()) total = (await db.execute(count_q)).scalar() or 0 rows = (await db.execute(query.offset((page - 1) * page_size).limit(page_size))).scalars().all() enriched = await _enrich_business_payments(db, list(rows)) return paginated_response( data=[BusinessPaymentResponse(**d) for d in enriched], total=total, page=page, page_size=page_size, request=request, ) @router.post("/business-payments") async def create_business_payment( request: Request, body: BusinessPaymentCreateRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): payload = body.model_dump(exclude_none=True) payload["software_type"] = _normalize_software_type(payload.get("software_type")) if body.software_catalog_id: software = await db.get(AssetSoftwareCatalog, body.software_catalog_id) if not software: raise AppException( code="SOFTWARE_NOT_FOUND", message=f"Software catalog '{body.software_catalog_id}' not found", status_code=404, ) payload["software_name"] = software.name if body.procurement_order_id: order = await _require_procurement_order(db, body.procurement_order_id) payload["po_number"] = order.order_number if not body.supplier_id and order.supplier_id: payload["supplier_id"] = order.supplier_id supplier_id = payload.get("supplier_id") if supplier_id: supplier = await _require_supplier(db, supplier_id) payload.setdefault("signing_entity", supplier.name) item = AssetBusinessPayment(**payload) db.add(item) await db.commit() await db.refresh(item) enriched = await _enrich_business_payments(db, [item]) return success_response(data=BusinessPaymentResponse(**enriched[0]), request=request) @router.get("/business-payments/{item_id}") async def get_business_payment( request: Request, item_id: UUID, current_user: AuthUser = Depends(require_software_read()), db: AsyncSession = Depends(get_db), ): item = await db.get(AssetBusinessPayment, item_id) if not item: from app.core.exceptions import NotFoundError raise NotFoundError("Business payment not found") enriched = await _enrich_business_payments(db, [item]) return success_response(data=BusinessPaymentResponse(**enriched[0]), request=request) @router.put("/business-payments/{item_id}") async def update_business_payment( request: Request, item_id: UUID, body: BusinessPaymentUpdateRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): item = await db.get(AssetBusinessPayment, item_id) if not item: from app.core.exceptions import NotFoundError raise NotFoundError("Business payment not found") payload = body.model_dump(exclude_none=True) payload["software_type"] = _normalize_software_type(payload.get("software_type")) if body.software_catalog_id: software = await db.get(AssetSoftwareCatalog, body.software_catalog_id) if not software: raise AppException( code="SOFTWARE_NOT_FOUND", message=f"Software catalog '{body.software_catalog_id}' not found", status_code=404, ) payload["software_name"] = software.name if body.procurement_order_id: order = await _require_procurement_order(db, body.procurement_order_id) payload["po_number"] = order.order_number if not body.supplier_id and order.supplier_id: payload["supplier_id"] = order.supplier_id supplier_id = payload.get("supplier_id") if supplier_id: supplier = await _require_supplier(db, supplier_id) payload.setdefault("signing_entity", supplier.name) for k, v in payload.items(): setattr(item, k, v) await db.commit() await db.refresh(item) enriched = await _enrich_business_payments(db, [item]) return success_response(data=BusinessPaymentResponse(**enriched[0]), request=request) @router.delete("/business-payments/{item_id}") async def delete_business_payment( request: Request, item_id: UUID, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): item = await db.get(AssetBusinessPayment, item_id) if not item: from app.core.exceptions import NotFoundError raise NotFoundError("Business payment not found") snapshot = { "software_name": item.software_name, "software_type": item.software_type, "supplier_id": str(item.supplier_id) if item.supplier_id else None, "po_number": item.po_number, } await db.delete(item) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action="asset.business_payment.delete", resource_type="asset_business_payment", resource_id=str(item_id), status="success", detail=snapshot, ip_address=get_real_ip(request), ) await db.commit() return success_response(data={"deleted": True}, request=request) # =========================================================================== # Supplier CRUD # =========================================================================== @router.get("/suppliers") async def list_suppliers( request: Request, keyword: str | None = Query(None, max_length=100, description="Search in name / contact / phone / remark"), is_active: bool | None = Query(None), current_user: AuthUser = Depends(require_software_read()), db: AsyncSession = Depends(get_db), ): from sqlalchemy import or_ query = select(AssetSupplier).order_by(AssetSupplier.name) if keyword: pattern = f"%{keyword}%" query = query.where( or_( AssetSupplier.name.ilike(pattern), AssetSupplier.contact_name.ilike(pattern), AssetSupplier.contact_phone.ilike(pattern), AssetSupplier.remark.ilike(pattern), ) ) if is_active is not None: query = query.where(AssetSupplier.is_active == is_active) # Drop the earlier inline `from sqlalchemy import or_` block — or_ # is now imported at module level. rows = (await db.execute(query)).scalars().all() return success_response( data=[SupplierResponse.model_validate(row) for row in rows], request=request, ) @router.post("/suppliers") async def create_supplier( request: Request, body: SupplierCreateRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): item = AssetSupplier(**body.model_dump(exclude_none=True)) db.add(item) await db.flush() audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action="asset.supplier.create", resource_type="asset_supplier", resource_id=str(item.id), status="success", detail={"name": item.name, "contact_name": item.contact_name}, ip_address=get_real_ip(request), ) await db.commit() await db.refresh(item) return success_response(data=SupplierResponse.model_validate(item), request=request) @router.get("/suppliers/{item_id}") async def get_supplier( request: Request, item_id: UUID, current_user: AuthUser = Depends(require_software_read()), db: AsyncSession = Depends(get_db), ): item = await db.get(AssetSupplier, item_id) if not item: raise AppException( code="SUPPLIER_NOT_FOUND", message=f"Supplier '{item_id}' not found", status_code=404, ) return success_response(data=SupplierResponse.model_validate(item), request=request) @router.put("/suppliers/{item_id}") async def update_supplier( request: Request, item_id: UUID, body: SupplierUpdateRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): item = await db.get(AssetSupplier, item_id) if not item: raise AppException( code="SUPPLIER_NOT_FOUND", message=f"Supplier '{item_id}' not found", status_code=404, ) changes = body.model_dump(exclude_none=True) for k, v in changes.items(): setattr(item, k, v) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action="asset.supplier.update", resource_type="asset_supplier", resource_id=str(item_id), status="success", detail={"name": item.name, "changed_fields": sorted(changes.keys())}, ip_address=get_real_ip(request), ) await db.commit() await db.refresh(item) return success_response(data=SupplierResponse.model_validate(item), request=request) @router.delete("/suppliers/{item_id}") async def delete_supplier( request: Request, item_id: UUID, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): item = await db.get(AssetSupplier, item_id) if not item: raise AppException( code="SUPPLIER_NOT_FOUND", message=f"Supplier '{item_id}' not found", status_code=404, ) # Check if supplier is referenced by procurement orders or business payments procurement_ref = await db.execute( select(func.count()).select_from(AssetProcurementOrder).where( AssetProcurementOrder.supplier_id == item_id ) ) payment_ref = await db.execute( select(func.count()).select_from(AssetBusinessPayment).where( AssetBusinessPayment.supplier_id == item_id ) ) ref_count = (procurement_ref.scalar() or 0) + (payment_ref.scalar() or 0) audit = AuditService(db) if ref_count > 0: # Soft-delete: deactivate instead of hard-delete item.is_active = False await audit.log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action="asset.supplier.deactivate", resource_type="asset_supplier", resource_id=str(item_id), status="success", detail={"name": item.name, "references": ref_count}, ip_address=get_real_ip(request), ) await db.commit() return success_response( data={"deactivated": True, "references": ref_count}, request=request, ) snapshot = {"name": item.name, "contact_name": item.contact_name} await db.delete(item) await audit.log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action="asset.supplier.delete", resource_type="asset_supplier", resource_id=str(item_id), status="success", detail=snapshot, ip_address=get_real_ip(request), ) await db.commit() return success_response(data={"deleted": True}, request=request) # =========================================================================== # Office Address CRUD # =========================================================================== async def _enrich_office_addresses(db: AsyncSession, rows: list) -> list[dict]: """Enrich office rows with the 当地 Admin list and legal entity name. ``admins`` / ``admin_person_name`` describe the office's local IT contacts. They carry no asset authority — that lives on the location (``GET /assets/locations``). """ entity_ids = {r.legal_entity_id for r in rows if r.legal_entity_id} office_ids = [r.id for r in rows] entity_map: dict[str, str] = {} if entity_ids: result = await db.execute( select(AssetLegalEntity.id, AssetLegalEntity.name) .where(AssetLegalEntity.id.in_(entity_ids)) ) entity_map = {str(r.id): r.name for r in result} admins_by_office = await list_office_local_admin_ids_bulk(db, office_ids) enriched = [] for r in rows: d = OfficeAddressResponse.model_validate(r).model_dump() office_admins = admins_by_office.get(r.id, []) d["admins"] = office_admins # ``admin_person_name`` / ``admin_person_id`` are derived from the # join's primary rather than read from the denormalised column, # which is being retired. Same answer, one source. primary = next( (a for a in office_admins if a["is_primary"]), office_admins[0] if len(office_admins) == 1 else None, ) d["admin_person_id"] = primary["person_id"] if primary else None d["admin_person_name"] = primary["person_name"] if primary else None d["legal_entity_name"] = entity_map.get(str(r.legal_entity_id)) if r.legal_entity_id else None enriched.append(d) return enriched @router.get("/office-addresses") async def list_office_addresses( request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): query = select(AssetOfficeAddress).order_by(AssetOfficeAddress.city) rows = (await db.execute(query)).scalars().all() enriched = await _enrich_office_addresses(db, list(rows)) return success_response( data=[OfficeAddressResponse(**d) for d in enriched], request=request, ) @router.post("/office-addresses") async def create_office_address( request: Request, body: OfficeAddressCreateRequest, current_user: AuthUser = Depends(require_foundation_admin()), db: AsyncSession = Depends(get_db), ): payload = body.model_dump(exclude_none=True) # ``admin_person_ids`` is the canonical 当地 Admin list. # ``admin_person_id`` (legacy single value) is honoured as a # fallback for old clients; explicit list wins when both are sent. admin_ids_payload = payload.pop("admin_person_ids", None) legacy_admin_id = payload.pop("admin_person_id", None) if admin_ids_payload: admin_ids = list(admin_ids_payload) elif legacy_admin_id is not None: admin_ids = [legacy_admin_id] else: admin_ids = [] item = AssetOfficeAddress(**payload) db.add(item) await db.flush() # need item.id before populating the join table affected_admin_ids: list[UUID] = [] if admin_ids: affected_admin_ids = await replace_office_local_admins( db, item.id, admin_ids, ) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action="asset.office_address.create", resource_type="asset_office_address", resource_id=str(item.id), status="success", detail={ "city": item.city, "office_name": item.office_name, "legal_entity_id": str(item.legal_entity_id) if item.legal_entity_id else None, "admin_ids": [str(p) for p in admin_ids], "affected_admin_ids": [str(p) for p in affected_admin_ids], }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() await db.refresh(item) enriched = await _enrich_office_addresses(db, [item]) return success_response(data=OfficeAddressResponse(**enriched[0]), request=request) @router.get("/office-addresses/{item_id}") async def get_office_address( request: Request, item_id: UUID, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): item = await db.get(AssetOfficeAddress, item_id) if not item: from app.core.exceptions import NotFoundError raise NotFoundError("Office address not found") enriched = await _enrich_office_addresses(db, [item]) return success_response(data=OfficeAddressResponse(**enriched[0]), request=request) @router.put("/office-addresses/{item_id}") async def update_office_address( request: Request, item_id: UUID, body: OfficeAddressUpdateRequest, current_user: AuthUser = Depends(require_foundation_admin()), db: AsyncSession = Depends(get_db), ): item = await db.get(AssetOfficeAddress, item_id) if not item: from app.core.exceptions import NotFoundError raise NotFoundError("Office address not found") payload = body.model_dump(exclude_unset=True) # Capture fields_changed BEFORE we pop the admin-list keys — # the audit log must surface admin-only edits (otherwise a # security auditor filtering by ``detail.fields_changed`` misses # admin grant/revoke actions that happen via this endpoint). fields_changed_all = list(payload.keys()) # Pop the admin-list keys — they're not direct columns on # AssetOfficeAddress; they drive the asset_office_local_admins # join-table sync. admin_ids_payload = payload.pop("admin_person_ids", None) legacy_admin_id_present = "admin_person_id" in payload legacy_admin_id_value = payload.pop("admin_person_id", None) for k, v in payload.items(): setattr(item, k, v) # Decide whether to touch the admin list. Three cases: # * ``admin_person_ids`` explicitly sent → replace. # * Only legacy ``admin_person_id`` sent — and ONLY when its # value is non-null — treat as single-element list (preserves # old single-admin clients). Sending ``admin_person_id=null`` # does NOT wipe the multi-admin list because that would be # a surprising semantic shift for legacy clients (their # intent: "no primary"; PR-new intent if treated as []: # "clear every admin"). Use ``admin_person_ids=[]`` to clear. # * Neither sent → leave existing rows alone. affected_admin_ids: list[UUID] = [] submitted_admin_ids: list[UUID] | None = None if admin_ids_payload is not None: submitted_admin_ids = list(admin_ids_payload) affected_admin_ids = await replace_office_local_admins( db, item_id, submitted_admin_ids, ) elif legacy_admin_id_present and legacy_admin_id_value is not None: submitted_admin_ids = [legacy_admin_id_value] affected_admin_ids = await replace_office_local_admins( db, item_id, submitted_admin_ids, ) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action="asset.office_address.update", resource_type="asset_office_address", resource_id=str(item_id), status="success", detail={ # Include the admin-list keys (popped earlier from payload) # so audit-log filtering by ``detail.fields_changed`` # surfaces admin-only edits. "fields_changed": fields_changed_all, "admin_ids_submitted": ( [str(p) for p in submitted_admin_ids] if submitted_admin_ids is not None else None ), "affected_admin_ids": [str(p) for p in affected_admin_ids], }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() await db.refresh(item) enriched = await _enrich_office_addresses(db, [item]) return success_response(data=OfficeAddressResponse(**enriched[0]), request=request) @router.delete("/office-addresses/{item_id}") async def delete_office_address( request: Request, item_id: UUID, current_user: AuthUser = Depends(require_foundation_admin()), db: AsyncSession = Depends(get_db), ): item = await db.get(AssetOfficeAddress, item_id) if not item: from app.core.exceptions import NotFoundError raise NotFoundError("Office address not found") snapshot = {"city": item.city, "address": item.address} # Snapshot the 当地 Admin list before the cascade so the audit row # records who was detached. No role re-sync is needed: since # migration 535 this list grants no role, so deleting an office # cannot change anybody's asset authority. (It could before, which # is why the audit detail is kept — an operator reading old and new # entries side by side should see the same shape.) from sqlalchemy import select as _select # noqa: PLC0415 affected_rows = await db.execute( _select(AssetOfficeLocalAdmin.person_id).where( AssetOfficeLocalAdmin.office_id == item_id, ) ) affected_person_ids = [r[0] for r in affected_rows.all()] await db.delete(item) await db.flush() audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action="asset.office_address.delete", resource_type="asset_office_address", resource_id=str(item_id), status="success", detail={ **snapshot, "affected_admin_count": len(affected_person_ids), "affected_admin_ids": [str(p) for p in affected_person_ids], }, ip_address=get_real_ip(request), ) await db.commit() return success_response(data={"deleted": True}, request=request) # --------------------------------------------------------------------------- # Return-defaults (退库 modal auto-fill) # --------------------------------------------------------------------------- @router.get("/return-defaults") async def get_return_defaults( request: Request, admin_id: UUID, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Return the recommended 退库后部门/位置 for the given admin. Drives the 退库 modal auto-fill: when the operator picks 管理员 the frontend hits this endpoint to populate 退库后使用部门 + 退库后位置 so they don't have to retype values the administrator already implies. **Authorization** — ``require_asset_admin()``. The response carries admin-only metadata: ``work_location_name`` (the admin's HRIS-sourced city / state / country, not always a managed-location name) and ``resolved_location`` (the canonical ``asset_locations.name`` the resolver maps them to). Both exist for the 退库 modal pre-fill and MUST NOT be returned by employee-scope or self-service endpoints — no leak of internal location mapping. Resolution: * ``locations`` — every managed location the admin administers, primary first. This is the same set the region gate uses, so the suggestion can never propose a location the operator would then be forbidden to write. * ``department`` — the admin's own HR department, as the closest proxy for 退库后使用部门 (a location has no department). * ``resolved_location`` — the primary administered location, falling back to the admin's own ``work_location_name`` routed through the managed-location resolver. ``None`` when nothing resolves, so the Select stays empty and the operator picks. """ from sqlalchemy.orm import selectinload from app.domains.asset.location_resolver import LocationIndex from app.identity.person_models import IdentityPerson # Eager-load org_memberships so ``person.department`` (a property # over the membership rows) resolves without a lazy SELECT in # async context. person_row = await db.execute( select(IdentityPerson) .options(selectinload(IdentityPerson.org_memberships)) .where(IdentityPerson.id == admin_id) ) person = person_row.scalar_one_or_none() if person is None: raise NotFoundError("Person", str(admin_id)) location_rows = await db.execute( select( AssetLocation.id, AssetLocation.name, AssetLocationAdmin.is_primary, ) .join( AssetLocationAdmin, AssetLocationAdmin.location_id == AssetLocation.id, ) .where(AssetLocationAdmin.person_id == admin_id) .order_by(AssetLocationAdmin.is_primary.desc(), AssetLocation.sort_order) ) locations = [ { "location_id": r.id, "name": r.name, "is_primary": bool(r.is_primary), } for r in location_rows.all() ] index = await LocationIndex.load(db) resolved_location: str | None = None if locations: resolved_location = locations[0]["name"] elif person.work_location_name: resolved_location = index.resolve_name(person.work_location_name) return success_response( data={ "admin_id": str(admin_id), "admin_name": person.display_name, "department": person.department, "work_location_name": person.work_location_name, "resolved_location": resolved_location, "locations": locations, }, request=request, ) # --------------------------------------------------------------------------- # Legal entities # --------------------------------------------------------------------------- # 开票信息 (legal-entity name / tax id / registered address / bank details) # is company-public — every employee legitimately needs it to have a vendor # raise an invoice. But the bank account / SWIFT / tax id are sensitive # enough that we mask them by default for non-stewards and require an # audited reveal, mirroring the identity.person.contact_reveal pattern. # Foundation admins (the people who create/edit the row) read plaintext so # the admin edit form hydrates with real values. _LEGAL_ENTITY_SENSITIVE_FIELDS = ("tax_identifier", "phone", "bank_account", "swift_code") def _can_read_legal_entity_plaintext(role_names) -> bool: return has_role(role_names, GLOBAL_ADMIN_ROLE, ASSET_ADMIN_ROLE, HR_ROLE) def _legal_entity_response(item: AssetLegalEntity, *, mask: bool) -> LegalEntityResponse: resp = LegalEntityResponse.model_validate(item) if mask: resp.tax_identifier = mask_tail(resp.tax_identifier) resp.phone = mask_phone(resp.phone) resp.bank_account = mask_tail(resp.bank_account) resp.swift_code = mask_head_tail(resp.swift_code) resp.sensitive_masked = True return resp @router.get("/legal-entities") async def list_legal_entities( request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): mask = not _can_read_legal_entity_plaintext(current_user.role_names) query = select(AssetLegalEntity).order_by(AssetLegalEntity.name) rows = (await db.execute(query)).scalars().all() return success_response( data=[_legal_entity_response(r, mask=mask) for r in rows], request=request, ) @router.post("/legal-entities") async def create_legal_entity( request: Request, body: LegalEntityCreateRequest, current_user: AuthUser = Depends(require_foundation_admin()), db: AsyncSession = Depends(get_db), ): payload = body.model_dump(exclude_none=True) item = AssetLegalEntity(**payload) db.add(item) await db.flush() audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action="asset.legal_entity.create", resource_type="asset_legal_entity", resource_id=str(item.id), status="success", detail={ "name": item.name, # Never log raw banking/tax PII — only the set of fields set. "fields_set": sorted(payload.keys()), }, ip_address=get_real_ip(request), ) await db.commit() await db.refresh(item) return success_response(data=LegalEntityResponse.model_validate(item), request=request) @router.get("/legal-entities/{item_id}") async def get_legal_entity( request: Request, item_id: UUID, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): item = await db.get(AssetLegalEntity, item_id) if not item: raise NotFoundError("asset_legal_entity", str(item_id)) mask = not _can_read_legal_entity_plaintext(current_user.role_names) return success_response(data=_legal_entity_response(item, mask=mask), request=request) @router.post("/legal-entities/{item_id}/reveal") async def reveal_legal_entity( request: Request, item_id: UUID, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rate: None = Depends(check_sensitive_endpoint_rate_limit), ): """Return the unmasked sensitive 开票/银行 fields for one entity. Open to any authenticated employee — this data is company-public — but every call writes an ``asset.legal_entity.sensitive_reveal`` audit row naming who disclosed it, so reveals stay attributable. The per-user sensitive rate limit (matching the identity contact-reveal / api-key reveal contract this mirrors) caps any single account at ``SENSITIVE_RATE_LIMIT_MAX`` reveals/hour, defeating a tight-loop scrape of every entity's plaintext banking/tax fields. """ item = await db.get(AssetLegalEntity, item_id) if not item: raise NotFoundError("asset_legal_entity", str(item_id)) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action="asset.legal_entity.sensitive_reveal", resource_type="asset_legal_entity", resource_id=str(item_id), status="success", detail={ "entity_name": item.name, "revealed_fields": [ f for f in _LEGAL_ENTITY_SENSITIVE_FIELDS if getattr(item, f) ], }, ip_address=get_real_ip(request), user_agent=request.headers.get("user-agent"), ) await db.commit() return success_response( data=LegalEntityRevealResponse( id=item.id, tax_identifier=item.tax_identifier, phone=item.phone, bank_account=item.bank_account, swift_code=item.swift_code, ), request=request, ) @router.put("/legal-entities/{item_id}") async def update_legal_entity( request: Request, item_id: UUID, body: LegalEntityUpdateRequest, current_user: AuthUser = Depends(require_foundation_admin()), db: AsyncSession = Depends(get_db), ): item = await db.get(AssetLegalEntity, item_id) if not item: raise NotFoundError("asset_legal_entity", str(item_id)) changes = body.model_dump(exclude_none=True) for k, v in changes.items(): setattr(item, k, v) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action="asset.legal_entity.update", resource_type="asset_legal_entity", resource_id=str(item_id), status="success", detail={ "name": item.name, # Never log raw banking/tax PII — only which fields changed. "changed_fields": sorted(changes.keys()), }, ip_address=get_real_ip(request), ) await db.commit() await db.refresh(item) return success_response(data=LegalEntityResponse.model_validate(item), request=request) @router.delete("/legal-entities/{item_id}") async def delete_legal_entity( request: Request, item_id: UUID, current_user: AuthUser = Depends(require_foundation_admin()), db: AsyncSession = Depends(get_db), ): item = await db.get(AssetLegalEntity, item_id) if not item: raise NotFoundError("asset_legal_entity", str(item_id)) # Check if referenced by office addresses ref_count = (await db.execute( select(func.count()).where(AssetOfficeAddress.legal_entity_id == item_id) )).scalar() or 0 audit = AuditService(db) if ref_count > 0: item.is_active = False await audit.log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action="asset.legal_entity.deactivate", resource_type="asset_legal_entity", resource_id=str(item_id), status="success", detail={"name": item.name, "references": ref_count}, ip_address=get_real_ip(request), ) await db.commit() await db.refresh(item) return success_response( data={"deactivated": True, "references": ref_count}, request=request, ) snapshot = {"name": item.name} await db.delete(item) await audit.log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action="asset.legal_entity.delete", resource_type="asset_legal_entity", resource_id=str(item_id), status="success", detail=snapshot, ip_address=get_real_ip(request), ) await db.commit() return success_response(data={"deleted": True}, request=request) # =========================================================================== # Asset lifecycle operations (must be after named routes) # =========================================================================== @router.post("/{asset_id}/claim") async def claim_asset( request: Request, asset_id: UUID, body: AssetClaimRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Claim/dispatch an asset to a user (IT Admin only).""" await _enforce_asset_region(db, current_user, asset_id) await _assert_target_person_in_region( db, current_user, body.to_user_id, target_location=body.location, ) service = AssetService(db) asset, operation = await service.claim_asset(asset_id, body, current_user.id) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.claim", resource_type="asset", resource_id=str(asset.id), status="success", detail={ "asset_tag": asset.asset_tag, "to_user_id": str(body.to_user_id), "operation_number": operation.operation_number, }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() enriched = await service.enrich_with_users([asset]) return success_response( data={ "asset": AssetResponse(**enriched[0]), "operation": await serialize_operation(db, operation), }, request=request, ) @router.post("/{asset_id}/return") async def return_asset( request: Request, asset_id: UUID, body: AssetReturnRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Return an asset to stock (IT Admin only).""" await _enforce_asset_region(db, current_user, asset_id) # #1777: when returning INTO an office (office-held mode), the # destination ``target_office_id`` is otherwise unconstrained — a # regional admin could return any in-region asset into a foreign # office's held pool, pushing it out of their own region entirely. # Gate the destination exactly like recycle-to-office / # bulk-recycle-to-office do. Only an explicit target office needs the # check; the ``resolve_office_for_person`` fallback derives the # returner's own home office, and the idle fallback holds no office. if body.return_target == "office" and body.target_office_id is not None: await _assert_target_office_in_region(db, current_user, body.target_office_id) service = AssetService(db) asset, operation = await service.return_asset(asset_id, body, current_user.id) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.return", resource_type="asset", resource_id=str(asset.id), status="success", detail={"asset_tag": asset.asset_tag, "operation_number": operation.operation_number}, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() enriched = await service.enrich_with_users([asset]) return success_response( data={ "asset": AssetResponse(**enriched[0]), "operation": await serialize_operation(db, operation), }, request=request, ) @router.post("/{asset_id}/borrow") async def borrow_asset( request: Request, asset_id: UUID, body: AssetBorrowRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Temporarily lend an asset (IT Admin only).""" await _enforce_asset_region(db, current_user, asset_id) await _assert_target_person_in_region( db, current_user, body.to_user_id, target_location=body.location, ) service = AssetService(db) asset, operation = await service.borrow_asset(asset_id, body, current_user.id) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.borrow", resource_type="asset", resource_id=str(asset.id), status="success", detail={ "asset_tag": asset.asset_tag, "to_user_id": str(body.to_user_id), "operation_number": operation.operation_number, }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() enriched = await service.enrich_with_users([asset]) return success_response( data={ "asset": AssetResponse(**enriched[0]), "operation": await serialize_operation(db, operation), }, request=request, ) @router.post("/{asset_id}/borrow-return") async def borrow_return_asset( request: Request, asset_id: UUID, body: AssetBorrowReturnRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Return an asset from borrow (IT Admin only).""" await _enforce_asset_region(db, current_user, asset_id) service = AssetService(db) asset, operation = await service.return_borrowed_asset(asset_id, body, current_user.id) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.borrow_return", resource_type="asset", resource_id=str(asset.id), status="success", detail={"asset_tag": asset.asset_tag, "operation_number": operation.operation_number}, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() enriched = await service.enrich_with_users([asset]) return success_response( data={ "asset": AssetResponse(**enriched[0]), "operation": await serialize_operation(db, operation), }, request=request, ) @router.post("/{asset_id}/borrow-extension") async def extend_asset_borrow( request: Request, asset_id: UUID, body: AssetBorrowExtensionRequest, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Extend a borrowed asset's expected return date — no approval. Dual-authorized: the **borrower themselves** (self-service "借用延期" card) OR an **asset admin** (the row-level "借用延期" action in the admin workbench). Admins may also bring the date forward; a borrower can only push it later. Notifications fan out from the service layer to the borrower, their lead, and — on self-service — the asset admins. """ service = AssetService(db) asset = await service.get_asset(asset_id) # raises NotFoundError on miss is_borrower = asset.borrowed_by is not None and asset.borrowed_by == current_user.id role_names = [r.role.name for r in (current_user.user_roles or [])] is_admin = can_manage_assets(role_names) if not is_borrower and not is_admin: raise ForbiddenError( "Only the borrower or an asset admin can extend this loan", ) # Region-scope narrowing applies to admins acting on someone else's # loan (mirrors every other admin asset mutation). The borrower acting # on their own asset bypasses the office-region gate. if is_admin and not is_borrower: await _enforce_asset_region(db, current_user, asset_id) asset, operation = await service.extend_borrow( asset_id, new_expected_return_date=body.expected_return_date, reason=body.reason, operator_id=current_user.id, allow_earlier=is_admin and not is_borrower, trace_id=request.state.trace_id, ) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.borrow_extension", resource_type="asset", resource_id=str(asset.id), status="success", detail={ "asset_tag": asset.asset_tag, "expected_return_date": body.expected_return_date.isoformat(), "operation_number": operation.operation_number, "actor_kind": "borrower" if is_borrower else "admin", }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() enriched = await service.enrich_with_users([asset]) return success_response( data={ "asset": AssetResponse(**enriched[0]), "operation": await serialize_operation(db, operation), }, request=request, ) @router.post("/{asset_id}/change-assignee") async def change_assignee( request: Request, asset_id: UUID, body: AssetChangeAssigneeRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Change who holds the asset (IT Admin only).""" await _enforce_asset_region(db, current_user, asset_id) await _assert_target_person_in_region( db, current_user, body.to_user_id, target_location=body.location, ) service = AssetService(db) asset, operation = await service.change_assignee(asset_id, body, current_user.id) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.change_assignee", resource_type="asset", resource_id=str(asset.id), status="success", detail={ "asset_tag": asset.asset_tag, "to_user_id": str(body.to_user_id), "operation_number": operation.operation_number, }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() enriched = await service.enrich_with_users([asset]) return success_response( data={ "asset": AssetResponse(**enriched[0]), "operation": await serialize_operation(db, operation), }, request=request, ) @router.post("/{asset_id}/repair") async def repair_asset( request: Request, asset_id: UUID, body: AssetRepairRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Send an asset to repair (IT Admin only).""" await _enforce_asset_region(db, current_user, asset_id) service = AssetService(db) asset, operation = await service.repair_asset(asset_id, body, current_user.id) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.repair", resource_type="asset", resource_id=str(asset.id), status="success", detail={"asset_tag": asset.asset_tag, "operation_number": operation.operation_number}, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() enriched = await service.enrich_with_users([asset]) return success_response( data={ "asset": AssetResponse(**enriched[0]), "operation": await serialize_operation(db, operation), }, request=request, ) @router.post("/{asset_id}/repair-complete") async def repair_complete( request: Request, asset_id: UUID, body: AssetRepairCompleteRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Mark repair done (IT Admin only).""" await _enforce_asset_region(db, current_user, asset_id) service = AssetService(db) asset, operation = await service.complete_repair(asset_id, body, current_user.id) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.repair_complete", resource_type="asset", resource_id=str(asset.id), status="success", detail={"asset_tag": asset.asset_tag, "operation_number": operation.operation_number}, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() enriched = await service.enrich_with_users([asset]) return success_response( data={ "asset": AssetResponse(**enriched[0]), "operation": await serialize_operation(db, operation), }, request=request, ) @router.post("/{asset_id}/dispose") async def dispose_asset( request: Request, asset_id: UUID, body: AssetDisposeRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Dispose/retire an asset (IT Admin only).""" await _enforce_asset_region(db, current_user, asset_id) service = AssetService(db) asset, operation = await service.dispose_asset( asset_id, body, current_user.id, allow_direct_held_disposal=has_role( current_user.role_names, GLOBAL_ADMIN_ROLE, ), ) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.dispose", resource_type="asset", resource_id=str(asset.id), status="success", detail={ "asset_tag": asset.asset_tag, "disposal_type": body.disposal_type, "operation_number": operation.operation_number, "disposal_photo_count": len(body.disposal_photos or []), }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() enriched = await service.enrich_with_users([asset]) return success_response( data={ "asset": AssetResponse(**enriched[0]), "operation": await serialize_operation(db, operation), }, request=request, ) @router.post("/{asset_id}/mark-held-by-leaver") async def mark_asset_held_by_leaver( request: Request, asset_id: UUID, body: AssetMarkHeldByLeaverRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Mark a held asset as held-by-departed-employee (IT Admin only). Used manually when an admin notices an asset still attributed to an ex-employee that the offboarding flow never picked up (legacy / dispute / overseas employee). The offboarding ``skip_blocker`` handler calls the same service path automatically — this HTTP endpoint is the manual-cleanup affordance the admin uses on the asset detail page. """ await _enforce_asset_region(db, current_user, asset_id) service = AssetService(db) asset, operation = await service.mark_held_by_leaver( asset_id, reason=body.reason, operator_id=current_user.id, source="manual", leaver_person_id=body.leaver_person_id, ) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.mark_held_by_leaver", resource_type="asset", resource_id=str(asset.id), status="success", detail={ "asset_tag": asset.asset_tag, "reason": body.reason, "operation_number": operation.operation_number, "leaver_person_id": ( str(body.leaver_person_id) if body.leaver_person_id else None ), }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() enriched = await service.enrich_with_users([asset]) return success_response( data={ "asset": AssetResponse(**enriched[0]), "operation": await serialize_operation(db, operation), }, request=request, ) @router.post("/{asset_id}/recover-from-leaver") async def recover_asset_from_leaver( request: Request, asset_id: UUID, body: AssetRecoverFromLeaverRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Recover a held-by-leaver asset back to idle stock (IT Admin only). Used after IT physically takes the asset back from the ex-employee (or a legal settlement returns it to company control). Clears ``assigned_to``, ``borrowed_by``, ``expected_return_date`` and flips the status to ``idle`` so the asset can be re-claimed by another employee through the normal claim flow. """ await _enforce_asset_region(db, current_user, asset_id) service = AssetService(db) asset, operation = await service.recover_from_leaver( asset_id, notes=body.notes, operator_id=current_user.id, ) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.recover_from_leaver", resource_type="asset", resource_id=str(asset.id), status="success", detail={ "asset_tag": asset.asset_tag, "notes": body.notes, "operation_number": operation.operation_number, }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() enriched = await service.enrich_with_users([asset]) return success_response( data={ "asset": AssetResponse(**enriched[0]), "operation": await serialize_operation(db, operation), }, request=request, ) # --------------------------------------------------------------------------- # Office-held mode (migration 183, 2026-05-19) # --------------------------------------------------------------------------- @router.post("/{asset_id}/recycle-to-office") async def recycle_asset_to_office( request: Request, asset_id: UUID, body: AssetRecycleToOfficeRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Recycle an asset to a city office (Asset Admin only). Used to convert personal-held or idle stock into office-held mode — the device's holder becomes the office's ``admin_person_id`` and inventory cycles route to that office rather than IT fallback. Also called programmatically by the offboarding T+0 disposition handler when ``hardware_disposition.action == 'recycle_to_office'``. Idempotent on a no-op match (asset already at target office). Rejects borrowed / held_by_leaver / terminal-status assets — the caller must clear those states first. """ await _enforce_asset_region(db, current_user, asset_id) await _assert_target_office_in_region(db, current_user, body.office_id) service = AssetService(db) asset, operation = await service.recycle_to_office( asset_id, office_id=body.office_id, reason=body.reason, operator_id=current_user.id, source="manual", ) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.recycle_to_office", resource_type="asset", resource_id=str(asset.id), status="success", detail={ "asset_tag": asset.asset_tag, "office_id": str(body.office_id), "reason": body.reason, "operation_number": operation.operation_number, }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() enriched = await service.enrich_with_users([asset]) return success_response( data={ "asset": AssetResponse(**enriched[0]), "operation": await serialize_operation(db, operation), }, request=request, ) @router.post("/bulk-recycle-to-office") async def bulk_recycle_assets_to_office( request: Request, body: AssetBulkRecycleToOfficeRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Bulk recycle multiple assets to one office (Asset Admin only). Per-row failures don't roll back the whole batch — caller renders a per-row result modal. The ``idempotency_key`` follows the same convention as ``bulk_claim`` / ``bulk_borrow``: a repeat call with the same key returns 409 ``DUPLICATE_BULK_OPERATION``. """ await _enforce_asset_region_bulk(db, current_user, list(body.asset_ids)) await _assert_target_office_in_region(db, current_user, body.office_id) service = AssetService(db) response = await service.bulk_recycle_to_office( body, operator_id=current_user.id, ) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.bulk_recycle_to_office", resource_type="asset", resource_id=None, status="success", detail={ "office_id": str(body.office_id), "total": response.total_rows, "succeeded": response.successful_rows, "failed": response.failed_rows, "idempotency_key": body.idempotency_key, }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() return success_response(data=response, request=request) @router.post("/{asset_id}/release-from-office") async def release_asset_from_office( request: Request, asset_id: UUID, body: AssetReleaseFromOfficeRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Release an office-held asset back to idle stock (Asset Admin only). Inverse of ``recycle_to_office``. After release the asset is ``status='idle'`` with no holder — ready to be ``claim``-ed by an individual. Use this when the office is shipping the device to a new hire or moving it permanently out of the office pool. """ await _enforce_asset_region(db, current_user, asset_id) service = AssetService(db) asset, operation = await service.release_from_office( asset_id, notes=body.notes, operator_id=current_user.id, ) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.release_from_office", resource_type="asset", resource_id=str(asset.id), status="success", detail={ "asset_tag": asset.asset_tag, "notes": body.notes, "operation_number": operation.operation_number, }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() enriched = await service.enrich_with_users([asset]) return success_response( data={ "asset": AssetResponse(**enriched[0]), "operation": await serialize_operation(db, operation), }, request=request, ) # --------------------------------------------------------------------------- # Operations # --------------------------------------------------------------------------- @router.get("/operations") async def list_operations( request: Request, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), asset_id: UUID | None = Query(None), operation_type: str | None = Query(None), status: str | None = Query(None), keyword: str | None = Query(None, description="Fuzzy match operation_number or asset_tag"), operator_id: UUID | None = Query(None), to_user_id: UUID | None = Query(None), start_date: str | None = Query(None, description="ISO date, inclusive lower bound on created_at"), end_date: str | None = Query(None, description="ISO date, inclusive upper bound on created_at"), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List asset operations. Admin (``can_read_all_assets``) sees every operation. Employees may only filter for operations targeting themselves (``to_user_id=``) — the IT 宇宙 page on /me uses this to surface 领用 / 归还 / 转让 / 报废 history. Anything else is 403. """ is_admin = can_read_all_assets(current_user.role_names) if not is_admin: if to_user_id is None or to_user_id != current_user.id: raise ForbiddenError( "You may only view your own asset operations.", ) service = AssetService(db) ops, total = await service.list_operations( page=page, page_size=page_size, asset_id=asset_id, operation_type=operation_type, status=status, keyword=keyword, operator_id=operator_id, to_user_id=to_user_id, start_date=start_date, end_date=end_date, ) # ``admin_view=is_admin`` redacts ``detail.location_raw`` from # employee-self-scope responses. Mirrors the gate # ``GET /assets/{id}`` already applies via # ``enrich_operations_with_users``; Round-5 review high — both the # list and detail surfaces must share the same redaction policy # so a non-admin employee can't side-step the gate by hitting the # list endpoint. return paginated_response( data=await serialize_operations(db, ops, admin_view=is_admin), total=total, page=page, page_size=page_size, request=request, ) @router.post("/operations/{operation_id}/confirm") async def confirm_operation( request: Request, operation_id: UUID, body: OperationConfirmRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Confirm or reject a pending operation (IT Admin only).""" # Region gate: every other operation/asset mutation in this router runs # ``_enforce_asset_region`` so a region-scoped asset_admin can only touch # assets in the offices they manage. Confirm/reject was the sole # operation-mutation that omitted it, letting a Beijing-office admin # confirm a Shanghai asset's pending operation. Resolve the operation's # asset and enforce region before mutating (global/global-asset scope # bypasses naturally inside ``_enforce_asset_region``). pending_op = await db.get(AssetOperation, operation_id) if pending_op is None: raise NotFoundError("Operation", str(operation_id)) await _enforce_asset_region(db, current_user, pending_op.asset_id) service = AssetService(db) operation = await service.confirm_operation( operation_id, body.action, current_user.id, body.notes ) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action=f"asset.operation.{body.action}", resource_type="asset_operation", resource_id=str(operation.id), status="success", detail={"operation_number": operation.operation_number, "action": body.action}, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() return success_response( data=await serialize_operation(db, operation), request=request, ) # --------------------------------------------------------------------------- # Procurement orders # --------------------------------------------------------------------------- @router.get("/procurement") async def list_procurement_orders( request: Request, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), status: str | None = Query(None), order_type: str | None = Query(None), supplier_id: UUID | None = Query(None), department: str | None = Query(None), keyword: str | None = Query(None, description="Fuzzy match order_number / supplier_name / notes"), start_date: str | None = Query(None), end_date: str | None = Query(None), current_user: AuthUser = Depends(require_software_read()), db: AsyncSession = Depends(get_db), ): """List procurement orders (admin-tier + management read-only).""" service = AssetService(db) orders, total = await service.list_procurement_orders( page=page, page_size=page_size, status=status, order_type=order_type, supplier_id=supplier_id, department=department, keyword=keyword, start_date=start_date, end_date=end_date, ) return paginated_response( data=[ProcurementOrderResponse.model_validate(o) for o in orders], total=total, page=page, page_size=page_size, request=request, ) @router.post("/procurement") async def create_procurement_order( request: Request, body: ProcurementOrderCreateRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Create a procurement order (IT Admin only).""" service = AssetService(db) order = await service.create_procurement_order(body, current_user.id) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.procurement.create", resource_type="procurement_order", resource_id=str(order.id), status="success", detail={"order_number": order.order_number, "order_type": order.order_type}, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() return success_response( data=ProcurementOrderResponse.model_validate(order), request=request, ) @router.get("/procurement/{order_id}") async def get_procurement_order( request: Request, order_id: UUID, current_user: AuthUser = Depends(require_software_read()), db: AsyncSession = Depends(get_db), ): """Get procurement order detail (admin-tier + management read-only).""" service = AssetService(db) order = await service.get_procurement_order(order_id) return success_response( data=ProcurementOrderResponse.model_validate(order), request=request, ) @router.patch("/procurement/{order_id}") async def update_procurement_order( request: Request, order_id: UUID, body: ProcurementOrderUpdateRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Update a procurement order (IT Admin only).""" service = AssetService(db) order = await service.update_procurement_order(order_id, body) audit = AuditService(db) # Convert Decimal values to float for JSON serialization in audit detail raw_detail = body.model_dump(exclude_unset=True) audit_detail = { k: float(v) if isinstance(v, Decimal) else v for k, v in raw_detail.items() } await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.procurement.update", resource_type="procurement_order", resource_id=str(order.id), status="success", detail=audit_detail, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() return success_response( data=ProcurementOrderResponse.model_validate(order), request=request, ) # --------------------------------------------------------------------------- # Employee requests # --------------------------------------------------------------------------- @router.get("/employee-requests") async def list_employee_requests( request: Request, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), status: str | None = Query(None), request_type: str | None = Query(None), requester_id: UUID | None = Query(None, description="Admin-only filter; employees always see only their own"), keyword: str | None = Query(None), start_date: str | None = Query(None), end_date: str | None = Query(None), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List employee requests. Admins see all, employees see their own.""" service = AssetService(db) # Employees are forced to see only their own requests regardless of the # ?requester_id= they send — otherwise they could enumerate other people's # requests. Admins can filter by any requester. resolved_requester_id = requester_id if can_manage_assets(current_user.role_names) else current_user.id requests_list, total = await service.list_employee_requests( page=page, page_size=page_size, requester_id=resolved_requester_id, status=status, request_type=request_type, keyword=keyword, start_date=start_date, end_date=end_date, ) return paginated_response( data=[EmployeeRequestResponse.model_validate(r) for r in requests_list], total=total, page=page, page_size=page_size, request=request, ) @router.post("/employee-requests") async def create_employee_request( request: Request, body: EmployeeRequestCreateRequest, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Create an employee request (any authenticated user).""" service = AssetService(db) emp_request = await service.create_employee_request(body, current_user.id) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.employee_request.create", resource_type="employee_request", resource_id=str(emp_request.id), status="success", detail={ "request_number": emp_request.request_number, "request_type": emp_request.request_type, }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() return success_response( data=EmployeeRequestResponse.model_validate(emp_request), request=request, ) @router.post("/employee-requests/{request_id}/supplement-uploads") async def supplement_employee_request_uploads( request: Request, request_id: UUID, body: EmployeeRequestSupplementUploadsRequest, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Attach supplementary files to an in-flight approval instance. Files are uploaded via the existing ``POST /api/v1/approval/asset/uploads`` endpoint first; the returned tokens are passed here keyed by widget_id. When the flow is parked on the requester's own node (回购模板的 提交人本人 节点, upload widget marked editable) and the caller IS the requester, Helix writes the files into the form widget itself via ``tasks/approve`` + ``form`` and completes that node; everything else falls back to a comment-with-attachments on the instance. See :meth:`AssetService.supplement_employee_request_uploads`. Open to the original requester (matches the cancel flow's auth) or any asset admin (admins always take the comment channel — they must not approve a node in the requester's name). Locked to ``status='pending'`` requests — once the Feishu instance is terminal, neither channel can influence the decision and we refuse to give a false signal that the supplement was delivered. """ service = AssetService(db) is_admin = can_manage_assets(current_user.role_names) req = await service.supplement_employee_request_uploads( request_id=request_id, actor=current_user, is_admin=is_admin, uploads=body.uploads, note=body.note, ) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.employee_request.supplement_uploads", resource_type="employee_request", resource_id=str(req.id), status="success", detail={ "request_number": req.request_number, "widget_ids": sorted(body.uploads.keys()), "total_files": sum(len(v) for v in body.uploads.values()), # "form" / "comment" / "mixed" — how the files reached # Feishu (form-widget write vs instance comment). "delivery": ( ((req.detail or {}).get("feishu") or {}).get("supplements") or [{}] )[-1].get("delivery"), }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() return success_response( data=EmployeeRequestResponse.model_validate(req), request=request, ) @router.get("/employee-requests/{request_id}/supplement-files/presign") async def presign_supplement_file( request: Request, request_id: UUID, object_key: str = Query(..., max_length=512), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Mint a short-lived download URL for a supplemented file. Supplement files ride the Feishu approval-comment channel, whose signed CDN urls expire in ~14 h with no re-sign path (comments are stored verbatim — unlike form widget values, which re-sign on every instance fetch). The upload endpoint therefore persists a durable copy to Helix S3 (purpose ``approval_supplement``) and stores its ``object_key`` in the request's supplement history; this endpoint turns that key back into a viewable URL whenever the detail drawer is opened — days or months later. Authz: the original requester or any asset-read role (admin / auditor / management — the audiences of the detail drawer). The ``object_key`` must appear in THIS request's supplement history — a key lifted from someone else's audit log mints nothing here (no cross-request IDOR). """ service = AssetService(db) req = await service.get_employee_request(request_id) if req.requester_id != current_user.id and not can_read_all_assets( current_user.role_names, ): raise AppException( code="EMPLOYEE_REQUEST_NOT_OWNER", message="Not authorised to view this request's files.", status_code=403, ) known_keys: set[str] = set() feishu_detail = (req.detail or {}).get("feishu") or {} for supplement in feishu_detail.get("supplements") or []: for info in (supplement.get("uploads") or {}).values(): for f in (info or {}).get("files") or []: key = (f or {}).get("object_key") if isinstance(key, str) and key: known_keys.add(key) if object_key not in known_keys: raise AppException( code="SUPPLEMENT_FILE_UNKNOWN", message="This file does not belong to the request.", status_code=403, ) from app.core.storage import StorageNotConfiguredError, presign_get try: url = await presign_get(db, object_key=object_key) except StorageNotConfiguredError as e: raise AppException( code="STORAGE_NOT_CONFIGURED", message="File storage backend is not configured.", status_code=503, ) from e return success_response( data={"url": url, "expires_in": 600}, request=request, ) @router.post("/employee-requests/{request_id}/cancel") async def cancel_employee_request( request: Request, request_id: UUID, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Requester (or admin) withdraws a still-pending employee request. Cancels the linked Feishu approval instance too — keeps the Feishu approver's inbox honest. Cascades to ``api_key_assignment`` rows that share the same ``employee_request_id`` so a withdrawn api-key request doesn't leave a half-built assignment row. Also revokes a still-``pending`` ``software_seat_allocation`` linked via ``approval_request.source_payload.seat_allocation_id`` — without this, the seat row sat in ``status='pending'`` forever and the per-(user, plan) dedup guard in ``check_no_active_seat_request`` blocked the user from re-applying with a "You already have a pending seat for X" 409. """ service = AssetService(db) # Use the canonicalised RBAC helper. Bare ``"it_admin" in role_names`` # silently returns False for real IT admins because role_names is # normalised to ``global_admin`` at AuthUser hydration time # (identity/person_models.py:293 → core/rbac.py:LEGACY_ROLE_ALIASES). is_admin = can_manage_assets(current_user.role_names) req = await service.cancel_employee_request( request_id=request_id, actor=current_user, is_admin=is_admin, ) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.employee_request.cancel", resource_type="employee_request", resource_id=str(req.id), status="success", detail={ "request_number": req.request_number, "request_type": req.request_type, }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() # False ⇒ Helix cancelled but the parallel Feishu instance is still # pending (e.g. template withdraw disabled) — frontend warns. return success_response( data={ **EmployeeRequestResponse.model_validate(req).model_dump(), "feishu_cancel_synced": service.last_feishu_cancel_synced, }, request=request, ) @router.post("/employee-requests/{request_id}/process") async def process_employee_request( request: Request, request_id: UUID, body: EmployeeRequestProcessRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Approve or reject an employee request (IT Admin only).""" service = AssetService(db) emp_request = await service.process_employee_request( request_id, body.action, current_user.id, body.notes ) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action=f"asset.employee_request.{body.action}", resource_type="employee_request", resource_id=str(emp_request.id), status="success", detail={ "request_number": emp_request.request_number, "action": body.action, }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() return success_response( data=EmployeeRequestResponse.model_validate(emp_request), request=request, ) # --------------------------------------------------------------------------- # Asset settings (locations, categories, tag generation) # Must come BEFORE /{asset_id} catch-all routes # --------------------------------------------------------------------------- @router.get("/locations") async def list_locations( request: Request, include_inactive: bool = Query(False), with_admins: bool = Query( False, description=( "Attach each location's administrator list. Asset-admin tier " "only, and one extra query — the asset intake / list / detail " "pages that use this endpoint as a plain dictionary leave it " "off." ), ), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List all asset locations. The location dictionary itself is readable by any authenticated user — every asset form renders it as a dropdown. ``with_admins`` is not: it turns the response into a roster of who holds ``asset_admin`` and where, complete with work emails, and that is an admin-tier read even though the rows it hangs off are public. """ if with_admins and not can_manage_assets(current_user.role_names): raise ForbiddenError( "with_admins requires an asset-admin role", ) service = AssetService(db) locations = await service.list_locations(include_inactive=include_inactive) payload = [ AssetLocationResponse.model_validate(loc).model_dump() for loc in locations ] if with_admins: from app.domains.asset.location_admin_sync import ( list_location_admin_ids_bulk, ) admins_by_location = await list_location_admin_ids_bulk( db, [loc.id for loc in locations], ) for row, loc in zip(payload, locations, strict=True): row["admins"] = admins_by_location.get(loc.id, []) return success_response( data=[AssetLocationResponse(**row) for row in payload], request=request, ) @router.get("/locations/coverage") async def get_location_coverage( request: Request, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Per-location administrator coverage, with what each one carries. The location settings page renders this, and the MCP twin ``get_asset_location_coverage`` returns the same shape — both call :func:`build_location_coverage`. A location that holds assets or seats people but has no administrator is an operational gap: every write there falls through to the global tier and the inventory / recycle flows have no one to address. The numbers sit next to the control that fixes them rather than in a report somebody would have to go find. """ from app.domains.asset.location_coverage import build_location_coverage return success_response( data=await build_location_coverage(db), request=request, ) @router.post("/locations") async def create_location( request: Request, body: AssetLocationCreateRequest, current_user: AuthUser = Depends(require_global_asset_admin()), db: AsyncSession = Depends(get_db), ): """Create a new asset location, optionally with its administrators. Global tier only — a location is a region, and minting regions is not something a regional administrator should be able to do. """ from app.domains.asset.location_admin_sync import replace_location_admins service = AssetService(db) location = await service.create_location(body) await db.flush() # need location.id before populating the join table admin_ids = list(body.admin_person_ids or []) affected_admin_ids: list[UUID] = [] if admin_ids: affected_admin_ids = await replace_location_admins( db, location.id, admin_ids, primary_person_id=body.primary_person_id, ) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.location.create", resource_type="asset_location", resource_id=str(location.id), status="success", detail={ "code": location.code, "name": location.name, "admin_ids": [str(p) for p in admin_ids], "affected_admin_ids": [str(p) for p in affected_admin_ids], }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() await db.refresh(location) return success_response( data=AssetLocationResponse.model_validate(location), request=request, ) @router.put("/locations/{location_id}/admins") async def replace_location_admin_list( request: Request, location_id: UUID, body: AssetLocationAdminsRequest, current_user: AuthUser = Depends(require_global_asset_admin()), db: AsyncSession = Depends(get_db), ): """Replace a location's administrator list. This endpoint *is* the ``asset_admin`` grant surface: the role is auto-derived from these rows, so adding someone here grants it and removing their last location revokes it, in the same transaction. That is why it takes the global tier and why the audit row records ``affected_admin_ids`` — the people whose role actually changed, as opposed to the list that was submitted. """ from app.domains.asset.location_admin_sync import ( list_location_admins, replace_location_admins, ) location = await db.get(AssetLocation, location_id) if location is None: raise NotFoundError("Location", str(location_id)) before = await list_location_admins(db, location_id) submitted = list(body.admin_person_ids) affected_admin_ids = await replace_location_admins( db, location_id, submitted, primary_person_id=body.primary_person_id, ) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.location.admins.replace", resource_type="asset_location", resource_id=str(location_id), status="success", detail={ "location_name": location.name, "admin_ids_before": [str(a["person_id"]) for a in before], "admin_ids_submitted": [str(p) for p in submitted], "primary_person_id": ( str(body.primary_person_id) if body.primary_person_id else None ), "affected_admin_ids": [str(p) for p in affected_admin_ids], }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() admins = await list_location_admins(db, location_id) return success_response(data={"admins": admins}, request=request) @router.patch("/locations/{location_id}") async def update_location( request: Request, location_id: UUID, body: AssetLocationUpdateRequest, current_user: AuthUser = Depends(require_global_asset_admin()), db: AsyncSession = Depends(get_db), ): """Update an asset location's own fields (global asset tier only). Administrators are managed through ``PUT /assets/locations/{id}/admins``. """ service = AssetService(db) location = await service.update_location(location_id, body) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.location.update", resource_type="asset_location", resource_id=str(location.id), status="success", detail=body.model_dump(mode="json", exclude_unset=True), ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() return success_response( data=AssetLocationResponse.model_validate(location), request=request, ) @router.delete("/locations/{location_id}") async def delete_location( request: Request, location_id: UUID, current_user: AuthUser = Depends(require_global_asset_admin()), db: AsyncSession = Depends(get_db), ): """Delete an asset location (global asset tier only). Refused while any asset still names the location — see ``AssetService.delete_location``. """ from sqlalchemy import select as _select # noqa: PLC0415 from app.domains.asset.location_admin_sync import ( # noqa: PLC0415 sync_asset_admin_role_for_person, ) # Snapshot the administrators BEFORE the FK cascade clears them, so # each one's ``asset_admin`` grant can be re-derived in this same # transaction. Without it, a person who administers only the deleted # location keeps the role until the 03:15 reconciler — a window in # which ``/auth/me`` reports the role with an empty managed set and # the frontend renders admin affordances that uniformly 403. affected_rows = await db.execute( _select(AssetLocationAdmin.person_id).where( AssetLocationAdmin.location_id == location_id, ) ) affected_person_ids = [r[0] for r in affected_rows.all()] service = AssetService(db) location = await service.delete_location(location_id) await db.flush() # surface the cascade before the role sync reads the join for pid in affected_person_ids: await sync_asset_admin_role_for_person(db, pid) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.location.delete", resource_type="asset_location", resource_id=str(location.id), status="success", detail={ "code": location.code, "name": location.name, "affected_admin_count": len(affected_person_ids), "affected_admin_ids": [str(p) for p in affected_person_ids], }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() return success_response(data={"deleted": True}, request=request) @router.get("/categories") async def list_categories( request: Request, include_inactive: bool = Query(False), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List all asset category configs.""" service = AssetService(db) categories = await service.list_categories(include_inactive=include_inactive) return success_response( data=[AssetCategoryConfigResponse.model_validate(c) for c in categories], request=request, ) @router.post("/categories") async def create_category( request: Request, body: AssetCategoryConfigCreateRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Create a new asset category config (IT Admin only).""" service = AssetService(db) category = await service.create_category(body) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.category.create", resource_type="asset_category_config", resource_id=str(category.id), status="success", detail={"code": category.code, "name": category.name}, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() return success_response( data=AssetCategoryConfigResponse.model_validate(category), request=request, ) @router.patch("/categories/{category_id}") async def update_category( request: Request, category_id: UUID, body: AssetCategoryConfigUpdateRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Update an asset category config (IT Admin only).""" service = AssetService(db) category = await service.update_category(category_id, body) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.category.update", resource_type="asset_category_config", resource_id=str(category.id), status="success", detail=body.model_dump(mode="json", exclude_unset=True), ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() return success_response( data=AssetCategoryConfigResponse.model_validate(category), request=request, ) @router.delete("/categories/{category_id}") async def delete_category( request: Request, category_id: UUID, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Delete an asset category config (IT Admin only).""" service = AssetService(db) category = await service.delete_category(category_id) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.category.delete", resource_type="asset_category_config", resource_id=str(category.id), status="success", detail={"code": category.code, "name": category.name}, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() return success_response(data={"deleted": True}, request=request) # =========================================================================== # Brand + model lookup CRUD (asset settings page → "Brands" tab) # =========================================================================== # Anyone authenticated can READ the lookups so the intake AutoComplete # works for all asset_admin / global_admin users; writes are gated on # asset-admin (same gate as locations / categories). @router.get("/brands") async def list_asset_brands( request: Request, include_inactive: bool = Query(default=False), with_counts: bool = Query( default=True, description=( "Add ``model_count`` to each row. False on hot intake " "AutoComplete paths where the count isn't needed." ), ), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List asset brands (any authenticated user). ``include_inactive=true`` requires asset-admin (employees never need to see deactivated rows). """ if include_inactive and not can_manage_assets(current_user.role_names): # Silently treat the query param as false rather than 403 — the # AutoComplete may default to ``include_inactive=true`` and we # don't want it to break for non-admin users. include_inactive = False service = AssetBrandModelService(db) rows = await service.list_brands( include_inactive=include_inactive, with_counts=with_counts, ) return success_response(data=rows, request=request) @router.post("/brands") async def create_asset_brand( request: Request, body: AssetBrandCreateRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): service = AssetBrandModelService(db) row = await service.create_brand(body) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.brand.create", resource_type="asset_brand", resource_id=str(row.id), status="success", detail={"name": row.name}, ip_address=get_real_ip(request), ) await db.commit() return success_response(data=row, request=request) @router.patch("/brands/{brand_id}") async def update_asset_brand( request: Request, brand_id: UUID, body: AssetBrandUpdateRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): service = AssetBrandModelService(db) row = await service.update_brand(brand_id, body) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.brand.update", resource_type="asset_brand", resource_id=str(brand_id), status="success", detail=body.model_dump(mode="json", exclude_unset=True), ip_address=get_real_ip(request), ) await db.commit() return success_response(data=row, request=request) @router.delete("/brands/{brand_id}") async def delete_asset_brand( request: Request, brand_id: UUID, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): service = AssetBrandModelService(db) await service.delete_brand(brand_id) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.brand.delete", resource_type="asset_brand", resource_id=str(brand_id), status="success", ip_address=get_real_ip(request), ) await db.commit() return success_response(data={"deleted": True}, request=request) @router.get("/models") async def list_asset_models( request: Request, brand_id: UUID | None = Query(default=None), include_inactive: bool = Query(default=False), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List asset models, optionally filtered by brand.""" if include_inactive and not can_manage_assets(current_user.role_names): include_inactive = False service = AssetBrandModelService(db) rows = await service.list_models( brand_id=brand_id, include_inactive=include_inactive, ) return success_response(data=rows, request=request) @router.post("/models") async def create_asset_model( request: Request, body: AssetModelCreateRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): service = AssetBrandModelService(db) row = await service.create_model(body) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.model.create", resource_type="asset_model", resource_id=str(row.id), status="success", detail={"brand_id": str(row.brand_id), "name": row.name}, ip_address=get_real_ip(request), ) await db.commit() return success_response(data=row, request=request) @router.patch("/models/{model_id}") async def update_asset_model( request: Request, model_id: UUID, body: AssetModelUpdateRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): service = AssetBrandModelService(db) row = await service.update_model(model_id, body) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.model.update", resource_type="asset_model", resource_id=str(model_id), status="success", detail=body.model_dump(mode="json", exclude_unset=True), ip_address=get_real_ip(request), ) await db.commit() return success_response(data=row, request=request) @router.delete("/models/{model_id}") async def delete_asset_model( request: Request, model_id: UUID, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): service = AssetBrandModelService(db) await service.delete_model(model_id) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.model.delete", resource_type="asset_model", resource_id=str(model_id), status="success", ip_address=get_real_ip(request), ) await db.commit() return success_response(data={"deleted": True}, request=request) @router.post("/generate-tag") async def generate_asset_tag( request: Request, body: GenerateAssetTagRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Generate the next asset tag (IT Admin only). Format: ``IT-{purchase_entity.code}-{code}-{YYYYMMDDHHMMSS}`` where ``code`` comes from the ``asset_category_configs`` row matching the given ``category`` (managed via 分类管理). Example: ``IT-CN-NB-20260508151325``. """ service = AssetService(db) tag = await service.generate_asset_tag_for_category( purchase_entity_id=body.purchase_entity_id, category=body.category, ) return success_response(data={"asset_tag": tag}, request=request) # =========================================================================== # Software License Plans & Seat Allocation APIs # (Must be registered before /{asset_id} catch-all) # =========================================================================== # --------------------------------------------------------------------------- # License Plan CRUD # --------------------------------------------------------------------------- @router.get("/license-plans") async def list_all_license_plans( request: Request, page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), software_id: UUID | None = Query(None), status: str | None = Query(None), billing_cycle: str | None = Query(None), keyword: str | None = Query(None, description="Fuzzy match plan name / software name"), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List ALL license plans across all software — admin & employees. Used by the admin 坐席类型管理 page so it isn't empty on entry, and by anyone who wants a cross-software view. Employees still get a read-only view — creation/edit routes remain admin-gated below. Price is finance-restricted: callers without finance / admin / auditor / management role get ``price=None`` in the response so cross-team plan lists don't leak per-seat cost. """ from app.core.rbac import can_view_finance show_price = can_view_finance(current_user.role_names) or can_manage_assets( current_user.role_names, ) query = ( select(SoftwareLicensePlan, AssetSoftwareCatalog.name.label("software_name")) .join(AssetSoftwareCatalog, SoftwareLicensePlan.software_id == AssetSoftwareCatalog.id) .order_by(AssetSoftwareCatalog.name, SoftwareLicensePlan.price) ) count_query = ( select(func.count()) .select_from(SoftwareLicensePlan) .join(AssetSoftwareCatalog, SoftwareLicensePlan.software_id == AssetSoftwareCatalog.id) ) conditions = [] if software_id: conditions.append(SoftwareLicensePlan.software_id == software_id) if status: conditions.append(SoftwareLicensePlan.status == status) if billing_cycle: conditions.append(SoftwareLicensePlan.billing_cycle == billing_cycle) if keyword: pattern = f"%{keyword}%" conditions.append( or_( SoftwareLicensePlan.name.ilike(pattern), AssetSoftwareCatalog.name.ilike(pattern), ) ) if conditions: query = query.where(and_(*conditions)) count_query = count_query.where(and_(*conditions)) total = (await db.execute(count_query)).scalar() or 0 rows = (await db.execute( query.offset((page - 1) * page_size).limit(page_size) )).all() # Batch-count active + pending seats per plan for the "used / max" badge plan_ids = [row[0].id for row in rows] seat_counts: dict = {} if plan_ids: count_rows = await db.execute( select( SoftwareSeatAllocation.plan_id, func.count(SoftwareSeatAllocation.id), ) .where( SoftwareSeatAllocation.plan_id.in_(plan_ids), SoftwareSeatAllocation.status.in_(("active", "pending")), ) .group_by(SoftwareSeatAllocation.plan_id), ) seat_counts = {pid: int(c or 0) for pid, c in count_rows.all()} def _plan_to_response(plan, sw_name, *, redact_price: bool): cols = {c.key: getattr(plan, c.key) for c in SoftwareLicensePlan.__table__.columns} if redact_price: cols["price"] = None return LicensePlanResponse( **cols, software_name=sw_name, active_seat_count=seat_counts.get(plan.id, 0), ) data = [ _plan_to_response(plan, sw_name, redact_price=not show_price) for plan, sw_name in rows ] return paginated_response(data=data, total=total, page=page, page_size=page_size, request=request) @router.get("/software-catalog/{software_id}/plans") async def list_license_plans( software_id: UUID, request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List license plans for a software — accessible to all authenticated users. Price is redacted for callers without finance/admin/auditor/ management role so the catalog browse path doesn't leak per-seat cost across teams. Required for the seat-request modal which any employee can open. """ from app.core.rbac import can_view_finance show_price = can_view_finance(current_user.role_names) or can_manage_assets( current_user.role_names, ) rows = (await db.execute( select(SoftwareLicensePlan) .where(SoftwareLicensePlan.software_id == software_id) .order_by(SoftwareLicensePlan.price) )).scalars().all() sw = (await db.execute( select(AssetSoftwareCatalog.name).where(AssetSoftwareCatalog.id == software_id) )).scalar_one_or_none() # Batch-count active + pending seat allocations per plan so the # UI can show "X / max" without N+1 queries. Uses the same # status set as provisioning_coordinator.check_plan_quota so the # displayed count matches what the quota check rejects on. from sqlalchemy import func as _func from app.domains.asset.models import SoftwareSeatAllocation plan_ids = [r.id for r in rows] seat_counts: dict = {} if plan_ids: count_rows = await db.execute( select( SoftwareSeatAllocation.plan_id, _func.count(SoftwareSeatAllocation.id), ) .where( SoftwareSeatAllocation.plan_id.in_(plan_ids), SoftwareSeatAllocation.status.in_(("active", "pending")), ) .group_by(SoftwareSeatAllocation.plan_id), ) seat_counts = {pid: int(c or 0) for pid, c in count_rows.all()} def _to_resp(r): cols = {c.key: getattr(r, c.key) for c in SoftwareLicensePlan.__table__.columns} if not show_price: cols["price"] = None return LicensePlanResponse( **cols, software_name=sw, active_seat_count=seat_counts.get(r.id, 0), ) data = [_to_resp(r) for r in rows] return success_response(data=data, request=request) @router.post("/software-catalog/{software_id}/plans") async def create_license_plan( software_id: UUID, body: LicensePlanCreateRequest, request: Request, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Create a license plan for a software — admin only.""" sw = (await db.execute( select(AssetSoftwareCatalog).where(AssetSoftwareCatalog.id == software_id) )).scalar_one_or_none() if not sw: raise AppException(code="NOT_FOUND", message="Software not found", status_code=404) plan = SoftwareLicensePlan( software_id=software_id, name=body.name, price=body.price, billing_cycle=body.billing_cycle, description=body.description, status=body.status, max_seats=body.max_seats, # Round 4 P0 fix: previously the currency field was unreachable # via API → every plan defaulted to CNY → multi-currency # hardening was dead code. Now plumbed through. currency=body.currency, over_quota_policy=body.over_quota_policy, ) db.add(plan) await db.flush() return success_response( data=LicensePlanResponse( **{c.key: getattr(plan, c.key) for c in SoftwareLicensePlan.__table__.columns}, software_name=sw.name, ), request=request, ) @router.put("/license-plans/{plan_id}") async def update_license_plan( plan_id: UUID, body: LicensePlanUpdateRequest, request: Request, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Update a license plan — admin only. Two billing-critical fields (``currency`` and ``billing_cycle``) are frozen per-row into every CLOSED monthly bill at close time (``SoftwareMonthlyBill.currency`` / ``.billing_cycle`` — migration 063). Once a bill is closed the plan's live value must not drift from it: * ``currency`` — live cross-currency aggregation (``provisioning.compute_billing``, ``billing_service``) refuses to mix decimals, so a plan with CNY-closed bills flipped to USD would produce a sticky "currency_warning=true" state on every read and break every per-plan cost stat. * ``billing_cycle`` — the live preview (``preview_current_or_draft``) reads the plan's live cycle, so a monthly → yearly flip after closes exist distorts already-closed periods retroactively. Gate on closed bills only. ``software_monthly_bills`` holds ONLY closed rows (the "close month" action is its sole writer), so its presence for this plan IS the closed-bill signal. Active/pending seats alone freeze nothing — no bill has been closed yet, so a currency/cycle change touches only future periods and is safe. Refuse with 409 when closed bills exist; the admin must create a fresh plan and migrate seats via the tier-change endpoint. Every successful mutation lands an audit row capturing before/ after for the changed fields — pre-launch hardening 2026-05-05 (P1 from CR3): the previous shape silently mutated and ran without audit, so a hostile admin could shift a plan's price up and the only evidence was the changed row itself. """ from app.domains.asset.models import SoftwareMonthlyBill plan = (await db.execute( select(SoftwareLicensePlan).where(SoftwareLicensePlan.id == plan_id) )).scalar_one_or_none() if not plan: raise AppException(code="NOT_FOUND", message="License plan not found", status_code=404) update_payload = body.model_dump(exclude_unset=True) if not update_payload: # Nothing to change — short-circuit so we don't write a # bogus audit row. return success_response( data=LicensePlanResponse( **{c.key: getattr(plan, c.key) for c in SoftwareLicensePlan.__table__.columns}, software_name=plan.software.name if plan.software else None, ), request=request, ) # Fields frozen per-row into closed monthly bills. Block the change # only when a CLOSED bill exists for this plan (see docstring); # active seats alone freeze nothing. Capture the values BEFORE # mutation so the audit row preserves the diff. sensitive_fields = ("currency", "billing_cycle") sensitive_changes: dict[str, tuple[Any, Any]] = {} for field in sensitive_fields: if field in update_payload: new_value = update_payload[field] current_value = getattr(plan, field) if (current_value or "") != (new_value or ""): sensitive_changes[field] = (current_value, new_value) if sensitive_changes: # software_monthly_bills holds ONLY closed rows, so any row for # this plan means a period was closed and froze currency/cycle. closed_bill_count = (await db.execute( select(func.count(SoftwareMonthlyBill.id)).where( SoftwareMonthlyBill.plan_id == plan_id, ), )).scalar_one() if closed_bill_count > 0: raise AppException( code="LICENSE_PLAN_LOCKED_FIELDS", message=( f"Cannot change {list(sensitive_changes.keys())} on " f"plan {plan.name!r}: it has {closed_bill_count} " "closed monthly-bill row(s) that froze the currency/" "billing_cycle at close time. Create a new plan with " "the desired currency/billing_cycle and migrate seats " "via the tier-change endpoint instead." ), status_code=409, ) # Snapshot the before-state for changed fields only — audit row # then captures the diff cleanly. before_snapshot = { field: getattr(plan, field) for field in update_payload } for field, value in update_payload.items(): setattr(plan, field, value) await db.flush() after_snapshot = { field: getattr(plan, field) for field in update_payload } audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action="asset.license_plan.update", resource_type="software_license_plan", resource_id=str(plan_id), status="success", detail={ "plan_name": plan.name, "fields": list(update_payload.keys()), "before": {k: str(v) if v is not None else None for k, v in before_snapshot.items()}, "after": {k: str(v) if v is not None else None for k, v in after_snapshot.items()}, }, ip_address=get_real_ip(request), ) return success_response( data=LicensePlanResponse( **{c.key: getattr(plan, c.key) for c in SoftwareLicensePlan.__table__.columns}, software_name=plan.software.name if plan.software else None, ), request=request, ) @router.delete("/license-plans/{plan_id}") async def delete_license_plan( plan_id: UUID, request: Request, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Delete a license plan — admin only. Refuses with 409 when ANY ``SoftwareSeatAllocation`` references the plan (active OR revoked) or any closed ``SoftwareMonthlyBill`` references it. Hard-delete in those cases would orphan finance history (the bill's ``plan_id`` is a bare UUID with no FK; the ``plan_name`` is denormalised but losing the plan row breaks "what did this plan cost in 2026-Q1?" forensic queries). Audits every successful delete with the plan's snapshot — pre-launch hardening 2026-05-05 (P1 from CR3): the previous shape had no audit row, so a hostile or careless admin could wipe a plan and the only evidence was HTTP access logs. """ from app.domains.asset.models import ( SoftwareMonthlyBill, SoftwareSeatAllocation, ) plan = (await db.execute( select(SoftwareLicensePlan).where(SoftwareLicensePlan.id == plan_id) )).scalar_one_or_none() if not plan: raise AppException(code="NOT_FOUND", message="License plan not found", status_code=404) # ── Usage guard ─────────────────────────────────────────── seat_count = (await db.execute( select(func.count(SoftwareSeatAllocation.id)).where( SoftwareSeatAllocation.plan_id == plan_id, ), )).scalar_one() bill_count = (await db.execute( select(func.count(SoftwareMonthlyBill.id)).where( SoftwareMonthlyBill.plan_id == plan_id, ), )).scalar_one() if seat_count > 0 or bill_count > 0: raise AppException( code="LICENSE_PLAN_HAS_USAGE", message=( f"Cannot delete plan {plan.name!r}: it has " f"{seat_count} seat allocation(s) (active or revoked) " f"and {bill_count} historical bill row(s). Archive the " "plan instead (set status='archived' via PATCH) so " "finance history stays intact." ), status_code=409, ) snapshot = { "id": str(plan.id), "software_id": str(plan.software_id), "name": plan.name, "price": str(plan.price), "billing_cycle": plan.billing_cycle, "currency": plan.currency, "max_seats": plan.max_seats, "over_quota_policy": plan.over_quota_policy, "status": plan.status, } await db.delete(plan) await db.flush() audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action="asset.license_plan.delete", resource_type="software_license_plan", resource_id=str(plan_id), status="success", detail=snapshot, ip_address=get_real_ip(request), ) await db.commit() return success_response(data={"deleted": True}, request=request) # --------------------------------------------------------------------------- # Seat allocation (request / list / approve / revoke) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Software one-time add-on (增值项) — item config + self-service charges # --------------------------------------------------------------------------- def _addon_item_to_resp(item, sw_name, *, show_price: bool) -> AddonItemResponse: cols = {c.key: getattr(item, c.key) for c in SoftwareAddonItem.__table__.columns} if not show_price: cols["unit_price"] = None return AddonItemResponse(**cols, software_name=sw_name) def _addon_charge_to_resp( charge, *, item_name, software_name, user_name, show_price: bool, ) -> AddonChargeResponse: return AddonChargeResponse( id=charge.id, item_id=charge.item_id, item_name=item_name, software_id=charge.software_id, software_name=software_name, user_id=charge.user_id, user_name=user_name, quantity=charge.quantity, unit_price=charge.unit_price if show_price else None, currency=charge.currency, amount=charge.amount if show_price else None, status=charge.status, reason=charge.reason, department=charge.department, period=charge.period, approval_request_id=charge.approval_request_id, created_at=charge.created_at, approved_at=charge.approved_at, ) @router.get("/addon-items") async def list_all_active_addon_items( request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Every ACTIVE add-on item across all software — powers the unified self-service request catalog (我的申请 ▸ 软件增值项申请), where the employee picks an item directly without pre-selecting a software. Unit price is redacted for non-finance/non-admin callers, same as the per-software listing.""" from app.core.rbac import can_view_finance from app.domains.asset import addon_service show_price = can_view_finance(current_user.role_names) or can_manage_assets( current_user.role_names, ) items = await addon_service.list_active_items_all(db) data = [ _addon_item_to_resp( i, i.software.name if i.software else None, show_price=show_price, ) for i in items ] return success_response(data=data, request=request) @router.get("/software-catalog/{software_id}/addon-items") async def list_addon_items( software_id: UUID, request: Request, include_inactive: bool = Query(False), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List a software's one-time add-on items. Any authenticated user can browse (needed for the request modal); employees see active items only, admins may include inactive. Unit price is redacted for non-finance/ non-admin callers, mirroring the license-plan list.""" from app.core.rbac import can_view_finance from app.domains.asset import addon_service is_admin = can_manage_assets(current_user.role_names) show_price = can_view_finance(current_user.role_names) or is_admin items = await addon_service.list_items_for_software( db, software_id, include_inactive=bool(is_admin and include_inactive), ) sw_name = (await db.execute( select(AssetSoftwareCatalog.name).where(AssetSoftwareCatalog.id == software_id) )).scalar_one_or_none() data = [_addon_item_to_resp(i, sw_name, show_price=show_price) for i in items] return success_response(data=data, request=request) @router.post("/software-catalog/{software_id}/addon-items") async def create_addon_item( software_id: UUID, body: AddonItemCreateRequest, request: Request, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Create a one-time add-on item on a software — admin only.""" from app.domains.asset import addon_service item = await addon_service.create_item( db, software_id=software_id, name=body.name, unit_label=body.unit_label, unit_price=body.unit_price, currency=body.currency, min_quantity=body.min_quantity, step=body.step, max_quantity=body.max_quantity, description=body.description, status=body.status, ) await AuditService(db).log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action="software_addon.item_create", resource_type="software_addon_item", resource_id=str(item.id), status="success", detail={ "software_id": str(software_id), "name": item.name, "unit_price": str(item.unit_price), "currency": item.currency, }, ip_address=get_real_ip(request), ) return success_response( data=_addon_item_to_resp( item, item.software.name if item.software else None, show_price=True, ), request=request, ) @router.put("/addon-items/{item_id}") async def update_addon_item( item_id: UUID, body: AddonItemUpdateRequest, request: Request, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Update an add-on item — admin only. Editing unit_price/currency only affects future charges; in-flight and billed charges keep their frozen snapshot.""" from app.domains.asset import addon_service payload = body.model_dump(exclude_unset=True) item = await addon_service.update_item(db, item_id=item_id, payload=payload) await AuditService(db).log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action="software_addon.item_update", resource_type="software_addon_item", resource_id=str(item.id), status="success", detail={"changed_fields": sorted(payload.keys())}, ip_address=get_real_ip(request), ) return success_response( data=_addon_item_to_resp( item, item.software.name if item.software else None, show_price=True, ), request=request, ) @router.post("/addon-charges") async def request_addon_charge( body: AddonChargeRequestCreate, request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), idempotency_key: str = Header(..., alias="Idempotency-Key"), ): """Employee requests a one-time add-on purchase. Submits to Feishu if a ``software_addon`` flow is configured, else falls back to admin review.""" from app.domains.asset import addon_service charge = await addon_service.request_charge( db, item_id=body.item_id, quantity=body.quantity, reason=body.reason, current_user=current_user, trace_id=request.state.trace_id, ip_address=get_real_ip(request), user_agent=request.headers.get("user-agent"), idempotency_key=idempotency_key, ) item = charge.item return success_response( data=_addon_charge_to_resp( charge, item_name=item.name if item else None, software_name=(item.software.name if item and item.software else None), user_name=current_user.display_name, show_price=True, ), request=request, ) @router.get("/my-addon-charges") async def list_my_addon_charges( request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """The caller's own add-on charges across all statuses.""" rows = (await db.execute( select(SoftwareAddonCharge, SoftwareAddonItem, AssetSoftwareCatalog) .join(SoftwareAddonItem, SoftwareAddonCharge.item_id == SoftwareAddonItem.id) .outerjoin( AssetSoftwareCatalog, SoftwareAddonCharge.software_id == AssetSoftwareCatalog.id, ) .where(SoftwareAddonCharge.user_id == current_user.id) .order_by(SoftwareAddonCharge.created_at.desc()) )).all() data = [ _addon_charge_to_resp( c, item_name=item.name if item else None, software_name=sw.name if sw else None, user_name=current_user.display_name, show_price=True, # own charges — always see own cost ) for c, item, sw in rows ] return success_response(data=data, request=request) @router.post("/addon-charges/{charge_id}/cancel") async def cancel_addon_charge( charge_id: UUID, request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Owner cancels their own still-pending add-on request.""" from app.domains.asset import addon_service charge = await addon_service.cancel_charge( db, charge_id=charge_id, current_user=current_user, trace_id=request.state.trace_id, ip_address=get_real_ip(request), ) return success_response( data={"id": str(charge.id), "status": charge.status}, request=request, ) @router.get("/addon-charges") async def admin_list_addon_charges( request: Request, status_filter: str | None = Query(None, alias="status"), software_id: UUID | None = Query(None), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), current_user: AuthUser = Depends(require_software_read()), db: AsyncSession = Depends(get_db), ): """Admin/finance list of add-on charges, newest first, optional status / software filter. Paginated so the review surface doesn't grow unbounded as charges accumulate over billing periods.""" from sqlalchemy import func as sa_func from app.identity.person_models import IdentityPerson stmt = ( select( SoftwareAddonCharge, SoftwareAddonItem, AssetSoftwareCatalog, IdentityPerson, ) .join(SoftwareAddonItem, SoftwareAddonCharge.item_id == SoftwareAddonItem.id) .outerjoin( AssetSoftwareCatalog, SoftwareAddonCharge.software_id == AssetSoftwareCatalog.id, ) .outerjoin(IdentityPerson, SoftwareAddonCharge.user_id == IdentityPerson.id) .order_by(SoftwareAddonCharge.created_at.desc()) ) # Filters live on the charge row, so the count query can stay on the # base table (the item join is a required FK and never drops rows). count_stmt = select(sa_func.count()).select_from(SoftwareAddonCharge) if status_filter: stmt = stmt.where(SoftwareAddonCharge.status == status_filter) count_stmt = count_stmt.where(SoftwareAddonCharge.status == status_filter) if software_id: stmt = stmt.where(SoftwareAddonCharge.software_id == software_id) count_stmt = count_stmt.where(SoftwareAddonCharge.software_id == software_id) total = (await db.execute(count_stmt)).scalar() or 0 rows = (await db.execute( stmt.offset((page - 1) * page_size).limit(page_size) )).all() data = [ _addon_charge_to_resp( c, item_name=item.name if item else None, software_name=sw.name if sw else None, user_name=person.display_name if person else None, show_price=True, ) for c, item, sw, person in rows ] return paginated_response( data=data, total=total, page=page, page_size=page_size, request=request, ) @router.post("/addon-charges/{charge_id}/approve") async def admin_approve_addon_charge( charge_id: UUID, request: Request, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Admin approves a pending add-on charge (no-Feishu fallback path). Mirrors ``_approve_software_addon_charge``: same leaver re-check and period-pin so the manual path can't drift from the Feishu callback.""" from app.domains.asset import addon_service from app.identity.person_models import IdentityPerson charge = (await db.execute( select(SoftwareAddonCharge).where(SoftwareAddonCharge.id == charge_id) )).scalar_one_or_none() if not charge: raise NotFoundError("Add-on charge", str(charge_id)) if charge.status != "pending": raise AppException( code="INVALID_STATUS", message=( f"Cannot approve a charge in {charge.status!r} status — only " "pending charges are approvable." ), status_code=400, ) target = await db.get(IdentityPerson, charge.user_id) if target is None or target.deleted_at is not None: raise AppException( code="TARGET_NOT_FOUND", message="Target user no longer exists; cannot approve.", status_code=400, ) if is_blocked_from_new_grant(target): raise AppException( code="TARGET_NOT_ACTIVE", message=( f"Target user not eligible (employment_status={target.employment_status!r}, " f"hris_record_state={target.hris_record_state!r}); reject instead of approving." ), status_code=400, ) await addon_service.mark_charge_approved( db, charge, department=target.department, ) await AuditService(db).log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action="software_addon.admin_approve", resource_type="software_addon_charge", resource_id=str(charge.id), status="success", detail={ "item_id": str(charge.item_id), "target_user_id": str(charge.user_id), "amount": str(charge.amount), "currency": charge.currency, "period": charge.period, }, ip_address=get_real_ip(request), ) return success_response( data={"id": str(charge.id), "status": charge.status, "period": charge.period}, request=request, ) @router.post("/addon-charges/{charge_id}/reject") async def admin_reject_addon_charge( charge_id: UUID, request: Request, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Admin rejects a pending add-on charge (no-Feishu fallback path).""" from app.domains.asset import addon_service charge = (await db.execute( select(SoftwareAddonCharge).where(SoftwareAddonCharge.id == charge_id) )).scalar_one_or_none() if not charge: raise NotFoundError("Add-on charge", str(charge_id)) if charge.status != "pending": raise AppException( code="INVALID_STATUS", message=( f"Cannot reject a charge in {charge.status!r} status — only " "pending charges are rejectable." ), status_code=400, ) await addon_service.mark_charge_rejected(db, charge, status="rejected") await AuditService(db).log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action="software_addon.admin_reject", resource_type="software_addon_charge", resource_id=str(charge.id), status="success", detail={"item_id": str(charge.item_id), "target_user_id": str(charge.user_id)}, ip_address=get_real_ip(request), ) return success_response( data={"id": str(charge.id), "status": charge.status}, request=request, ) @router.post("/seat-requests") async def request_seat( body: SeatRequestCreate, request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), idempotency_key: str = Header(..., alias="Idempotency-Key"), ): """Employee requests a software seat. If Feishu approval is configured for software_seat, submits to Feishu and creates a pending allocation that is activated on approval callback. Otherwise falls back to direct pending allocation (admin manual approval). """ from app.approval.submission_idempotency import ( normalize_approval_idempotency_key, stable_approval_resource_id, ) key = normalize_approval_idempotency_key(idempotency_key) allocation_id = stable_approval_resource_id( scope="software_seat", applicant_id=current_user.id, idempotency_key=key, ) plan = (await db.execute( select(SoftwareLicensePlan).where(SoftwareLicensePlan.id == body.plan_id) )).scalar_one_or_none() if not plan: raise AppException(code="NOT_FOUND", message="License plan not found", status_code=404) if plan.status != "active": raise AppException(code="PLAN_INACTIVE", message="License plan is not active", status_code=400) allocation = await db.get(SoftwareSeatAllocation, allocation_id) allocation_created = allocation is None if allocation is not None: if ( allocation.user_id != current_user.id or allocation.plan_id != body.plan_id or (allocation.reason or None) != (body.reason or None) ): raise AppException( code="APPROVAL_IDEMPOTENCY_PAYLOAD_MISMATCH", message=( "This Idempotency-Key was already used with a different " "software seat request." ), status_code=409, ) else: # Enforce quota only for the first attempt. An uncertain retry must # reuse the durable pending row committed with the submission intent, # not reject itself as an already-active request. from app.core.coordinators.provisioning_coordinator import ( check_no_active_seat_request, check_plan_quota, ) await check_no_active_seat_request( db, plan_id=plan.id, user_id=current_user.id, ) await check_plan_quota(db, plan.id) candidate = SoftwareSeatAllocation( id=allocation_id, plan_id=body.plan_id, user_id=current_user.id, status="pending", reason=body.reason, ) try: async with db.begin_nested(): db.add(candidate) await db.flush() allocation = candidate except IntegrityError: allocation = await db.get(SoftwareSeatAllocation, allocation_id) if allocation is None: raise AppException( code="APPROVAL_SUBMISSION_IN_PROGRESS", message="This software seat request is still in progress.", status_code=409, ) from None allocation_created = False assert allocation is not None # Try Feishu approval flow from app.approval.service import ApprovalService svc = ApprovalService(db) feishu_config = (await svc.template_config()).get("software_seat") if feishu_config: from app.approval.schemas import AssetApprovalSubmitRequest # Auto-fill Feishu form fields from plan data. Billing-cycle # label respects ``identity_persons.preferred_language`` so an # ``en_US`` employee sees "Monthly" / "Yearly" / "One-time" # in the Feishu approval card instead of 月付 / 年付. applicant_lang = (current_user.preferred_language or "").lower() if applicant_lang.startswith("en") or applicant_lang.startswith("ja"): billing_labels = { "monthly": "Monthly", "yearly": "Yearly", "one_time": "One-time", } else: billing_labels = { "monthly": "月付", "yearly": "年付", "one_time": "一次性", } software_name = plan.software.name if plan.software else "Unknown" price_str = f"${plan.price}/{billing_labels.get(plan.billing_cycle or '', plan.billing_cycle or '')}" if plan.price else "-" # Bilingual keys — the serializer's ``name_to_id`` lookup # resolves a single widget per name; writing each value under # BOTH the Chinese and the English label means a Feishu admin # rename in either direction (e.g. ``软件名称`` → ``Software # Name``) is survivable without a code change. Unknown keys # are stripped by ``_serialize_feishu_form``'s widget-id # filter, so the duplicates are inert when only one alias # matches the live template. Same hardening as the # ``_pick(*aliases)`` bilingual lookup in # ``_collect_request_type_metadata`` — bug class is "silent # data loss on widget rename" (2026-05-12). feishu_answers: dict[str, Any] = {} for value, aliases in ( (software_name, ("软件名称", "Software Name")), (plan.name or "", ("坐席类型", "Plan Name", "Seat Type")), (price_str, ("单价", "Unit Price", "Price")), (body.reason or "", ("申请原因", "Reason", "Application Reason")), ): for alias in aliases: feishu_answers[alias] = value # Pre-launch P0 fix (Round 45, CR-305): never let # client-supplied ``answers`` overwrite server-resolved # auto-fill fields (software name, plan name, price, reason). # Without this, an employee could submit body.answers with a # fake software_name + Free-tier price to deceive the approver # while body.plan_id silently points at the actual (paid) plan. # The seat that activates is keyed off plan_id (server-side # validated), so this isn't direct privilege escalation — but # it's a clean approval-fraud + audit-trail-poisoning vector. _server_aliases = { "软件名称", "Software Name", "坐席类型", "Plan Name", "Seat Type", "单价", "Unit Price", "Price", "申请原因", "Reason", "Application Reason", } if body.answers: for k, v in body.answers.items(): if k in _server_aliases: continue feishu_answers[k] = v submit_body = AssetApprovalSubmitRequest( template_key="software_seat", reason=body.reason, answers=feishu_answers, ) # Pre-launch hardening 2026-05-04: ``extra_source_data`` and # ``extra_detail`` are no longer accepted on the public # ``AssetApprovalSubmitRequest`` schema. They're plumbing knobs # passed via kw-only service params now, so an external caller # cannot inject a victim's ``seat_allocation_id`` to force a # cross-user seat activation. approval_resp = await svc.create_asset_approval_request( body=submit_body, current_user=current_user, trace_id=request.state.trace_id, ip_address=get_real_ip(request), user_agent=request.headers.get("user-agent"), idempotency_key=key, internal_idempotency_payload={ "kind": "software_seat", "plan_id": str(body.plan_id), "reason": body.reason, "answers": body.answers, }, internal_extra_source_data={ "seat_allocation_id": str(allocation.id), }, # Stamp the resolved software / plan / provider onto the # request's ``detail`` so the "我的申请" + "管理员审批" tables # can render dedicated columns without rejoining # software_license_plans → asset_software_catalog on every # list call. Surfaced by the frontend as # ``record.detail.software_seat.{software_name|plan_name|sso_provider}``. internal_extra_detail={ "software_seat": { "software_id": ( str(plan.software_id) if plan.software_id else None ), "software_name": ( plan.software.name if plan.software else None ), "plan_id": str(plan.id), "plan_name": plan.name, "billing_cycle": plan.billing_cycle, "price": ( str(plan.price) if plan.price is not None else None ), "sso_provider": ( plan.software.sso_provider if plan.software else None ), "seat_allocation_id": str(allocation.id), }, }, ) if allocation.approval_request_id is None: allocation.approval_request_id = approval_resp.id await AuditService(db).log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action="software_seat.request", resource_type="software_seat_allocation", resource_id=str(allocation.id), status="success", detail={ "plan_id": str(body.plan_id), "software_name": ( plan.software.name if plan.software else None ), "approval_request_id": str(approval_resp.id), "feishu_approval": True, }, ip_address=get_real_ip(request), ) else: # No Feishu approval configured — admin manual approval fallback if allocation_created: await AuditService(db).log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action="software_seat.request", resource_type="software_seat_allocation", resource_id=str(allocation.id), status="success", detail={ "plan_id": str(body.plan_id), "software_name": ( plan.software.name if plan.software else None ), "feishu_approval": False, }, ip_address=get_real_ip(request), ) return success_response( data=SeatAllocationResponse( id=allocation.id, plan_id=allocation.plan_id, plan_name=plan.name, software_id=plan.software_id, software_name=plan.software.name if plan.software else None, billing_cycle=plan.billing_cycle, price=plan.price, user_id=allocation.user_id, user_name=current_user.display_name, department=current_user.department, status=allocation.status, reason=allocation.reason, allocated_at=allocation.allocated_at, ), request=request, ) @router.post("/seat-allocations") async def admin_grant_seat( body: AdminSeatGrantRequest, request: Request, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Admin directly grants a seat to another user — no approval loop. Mirrors the approval-callback path's "seat active + enqueue grant" outcome. Idempotent: if the target already has an ``active`` seat on the plan, returns it unchanged instead of creating a duplicate (Zoom would 409 on the grant call anyway). Quota rules mirror the self-request path: - ``reject`` policy: hard-fail with 409 QUOTA_EXCEEDED. - ``evict_lru`` policy: proceed — the worker's dispatch path evicts the LRU holder when the grant task runs. """ from datetime import datetime as dt from app.core.coordinators.provisioning_coordinator import ( check_plan_quota, enqueue_grant_for_seat, ) from app.identity.person_models import IdentityPerson # SELECT FOR UPDATE on the plan row closes the TOCTOU window # against concurrent admin grants for the same (plan, user). Two # concurrent calls serialise on the plan-row lock; the second one # observes the freshly-inserted row in the existence check below # and short-circuits to "return existing". Mirrors the # ``check_no_active_seat_request`` helper used by the self-request # path. Pre-launch hardening 2026-05-05 (P1 from CR3 + LS1). plan = (await db.execute( select(SoftwareLicensePlan) .where(SoftwareLicensePlan.id == body.plan_id) .with_for_update(), )).scalar_one_or_none() if not plan: raise AppException(code="NOT_FOUND", message="License plan not found", status_code=404) if plan.status != "active": raise AppException( code="PLAN_INACTIVE", message="License plan is not active", status_code=400, ) target = await db.get(IdentityPerson, body.target_user_id) if not target or target.deleted_at is not None: raise AppException( code="NOT_FOUND", message="Target user not found", status_code=404, ) # Pre-launch hardening 2026-05-04: also block ``leaver_pending``. # Previously only ``left`` was blocked, leaving a window where IT # could grant a paid seat to someone whose offboarding had already # started — billing the org for a seat the user would never use. if is_blocked_from_new_grant(target): raise AppException( code="TARGET_NOT_ACTIVE", message=( f"Cannot grant a seat — user not eligible (employment_status=" f"{target.employment_status!r}, hris_record_state={target.hris_record_state!r})" ), status_code=400, ) # Idempotent: active OR pending seat on same plan → return # existing, don't create. (Pending counts here too — a half- # completed approval flow holds a row that the new grant would # collide with at the unique-index level.) existing = (await db.execute( select(SoftwareSeatAllocation).where( SoftwareSeatAllocation.plan_id == plan.id, SoftwareSeatAllocation.user_id == target.id, SoftwareSeatAllocation.status.in_(("active", "pending")), ) .order_by(SoftwareSeatAllocation.allocated_at.desc()) )).scalars().first() if existing is not None: return success_response( data=SeatAllocationResponse( id=existing.id, plan_id=existing.plan_id, plan_name=plan.name, software_id=plan.software_id, software_name=plan.software.name if plan.software else None, billing_cycle=plan.billing_cycle, price=plan.price, user_id=existing.user_id, user_name=target.display_name, department=target.department, status=existing.status, reason=existing.reason, allocated_at=existing.allocated_at, ), request=request, ) await check_plan_quota(db, plan.id) now = dt.now(UTC) allocation = SoftwareSeatAllocation( plan_id=plan.id, user_id=target.id, status="active", allocated_at=now, reason=body.reason or f"admin grant by {current_user.email}", ) db.add(allocation) await db.flush() # Wire the grant into the provisioning queue. If the software has a # connector bound (e.g. Zoom), the worker runs ``grant_seat``; if # not, the active row stands on its own and admin handles the # vendor side manually. async with best_effort( db, label=f"admin grant: enqueue for seat {allocation.id}", log=logger, ) as grant_step: await enqueue_grant_for_seat( db, seat=allocation, trace_id=f"admin_grant:{allocation.id}", actor_user_id=current_user.id, ) if not grant_step.ok: logger.warning( "enqueue_grant_for_seat failed for admin grant %s: %s", allocation.id, grant_step.reason(), ) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action="software_seat.admin_grant", resource_type="software_seat_allocation", resource_id=str(allocation.id), status="success", detail={ "target_user_id": str(target.id), "target_email": target.work_email, "plan_id": str(plan.id), "plan_name": plan.name, "software_name": plan.software.name if plan.software else None, "over_quota_policy": plan.over_quota_policy, }, ip_address=get_real_ip(request), ) return success_response( data=SeatAllocationResponse( id=allocation.id, plan_id=allocation.plan_id, plan_name=plan.name, software_id=plan.software_id, software_name=plan.software.name if plan.software else None, billing_cycle=plan.billing_cycle, price=plan.price, user_id=allocation.user_id, user_name=target.display_name, department=target.department, status=allocation.status, reason=allocation.reason, allocated_at=allocation.allocated_at, ), request=request, ) @router.post("/seat-allocations/{allocation_id}/approve") async def approve_seat( allocation_id: UUID, request: Request, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Admin approves a pending seat request (no-Feishu fallback path). Mirrors ``admin_grant_seat`` and the Feishu ``_activate_software_seat`` callback so the manual-approval flow has identical post-conditions: leaver re-check, plan-quota re-check, vendor-side grant enqueue, and audit trail. Without these the row is silently flipped to ``active`` with no provisioning task and no audit row — a compliance hole that a 500-employee tenant will not survive its first audit. """ from datetime import datetime as dt from app.core.coordinators.provisioning_coordinator import ( check_plan_quota, enqueue_grant_for_seat, ) from app.identity.person_models import IdentityPerson row = (await db.execute( select(SoftwareSeatAllocation, SoftwareLicensePlan) .join( SoftwareLicensePlan, SoftwareSeatAllocation.plan_id == SoftwareLicensePlan.id, ) .where(SoftwareSeatAllocation.id == allocation_id) )).first() if row is None: raise AppException( code="NOT_FOUND", message="Allocation not found", status_code=404, ) alloc, plan = row if alloc.status != "pending": raise AppException( code="INVALID_STATUS", message=( f"Cannot approve allocation in {alloc.status!r} status " "(only 'pending' allocations are approvable)." ), status_code=400, ) # Re-check the target is still active. The original request may # have been submitted weeks ago and the person could have left in # the meantime — granting a paid seat to a leaver is a compliance # incident. Mirrors ``admin_grant_seat``. target = await db.get(IdentityPerson, alloc.user_id) if target is None or target.deleted_at is not None: raise AppException( code="TARGET_NOT_FOUND", message="Target user no longer exists; cannot approve seat.", status_code=400, ) # Round 2b audit fix-up 2026-05-04: also block ``leaver_pending``, # mirroring ``admin_grant_seat`` and ``_activate_software_seat``. # Without this, an asset admin could approve a pending seat for a # user whose offboarding has already started — re-opening the # documented "no paid seat for a leaver" invariant. if is_blocked_from_new_grant(target): raise AppException( code="TARGET_NOT_ACTIVE", message=( f"Target user not eligible (employment_status={target.employment_status!r}, " f"hris_record_state={target.hris_record_state!r}); reject " "instead of approving." ), status_code=400, ) # Re-check quota at activation time. The plan cap may have been # tightened, or other concurrent activations may have consumed the # remaining seats since the original request was submitted. await check_plan_quota(db, plan.id) alloc.status = "active" alloc.allocated_at = dt.now(UTC) await db.flush() task_id: UUID | None = None # SAVEPOINT-isolate: this is the admin fallback for exactly the seats # the Feishu path failed on, so it loads rows carrying the same # EmployeeRequest-valued ``approval_request_id``. Before the 2026-08-06 # fix it hit the identical FK violation, and the swallowed error then # deactivated the transaction so the audit write below raised — the # admin's 批准 click 500'd and the activation rolled back, every time. async with best_effort( db, label=f"admin approve: enqueue grant for seat {alloc.id}", log=logger, ) as enqueue_step: task_id = await enqueue_grant_for_seat( db, seat=alloc, trace_id=f"admin_approve:{alloc.id}", actor_user_id=current_user.id, ) if not enqueue_step.ok: task_id = None audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action="software_seat.admin_approve", resource_type="software_seat_allocation", resource_id=str(alloc.id), status="success", detail={ "plan_id": str(alloc.plan_id), "plan_name": plan.name, "target_user_id": str(alloc.user_id), "target_email": target.work_email, "software_name": plan.software.name if plan.software else None, "provisioning_task_id": str(task_id) if task_id else None, }, ip_address=get_real_ip(request), ) # 2026-05-12: when the admin manually approves a seat that ALSO # has a parallel Feishu approval instance in flight, cancel the # Feishu card so the approver's inbox doesn't sit on a stale # "pending" entry forever — `ApprovalService` has no # approve-task endpoint, so cancellation is the only way to # converge the inboxes when the admin path overrides Feishu. # Logic extracted to ``_cancel_parallel_feishu_instance_for_seat`` # for direct unit-test coverage (mirrors the testable shape of # ``CloudScopeRequestService._cancel_feishu_instance_on_admin_decision``). # # Commit FIRST, then call Feishu. Without this, a Feishu cancel # could succeed BEFORE get_db()'s post-yield commit, and any # subsequent failure (transient DB error, downstream raise) would # roll back the local seat back to "pending" while Feishu already # shows "cancelled" — the reverse orphan. Mirrors the explicit # `db.commit()` in scope_request_service.approve / reject and the # existing `withdraw()` pattern. await db.commit() await _cancel_parallel_feishu_instance_for_seat( db, seat_allocation_id=alloc.id, actor_work_email=current_user.work_email, ) return success_response( data={ "id": str(alloc.id), "status": "active", "provisioning_task_id": str(task_id) if task_id else None, }, request=request, ) @router.get("/seat-allocations/{allocation_id}/revoke-preview") async def revoke_seat_preview( allocation_id: UUID, request: Request, current_user: AuthUser = Depends(require_software_read()), db: AsyncSession = Depends(get_db), ): """Preview endpoint: tells the frontend whether this seat's software requires a successor (Zoom delete / Google user_suspend) and, if so, suggests the target user's manager as a default pick. Returned in advance so the UI can decide between a simple confirm dialog and a full successor-picker Modal without first POSTing a revoke and handling a validation error. """ from app.core.coordinators.provisioning_coordinator import ( resolve_revoke_requires_successor, ) from app.identity.person_models import IdentityPerson row = (await db.execute( select(SoftwareSeatAllocation, SoftwareLicensePlan) .join(SoftwareLicensePlan, SoftwareSeatAllocation.plan_id == SoftwareLicensePlan.id) .where(SoftwareSeatAllocation.id == allocation_id) )).first() if row is None: raise AppException(code="NOT_FOUND", message="Allocation not found", status_code=404) alloc, plan = row requires, provider = await resolve_revoke_requires_successor( db, software_id=plan.software_id, ) suggested_id: UUID | None = None suggested_name: str | None = None if requires: target = await db.get(IdentityPerson, alloc.user_id) if target is not None and target.manager_person_id is not None: manager = await db.get(IdentityPerson, target.manager_person_id) if manager is not None and manager.employment_status == "active": suggested_id = manager.id suggested_name = manager.display_name return success_response( data=SeatRevokePreviewResponse( requires_successor=requires, provider=provider, suggested_successor_user_id=suggested_id, suggested_successor_display_name=suggested_name, ), request=request, ) @router.post("/seat-allocations/{allocation_id}/revoke") # Vendor-side seat revoke (and, for some drivers, a data handoff) is not # internally idempotent. @idempotent( "software_seat.revoke", key_params=("allocation_id", "current_user.id"), ) async def revoke_seat( allocation_id: UUID, request: Request, body: AdminSeatRevokeRequest | None = None, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Admin revokes an active seat. When the bound driver needs a vendor-side data handoff (Zoom with ``action_on_offboard=delete`` moving meetings/recordings, Google Workspace ``user_suspend`` moving Drive/Calendar ownership), a ``successor_user_id`` is required. If the caller omits it the endpoint falls back to the target user's manager; if no manager is set the call 400s so the admin picks one in the UI. Successor must: • be a different person from the target, • have employment_status = 'active', • hold an active seat on the same software (otherwise the vendor-side transfer would land data on an account that doesn't exist at the provider). """ from datetime import datetime as dt from app.core.coordinators.provisioning_coordinator import ( enqueue_revoke_for_seat, resolve_revoke_requires_successor, ) from app.domains.provisioning.models import SOURCE_MANUAL from app.identity.person_models import IdentityPerson row = (await db.execute( select(SoftwareSeatAllocation, SoftwareLicensePlan) .join(SoftwareLicensePlan, SoftwareSeatAllocation.plan_id == SoftwareLicensePlan.id) .where(SoftwareSeatAllocation.id == allocation_id) )).first() if row is None: raise AppException(code="NOT_FOUND", message="Allocation not found", status_code=404) alloc, plan = row # Refuse to revoke a non-active row. Without this guard a duplicate # admin click (or a race between two admins) overwrites the # original ``revoked_at`` timestamp — destroying the seat's death # date and breaking the "did this leaver still have a paid seat # on date X" forensic question. Pre-launch hardening 2026-05-05 # (P1 from LS2). if alloc.status != "active": raise AppException( code="INVALID_STATUS", message=( f"Seat allocation is in status {alloc.status!r}; only " "an active seat can be revoked. (Re-revoking a revoked " "seat would overwrite its original revoke_at timestamp.)" ), status_code=409, ) requires_successor, provider = await resolve_revoke_requires_successor( db, software_id=plan.software_id, ) successor_user_id: UUID | None = body.successor_user_id if body else None successor_email: str | None = None if requires_successor: # Default to manager if caller didn't pick one. if successor_user_id is None: target = await db.get(IdentityPerson, alloc.user_id) if target is not None and target.manager_person_id is not None: successor_user_id = target.manager_person_id if successor_user_id is None: raise AppException( code="SUCCESSOR_REQUIRED", message=( f"Revoking this seat requires a successor " f"({provider or 'driver'} needs data handoff). " "Pick a successor and retry." ), status_code=400, ) if successor_user_id == alloc.user_id: raise AppException( code="SUCCESSOR_INVALID", message="Successor must be a different person from the seat holder.", status_code=400, ) successor = await db.get(IdentityPerson, successor_user_id) if successor is None: raise AppException( code="SUCCESSOR_NOT_FOUND", message="Successor user not found.", status_code=400, ) if successor.employment_status != "active": raise AppException( code="SUCCESSOR_INACTIVE", message="Successor is not an active employee.", status_code=400, ) # Successor must hold an active seat on the same software so # the vendor-side transfer has somewhere to land. successor_seat = await db.scalar( select(SoftwareSeatAllocation) .join(SoftwareLicensePlan, SoftwareSeatAllocation.plan_id == SoftwareLicensePlan.id) .where( SoftwareSeatAllocation.user_id == successor_user_id, SoftwareLicensePlan.software_id == plan.software_id, SoftwareSeatAllocation.status == "active", ) ) if successor_seat is None: raise AppException( code="SUCCESSOR_NO_SEAT", message=( "Successor must hold an active seat on the same " "software before data can be transferred to them." ), status_code=400, ) # Cross-department exfiltration guard. ``action_on_offboard= # delete`` (Zoom) and ``user_suspend`` (Google) move recordings # / Drive ownership to the successor. An asset_admin who sets # an out-of-BU successor effectively exfiltrates data. We # require an explicit confirm flag AND restrict cross-dept to # global_admin so the typical case (manager-fallback within # same dept) is unblocked but the exfiltration vector is # closed. # # Department resolution goes through a direct membership query # rather than ``IdentityPerson.department`` because the latter # iterates over the ``org_memberships`` relationship and would # trigger an async lazy-load (MissingGreenlet) on a row hydrated # by ``db.get``. from app.identity.person_models import IdentityOrgMembership async def _primary_dept(person_id: UUID) -> str | None: return await db.scalar( select(IdentityOrgMembership.org_node_name) .where( IdentityOrgMembership.person_id == person_id, IdentityOrgMembership.is_primary == True, # noqa: E712 IdentityOrgMembership.deleted_at.is_(None), ) .limit(1) ) target_dept = await _primary_dept(alloc.user_id) successor_dept = await _primary_dept(successor_user_id) cross_department = bool( target_dept and successor_dept and target_dept != successor_dept ) if cross_department: confirmed = bool( body and getattr(body, "confirm_cross_department", False) ) if not confirmed: raise AppException( code="SUCCESSOR_CROSS_DEPARTMENT", message=( "Successor is in a different department from the " "seat holder. Re-submit with confirm_cross_" "department=true if this is intentional. Note: " "vendor-side data (Zoom recordings, Drive files) " "will transfer to the successor." ), status_code=400, ) from app.core.rbac import GLOBAL_ADMIN_ROLE, has_role if not has_role(current_user.role_names, GLOBAL_ADMIN_ROLE): raise ForbiddenError( "Cross-department seat revoke with data transfer is " "restricted to global_admin to prevent exfiltration. " "Ask a global admin to perform this revoke.", ) successor_email = successor.work_email # Atomicity: enqueue the revoke task FIRST, then flip the seat to # revoked. The previous order (flip-then-enqueue) had a footgun # where a transient enqueue failure left Helix saying "revoked" # while the vendor still had the user — billing would stop # counting the seat next cycle but the access stayed live. # ``enqueue_revoke_for_seat`` is read-only on the seat row and # idempotent on its task lookup, so it's safe to call before the # status flip. If it raises, we abort the whole revoke and bubble # the error to the caller — better a 5xx than silent inconsistency. task_id: UUID | None = None try: task_id = await enqueue_revoke_for_seat( db, seat=alloc, trace_id=f"admin_revoke:{alloc.id}", actor_user_id=current_user.id, source=SOURCE_MANUAL, enforce_offboarding_policy=False, successor_user_id=successor_user_id, successor_email=successor_email, # Direct admin revoke has no sibling state — opt the task # into the dispatcher's failure-revert hook so a Zoom 1107 # / vendor 5xx beyond max retries flips the seat back to # ``active`` instead of leaving a falsely-revoked row. revert_seat_on_failure=True, ) except Exception as exc: # noqa: BLE001 logger.warning( "enqueue_revoke_for_seat failed for admin revoke %s: %s", alloc.id, exc, ) raise AppException( code="REVOKE_ENQUEUE_FAILED", message=( "Could not enqueue the vendor-side revoke task. The " "seat was NOT marked revoked locally to keep state " "consistent. Try again or escalate to the platform " "team." ), status_code=503, ) from exc alloc.status = "revoked" alloc.revoked_at = dt.now(UTC) await db.flush() audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action="software_seat.admin_revoke", resource_type="software_seat_allocation", resource_id=str(alloc.id), status="success", detail={ "plan_id": str(alloc.plan_id), "target_user_id": str(alloc.user_id), "successor_user_id": str(successor_user_id) if successor_user_id else None, "provisioning_task_id": str(task_id) if task_id else None, }, ) return success_response( data={ "id": str(alloc.id), "status": "revoked", "successor_user_id": str(successor_user_id) if successor_user_id else None, "provisioning_task_id": str(task_id) if task_id else None, }, request=request, ) @router.get("/my/seats") async def list_my_seats( request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Employee views own seat allocations.""" rows = (await db.execute( select(SoftwareSeatAllocation) .where(SoftwareSeatAllocation.user_id == current_user.id) .order_by(SoftwareSeatAllocation.allocated_at.desc()) )).scalars().all() return success_response(data=await _enrich_seat_allocations(db, list(rows)), request=request) def _start_of_next_month_utc(now: _dt) -> _dt: """First instant (UTC) of the calendar month after ``now``. A seat scheduled for "next-month cancellation" is revoked by the cron at this exact instant. Picking month-start (not month-end) means the employee keeps the seat for every day they've been billed for and loses it the moment the new bill cycle starts — matches every SaaS vendor's own monthly-renewal semantics. """ if now.month == 12: return now.replace( year=now.year + 1, month=1, day=1, hour=0, minute=0, second=0, microsecond=0, ) return now.replace( month=now.month + 1, day=1, hour=0, minute=0, second=0, microsecond=0, ) @router.get("/seat-allocations/{allocation_id}/cancel-preview") async def preview_cancel_seat( allocation_id: UUID, request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Read-only preview: what would cancelling this seat affect? Surfaces the **product-access cascade** for the confirm dialog — so a user clicking "下月取消" on Jira eazyBI sees up-front that the action will also cancel Jira Software + Tempo + ScriptRunner (4 plans total, because they all ride the same Atlassian ``jira-software`` product license). Without this preview, the cascade only surfaced AFTER confirm in the success toast, which was honest but late. Returns ``cascade_sibling_count`` + the plan names so the modal can render an unambiguous "this action will cancel: [Jira Software, Jira eazyBI, Jira Tempo Timesheets, Jira ScriptRunner]" list. For non-cascade vendors (Slack / Zoom / Google / Feishu) the count is 0 and the modal can fall back to the plain copy. Same authorisation as ``cancel-next-month`` (seat holder OR admin). 404 on unknown allocation, 403 on stranger. **Read-only** — explicitly no side effects so refresh-on-hover is safe. """ alloc = await db.get(SoftwareSeatAllocation, allocation_id) if alloc is None: raise AppException( code="NOT_FOUND", message="Allocation not found", status_code=404, ) is_admin = can_manage_assets(current_user.role_names) is_self = alloc.user_id == current_user.id if not (is_admin or is_self): raise ForbiddenError( "You can only preview your own seat allocation.", ) plan_row = await db.get(SoftwareLicensePlan, alloc.plan_id) plan_name = plan_row.name if plan_row else None # Self-cancel policy — surface so the confirm modal can render the # managed-software state (employee: button hidden/disabled; admin: # an "on the employee's behalf" note) without a second roundtrip. catalog_row = ( await db.get(AssetSoftwareCatalog, plan_row.software_id) if plan_row is not None else None ) allow_self_cancel = ( bool(catalog_row.allow_self_cancel) if catalog_row is not None else True ) cascade_sibling_plan_names: list[str] = [] cascade_sibling_truncated = False cascade_total_siblings = 0 uses_product_access_path = False try: from sqlalchemy.exc import SQLAlchemyError # narrowed-except import from app.core.coordinators.provisioning_coordinator import ( _driver_supports_product_access_revoke, ) from app.domains.provisioning.models import ( SoftwareConnector as _SCRow, ) if plan_row is not None: cp_connector = await db.scalar( select(_SCRow).where( _SCRow.software_id == plan_row.software_id, _SCRow.is_active.is_(True), ), ) if ( cp_connector is not None and await _driver_supports_product_access_revoke(db, cp_connector) ): uses_product_access_path = True product_key = str( (cp_connector.binding_config or {}).get("product_key") or "", ).strip().lower() integration_id = cp_connector.integration_id if product_key: from sqlalchemy import func as _func rows = (await db.execute( select(SoftwareLicensePlan.name) .select_from(SoftwareSeatAllocation) .join( SoftwareLicensePlan, SoftwareLicensePlan.id == SoftwareSeatAllocation.plan_id, ) .join( AssetSoftwareCatalog, AssetSoftwareCatalog.id == SoftwareLicensePlan.software_id, ) .join( _SCRow, _SCRow.software_id == AssetSoftwareCatalog.id, ) .where( SoftwareSeatAllocation.user_id == alloc.user_id, SoftwareSeatAllocation.status == "active", SoftwareSeatAllocation.id != alloc.id, _SCRow.integration_id == integration_id, _SCRow.is_active.is_(True), _func.json_unquote( _func.json_extract( _SCRow.binding_config, "$.product_key", ), ) == product_key, ) .order_by(SoftwareLicensePlan.name.asc()) )).scalars().all() cascade_total_siblings = len(rows) cascade_sibling_plan_names = [ # Cap at 50 so a corrupted catalog can't bloat # the response; frontend renders the real total # via ``cascade_sibling_count`` (NOT 50+1 — the # original off-by-N: 200 real siblings showed # as "51 sibling seat(s)" in the modal). # Round 3 Round 2 P2-1. str(r) for r in list(rows)[:50] ] cascade_sibling_truncated = cascade_total_siblings > 50 except (SQLAlchemyError, KeyError, ValueError, AttributeError) as exc: # Narrow except: a SQL / shape error in the discovery query # IS a real problem the operator must know about — we surface # it via ``preview_error`` so the frontend renders the modal # in an explicit error state (Alert + disabled OK) instead of # silently showing "no cascade" and letting the user proceed # into a cascade they were never warned about. Pre-Round-3 # this branch was a bare ``except Exception`` that turned a # broken preview into "cascade_sibling_count: 0" — the exact # silent-degrade pattern the user banned. logger.error( "cancel-preview cascade discovery failed for seat %s trace_id=%s " "error_type=%s", alloc.id, getattr(request.state, "trace_id", None), type(exc).__name__, ) return success_response( data={ "id": str(alloc.id), "plan_id": str(alloc.plan_id), "plan_name": plan_name, "uses_product_access_path": False, "cascade_sibling_count": 0, "cascade_sibling_plan_names": [], "allow_self_cancel": allow_self_cancel, "preview_error": "INTERNAL_ERROR", }, request=request, ) return success_response( data={ "id": str(alloc.id), "plan_id": str(alloc.plan_id), "plan_name": plan_name, "uses_product_access_path": uses_product_access_path, # Round 3 Round 2 P2-1: report the REAL sibling total # from the query, not ``len(names) + 1``. Pre-fix, a user # with 200 cascade siblings saw "51 sibling seat(s)" in # the modal (50 capped names + 1 truncation marker) — # accurate names list, wildly inaccurate count. "cascade_sibling_count": cascade_total_siblings, "cascade_sibling_plan_names": cascade_sibling_plan_names, "cascade_sibling_truncated": cascade_sibling_truncated, "allow_self_cancel": allow_self_cancel, "preview_error": None, }, request=request, ) @router.post("/seat-allocations/{allocation_id}/cancel-next-month") async def request_cancel_seat_next_month( allocation_id: UUID, request: Request, body: SeatCancelNextMonthRequest | None = None, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Self-service: cancel an active seat at the end of the current billing period (= start of next month UTC). Authorisation: • Seat holder may cancel their own seat — the natural self-service path the IT 宇宙 page surfaces. • Asset / global admin may cancel anyone's seat (admin-on-behalf, e.g. when an employee asks IT in chat to do it for them). • Anyone else gets 403. The actual revoke is performed by the ``software_seat.auto_revoke_pending_cancels`` cron at the next month boundary; this endpoint only marks the row. Submitting twice updates the reason but leaves the schedule unchanged (idempotent — the boundary was already the same one). Already-revoked seats can't be cancelled again. Seats with a pending cancellation can be re-submitted to update the reason (the cancel_at_period_end + cancel_requested_at fields are preserved). """ from datetime import datetime as dt alloc = await db.get(SoftwareSeatAllocation, allocation_id) if alloc is None: raise AppException( code="NOT_FOUND", message="Allocation not found", status_code=404, ) # Ownership BEFORE state. Reversing these would let a caller probe # whether a stranger's allocation is ``active`` by error-code # differential (400 SEAT_NOT_ACTIVE vs 403 forbidden); the MCP twin # already orders correctly — the HTTP path must match. is_admin = can_manage_assets(current_user.role_names) is_self = alloc.user_id == current_user.id if not (is_admin or is_self): raise ForbiddenError( "You can only cancel your own seat allocation.", ) # Self-cancel policy gate. IT-managed identity software (Google / # Feishu) is marked ``allow_self_cancel=False`` on its catalog row: # the holder must NOT be able to drop their own seat (the account is # provisioned + governed by IT and tied to SSO). Admins bypass this — # they cancel on the employee's behalf. Checked before the status # gate so a blocked self caller gets the policy reason, not a state # oracle. if is_self and not is_admin: plan_for_policy = await db.get(SoftwareLicensePlan, alloc.plan_id) catalog_for_policy = ( await db.get(AssetSoftwareCatalog, plan_for_policy.software_id) if plan_for_policy is not None else None ) if ( catalog_for_policy is not None and catalog_for_policy.allow_self_cancel is False ): raise ForbiddenError( "This software is managed by IT and cannot be cancelled by " "you. Contact IT if you no longer need this access.", ) if alloc.status != "active": raise AppException( code="SEAT_NOT_ACTIVE", message=( "Seat is not active; only active seats can be scheduled " "for cancellation." ), status_code=400, ) now = dt.now(UTC) boundary = _start_of_next_month_utc(now) alloc.cancel_at_period_end = boundary alloc.cancel_requested_at = now alloc.cancel_requested_by = current_user.id alloc.cancel_reason = (body.reason if body else None) or alloc.cancel_reason # ── Sibling cascade for product_access-style vendors ────────── # If the connector's driver revokes by stripping every conferring # group (Atlassian / Jira today), the cron's eventual revoke will # cascade across all sibling allocations the user has on plans # sharing the same (integration, product_key). Reflect that upfront # by also stamping cancel_at_period_end on the siblings — otherwise # the user clicks "cancel eazyBI" today, sees three other Jira plan # rows still labelled active for the rest of the month, then on the # 1st they all disappear at once with no warning. T+0 transparency. cascade_siblings: list[SoftwareSeatAllocation] = [] # SAVEPOINT-isolate (2026-08-06): "cosmetic UX enhancement must not fail # the user's primary action" only holds if the session survives — the # cancel's own writes and audit come after this block. async with best_effort( db, label=f"cancel-next-month sibling cascade for seat {alloc.id}", log=logger, ) as cascade_step: from app.core.coordinators.provisioning_coordinator import ( _driver_supports_product_access_revoke, ) from app.domains.provisioning.models import ( SoftwareConnector as _SCRow, ) plan_row = await db.get(SoftwareLicensePlan, alloc.plan_id) if plan_row is not None: cn_connector = await db.scalar( select(_SCRow).where( _SCRow.software_id == plan_row.software_id, _SCRow.is_active.is_(True), ), ) if ( cn_connector is not None and await _driver_supports_product_access_revoke(db, cn_connector) ): product_key = str( (cn_connector.binding_config or {}).get("product_key") or "", ).strip().lower() integration_id = cn_connector.integration_id if product_key: from sqlalchemy import func as _func cascade_siblings = list((await db.execute( select(SoftwareSeatAllocation) .join( SoftwareLicensePlan, SoftwareLicensePlan.id == SoftwareSeatAllocation.plan_id, ) .join( AssetSoftwareCatalog, AssetSoftwareCatalog.id == SoftwareLicensePlan.software_id, ) .join( _SCRow, _SCRow.software_id == AssetSoftwareCatalog.id, ) .where( SoftwareSeatAllocation.user_id == alloc.user_id, SoftwareSeatAllocation.status == "active", SoftwareSeatAllocation.id != alloc.id, _SCRow.integration_id == integration_id, _SCRow.is_active.is_(True), _func.json_unquote( _func.json_extract( _SCRow.binding_config, "$.product_key", ), ) == product_key, ) )).scalars().all()) for sib in cascade_siblings: sib.cancel_at_period_end = boundary sib.cancel_requested_at = now sib.cancel_requested_by = current_user.id sib.cancel_reason = ( (body.reason if body else None) or alloc.cancel_reason or ( "cascade: cancel-next-month requested on " f"allocation {alloc.id} (shared product " f"{product_key} on the same Atlassian site)" ) ) if not cascade_step.ok: # If cascade discovery fails for any reason, the source seat's # cancel still goes through and the cron will eventually # cascade at revoke time. Log + continue so the user's primary # action doesn't fail on a cosmetic UX enhancement. logger.error( "cancel-next-month sibling cascade discovery failed for seat " "%s: %s", alloc.id, cascade_step.reason(), ) await db.flush() audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action=( "software_seat.cancel_next_month_self" if is_self else "software_seat.cancel_next_month_admin" ), resource_type="software_seat_allocation", resource_id=str(alloc.id), status="success", detail={ "plan_id": str(alloc.plan_id), "target_user_id": str(alloc.user_id), "cancel_at_period_end": boundary.isoformat(), "reason": alloc.cancel_reason, "cascade_sibling_ids": [str(s.id) for s in cascade_siblings], }, ) await db.commit() return success_response( data={ "id": str(alloc.id), "status": alloc.status, "cancel_at_period_end": boundary.isoformat(), "cancel_requested_at": now.isoformat(), "cancel_requested_by": str(current_user.id), "cancel_reason": alloc.cancel_reason, "cascade_sibling_allocation_ids": [ str(s.id) for s in cascade_siblings ], }, request=request, ) @router.post("/seat-allocations/{allocation_id}/cancel-undo") async def undo_pending_seat_cancel( allocation_id: UUID, request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Undo a pending "cancel next month" — keep the seat. Authorisation matches the request endpoint: seat holder OR admin. No-op (200) if there was nothing pending — undoing a non-existent cancellation is a desirable idempotent UX (refresh-and-undo). """ alloc = await db.get(SoftwareSeatAllocation, allocation_id) if alloc is None: raise AppException( code="NOT_FOUND", message="Allocation not found", status_code=404, ) is_admin = can_manage_assets(current_user.role_names) is_self = alloc.user_id == current_user.id if not (is_admin or is_self): raise ForbiddenError( "You can only undo your own pending seat cancellation.", ) # Race guard: the auto-revoke cron at 00:05 UTC on the 1st flips # ``status='active' AND cancel_at_period_end<=NOW()`` rows to # ``revoked`` but does NOT clear the cancel fields. Without this # check, a user clicking Undo at 00:07 UTC would silently clear # the cancel fields and get a 200 response while the seat stays # revoked. Surface the truth instead. if alloc.status != "active": raise AppException( code="SEAT_NOT_ACTIVE", message=( "Seat is no longer active — the scheduled cancellation " "has already been applied. Submit a new seat request " "if you still need access." ), status_code=409, ) had_pending = alloc.cancel_at_period_end is not None alloc.cancel_at_period_end = None alloc.cancel_requested_at = None alloc.cancel_requested_by = None alloc.cancel_reason = None # Symmetric cascade-undo: when the original cancel-next-month cascaded # ``cancel_at_period_end`` across sibling allocations (Atlassian / # Jira's product-access vendors), the undo must clear that stamp on # every sibling too. Otherwise the user clicks Undo, the source row # is preserved, and the 3 add-on siblings still vanish on the 1st # — same UX desync the cascade stamp itself was designed to avoid. cascade_undo_count = 0 # SAVEPOINT-isolate — same rationale as the cascade discovery above. async with best_effort( db, label=f"cancel-undo sibling cascade for seat {alloc.id}", log=logger, ) as undo_step: from app.core.coordinators.provisioning_coordinator import ( _driver_supports_product_access_revoke, ) from app.domains.provisioning.models import ( SoftwareConnector as _SCRow, ) plan_row = await db.get(SoftwareLicensePlan, alloc.plan_id) if plan_row is not None: cu_connector = await db.scalar( select(_SCRow).where( _SCRow.software_id == plan_row.software_id, _SCRow.is_active.is_(True), ), ) if ( cu_connector is not None and await _driver_supports_product_access_revoke(db, cu_connector) ): product_key = str( (cu_connector.binding_config or {}).get("product_key") or "", ).strip().lower() integration_id = cu_connector.integration_id if product_key: from sqlalchemy import func as _func cascade_rows = list((await db.execute( select(SoftwareSeatAllocation) .join( SoftwareLicensePlan, SoftwareLicensePlan.id == SoftwareSeatAllocation.plan_id, ) .join( AssetSoftwareCatalog, AssetSoftwareCatalog.id == SoftwareLicensePlan.software_id, ) .join( _SCRow, _SCRow.software_id == AssetSoftwareCatalog.id, ) .where( SoftwareSeatAllocation.user_id == alloc.user_id, SoftwareSeatAllocation.status == "active", SoftwareSeatAllocation.id != alloc.id, SoftwareSeatAllocation.cancel_at_period_end.is_not(None), _SCRow.integration_id == integration_id, _SCRow.is_active.is_(True), _func.json_unquote( _func.json_extract( _SCRow.binding_config, "$.product_key", ), ) == product_key, ) )).scalars().all()) for sib in cascade_rows: sib.cancel_at_period_end = None sib.cancel_requested_at = None sib.cancel_requested_by = None sib.cancel_reason = None cascade_undo_count += 1 if not undo_step.ok: logger.error( "cancel-undo sibling cascade-clear failed for seat %s: %s", alloc.id, undo_step.reason(), ) await db.flush() if had_pending: audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action=( "software_seat.cancel_undo_self" if is_self else "software_seat.cancel_undo_admin" ), resource_type="software_seat_allocation", resource_id=str(alloc.id), status="success", detail={ "plan_id": str(alloc.plan_id), "target_user_id": str(alloc.user_id), "cascade_siblings_cleared": cascade_undo_count, }, ) await db.commit() return success_response( data={"id": str(alloc.id), "status": alloc.status}, request=request, ) _DEFAULT_IDLE_KEEP_DAYS = 180 _SETTING_IDLE_KEEP_DAYS = "software_seat.idle_reclaim.keep_days" async def _resolve_idle_keep_days(db: AsyncSession) -> int: """Keep-window length (days) for the idle-seat 'keep my seat' action. Reads the ``software_seat.idle_reclaim.keep_days`` platform setting (seeded by migration 204), falling back to the hard default. Clamped to a sane floor so a fat-fingered ``0`` can't make a keep a no-op. """ from app.integrations.models import PlatformSetting row = await db.get(PlatformSetting, _SETTING_IDLE_KEEP_DAYS) value = row.value if row is not None else None if isinstance(value, bool) or value is None: days = _DEFAULT_IDLE_KEEP_DAYS elif isinstance(value, (int, float)): days = int(value) elif isinstance(value, str): try: days = int(value.strip()) except ValueError: days = _DEFAULT_IDLE_KEEP_DAYS else: days = _DEFAULT_IDLE_KEEP_DAYS return max(1, days) @router.post("/seat-allocations/{allocation_id}/keep") async def keep_idle_seat( allocation_id: UUID, request: Request, body: SeatKeepRequest | None = None, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Self-service: keep an idle-warned seat so it isn't reclaimed. The ``software_seat.idle_reclaim`` job warns a holder when a paid Atlassian seat goes unused past the threshold, then reclaims it after a grace buffer. Clicking "keep" here sets ``idle_kept_until`` to now + the configured keep window so the job skips the seat, and clears ``idle_warned_at`` so that when the keep window later expires the holder gets a fresh warning + a full buffer (rather than an instant reclaim). Authorisation mirrors the cancel-next-month path: the seat holder may keep their own seat; asset/global admin may keep anyone's. Idempotent — re-submitting just extends the keep window. Only active seats can be kept (a revoked seat must be re-requested). """ alloc = await db.get(SoftwareSeatAllocation, allocation_id) if alloc is None: raise AppException( code="NOT_FOUND", message="Allocation not found", status_code=404, ) is_admin = can_manage_assets(current_user.role_names) is_self = alloc.user_id == current_user.id if not (is_admin or is_self): raise ForbiddenError( "You can only keep your own seat allocation.", ) if alloc.status != "active": raise AppException( code="SEAT_NOT_ACTIVE", message=( "Seat is not active; only an active seat can be kept. " "If it was already reclaimed, submit a new seat request." ), status_code=400, ) from datetime import timedelta as _td keep_days = await _resolve_idle_keep_days(db) now = _dt.now(UTC) kept_until = now + _td(days=keep_days) alloc.idle_kept_until = kept_until # Clear the warning so the next idle period (after the keep window) # starts fresh with a full buffer instead of reclaiming instantly. alloc.idle_warned_at = None alloc.idle_manual_review_at = None await db.flush() audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_user_id=current_user.id, actor_type="user", action=( "software_seat.idle_keep_self" if is_self else "software_seat.idle_keep_admin" ), resource_type="software_seat_allocation", resource_id=str(alloc.id), status="success", detail={ "plan_id": str(alloc.plan_id), "target_user_id": str(alloc.user_id), "idle_kept_until": kept_until.isoformat(), "keep_days": keep_days, "reason": (body.reason if body else None), }, ) await db.commit() return success_response( data={ "id": str(alloc.id), "status": alloc.status, "idle_kept_until": kept_until.isoformat(), "keep_days": keep_days, }, request=request, ) @router.post("/seat-allocations/{allocation_id}/change-plan") async def admin_change_seat_plan( allocation_id: UUID, body: AdminSeatChangePlanRequest, request: Request, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Admin changes a holder's seat tier without going through a revoke + employee re-request loop. Atomically: 1. Revokes the existing seat (status='revoked', enqueues a vendor-side revoke task with ``allow_demote=True`` so the driver knows this is an audit-traceable tier change rather than a silent downgrade). 2. Creates a new ``status='active'`` seat on ``new_plan_id`` and enqueues the vendor-side grant. Both seats reference each other via ``context_data`` so the finance / compliance audit can reconstruct "Jane's Zoom seat was changed from Licensed to Basic on 2026-05-05 by sd@" with a single trace_id. Constraints: * ``new_plan_id`` MUST belong to the same software as the current seat — preventing a Zoom→Slack swap masquerading as a tier change. Cross-software moves go through revoke + new grant, which the existing endpoints already cover. * The user MUST NOT already hold an active seat on the new plan — that's a duplicate, not a change. * Plan must be ``status='active'``. * Same-currency check on the two plans (mixed-currency would produce nonsense bills). """ from datetime import datetime as dt from app.core.coordinators.provisioning_coordinator import ( check_plan_quota, enqueue_grant_for_seat, enqueue_revoke_for_seat, ) from app.domains.provisioning.models import SOURCE_MANUAL from app.identity.person_models import IdentityPerson alloc = await db.get(SoftwareSeatAllocation, allocation_id) if alloc is None: raise AppException( code="NOT_FOUND", message="Allocation not found", status_code=404, ) if alloc.status != "active": raise AppException( code="SEAT_NOT_ACTIVE", message=( f"Seat is {alloc.status!r}; only active seats can have " "their plan changed." ), status_code=400, ) old_plan = (await db.execute( select(SoftwareLicensePlan).where( SoftwareLicensePlan.id == alloc.plan_id, ) )).scalar_one_or_none() new_plan = (await db.execute( select(SoftwareLicensePlan).where( SoftwareLicensePlan.id == body.new_plan_id, ) )).scalar_one_or_none() if old_plan is None or new_plan is None: raise AppException( code="NOT_FOUND", message="License plan not found", status_code=404, ) if old_plan.id == new_plan.id: raise AppException( code="SAME_PLAN", message="new_plan_id is identical to the current plan.", status_code=400, ) if new_plan.status != "active": raise AppException( code="PLAN_INACTIVE", message="Target license plan is not active.", status_code=400, ) if new_plan.software_id != old_plan.software_id: raise AppException( code="PLAN_DIFFERENT_SOFTWARE", message=( "Tier change must stay within the same software. " "Use the revoke + grant endpoints for cross-software " "moves so the audit trail captures it as two separate " "actions." ), status_code=400, ) # Case-insensitive comparison — `_aggregate_by_currency` and the # rest of billing_service uppercase before bucketing, so a hand- # written `UPDATE ... SET currency='cny'` would otherwise produce # a false PLAN_DIFFERENT_CURRENCY rejection while billing aggregates # everything under 'CNY'. (Round 4 logic-scan P1.) old_currency = (old_plan.currency or "CNY").upper() new_currency = (new_plan.currency or "CNY").upper() if old_currency != new_currency: raise AppException( code="PLAN_DIFFERENT_CURRENCY", message=( f"Currency mismatch: current plan is " f"{old_currency}, target is {new_currency}. " "Tier change must preserve currency." ), status_code=400, ) target = await db.get(IdentityPerson, alloc.user_id) if target is None or target.deleted_at is not None: raise AppException( code="NOT_FOUND", message="Seat holder not found", status_code=404, ) if is_blocked_from_new_grant(target): raise AppException( code="TARGET_NOT_ACTIVE", message=( f"Cannot change seat — user not eligible (employment_status=" f"{target.employment_status!r}, hris_record_state={target.hris_record_state!r}). " "Revoke the seat instead." ), status_code=400, ) # Refuse if the user already has an active seat on the new plan — # that's a duplicate, not a change. Caller should revoke the # other seat first or pick a different plan. duplicate = (await db.execute( select(SoftwareSeatAllocation).where( SoftwareSeatAllocation.plan_id == new_plan.id, SoftwareSeatAllocation.user_id == alloc.user_id, SoftwareSeatAllocation.status == "active", ) )).scalars().first() if duplicate is not None: raise AppException( code="DUPLICATE_SEAT", message=( "User already holds an active seat on the target plan. " "Revoke the duplicate or pick a different target plan." ), status_code=409, ) # Refuse change-plan for ``must_transfer`` software (Google # Workspace user_suspend, Zoom delete with hosted recordings, # …). The vendor-side revoke for these moves data ownership; # without an explicit successor the user's Drive / meetings / # webinars would be orphaned. Tier change must go through the # explicit revoke + grant flow which captures a successor. # (Pre-launch round 2 security P1.) software_row = await db.get(AssetSoftwareCatalog, new_plan.software_id) if software_row is not None and software_row.offboarding_policy == "must_transfer": raise AppException( code="CHANGE_PLAN_REQUIRES_TRANSFER", message=( "This software's offboarding policy is 'must_transfer' — " "vendor-side data (Drive files, recordings) would be " "orphaned by a tier change. Use the revoke endpoint " "with a successor, then grant the new tier." ), status_code=400, ) # Refuse change-plan when the vendor uses product-access semantics # (Atlassian / Jira today). The revoke step strips the user from # EVERY conferring group for the product (jira-dev-engineers, RD # Leader, …) — by design, so the seat is truly freed — but the # paired grant step on the new plan only adds them back to ONE # bound ``group_name``. The user would lose their team / role # group memberships and end up with a smaller footprint than they # started with. Atlassian's group model isn't tiered the way # Zoom's is anyway (there is no "Premium" vs "Basic" Jira # Software per-user license at the site), so change_plan for # these vendors is conceptually undefined. Caller can still revoke # + grant separately if they really want to re-shape the binding. # (PR addressing shengbo.ma / sunxiaoguang revoke loop, 2026-05-28.) from app.core.coordinators.provisioning_coordinator import ( _driver_supports_product_access_revoke, ) from app.domains.provisioning.models import ( SoftwareConnector as _SCRow, ) cp_connector = await db.scalar( select(_SCRow).where( _SCRow.software_id == new_plan.software_id, _SCRow.is_active.is_(True), ), ) if ( cp_connector is not None and await _driver_supports_product_access_revoke(db, cp_connector) ): raise AppException( code="CHANGE_PLAN_NOT_SUPPORTED_FOR_PRODUCT_ACCESS_VENDOR", message=( "This software's provider revokes by stripping every group " "that confers the product (Atlassian / Jira). A tier " "change would remove the user from team / role groups in " "the revoke step and only re-add them to the single bound " "group in the grant step, shrinking their actual access. " "There is no per-user 'tier' for this vendor — explicit " "revoke + new grant via the dedicated endpoints if the " "binding really needs to change." ), status_code=400, ) # Quota — only enforced when policy is reject; evict_lru bypasses. await check_plan_quota(db, new_plan.id) trace_id = f"admin_change_plan:{alloc.id}" now = dt.now(UTC) revoke_task_id: UUID | None = None grant_task_id: UUID | None = None # Phase 1: enqueue the revoke task. ``allow_demote=True`` is # plumbed via the seat's context so the worker (and the driver, # via ProvisioningContext.extra) knows this is a deliberate # tier change rather than a silent vendor downgrade. The Zoom # driver's ``_reconcile_existing_user`` now respects this flag. try: revoke_task_id = await enqueue_revoke_for_seat( db, seat=alloc, trace_id=trace_id, actor_user_id=current_user.id, source=SOURCE_MANUAL, enforce_offboarding_policy=False, ) except Exception as exc: # noqa: BLE001 logger.warning( "change-plan revoke enqueue failed for %s: %s", alloc.id, exc, ) raise AppException( code="CHANGE_PLAN_REVOKE_ENQUEUE_FAILED", message=( "Could not enqueue the vendor-side revoke for the old " "tier. Tier change aborted; original seat unchanged." ), status_code=503, ) from exc # Phase 2: flip old seat to revoked locally + create new seat. alloc.status = "revoked" alloc.revoked_at = now alloc.reason = ( (alloc.reason or "") + f"\n[admin tier change → {new_plan.name}: {body.reason}]" ).strip() new_alloc = SoftwareSeatAllocation( plan_id=new_plan.id, user_id=alloc.user_id, status="active", allocated_at=now, reason=( f"admin tier change from {old_plan.name} → {new_plan.name} " f"by {current_user.email}: {body.reason}" ), ) db.add(new_alloc) await db.flush() # Phase 3: enqueue the grant task with ``allow_demote=True`` so # the Zoom driver permits the licensed→basic PATCH path that # is normally blocked. Other drivers ignore the flag. # Phase 3: enqueue grant task for the new tier. ATOMICITY: a # failure here MUST roll back Phase 2 (the local revoke flip + # the new seat row + the queued revoke task). Without rollback, # the admin sees "200 success" while the user has actually lost # service: vendor revoke runs from the queued task, no vendor # grant ever lands. (Pre-launch round 2 logic-scan P0.) try: grant_task_id = await enqueue_grant_for_seat( db, seat=new_alloc, trace_id=trace_id, actor_user_id=current_user.id, ) # Stamp the grant task's context_data with the demote flag # and tier-change metadata. enqueue_grant_for_seat only # writes plan_name/plan_id; we extend it here so the worker # (and driver) can see the explicit "this is a tier change" # signal without changing the coordinator signature. if grant_task_id is not None: from app.domains.provisioning.models import ProvisioningTask grant_task = await db.get(ProvisioningTask, grant_task_id) if grant_task is not None: ctx = dict(grant_task.context_data or {}) ctx["allow_demote"] = True ctx["tier_change_from_plan_id"] = str(old_plan.id) ctx["tier_change_from_seat_id"] = str(alloc.id) ctx["tier_change_reason"] = body.reason[:500] grant_task.context_data = ctx except Exception as exc: # noqa: BLE001 # Rollback the in-flight tier change. Nothing has been # committed yet; ROLLBACK undoes Phase 2's status flip + new # seat row + the queued revoke task all at once. await db.rollback() logger.warning( "change-plan grant enqueue failed for %s → %s: %s — rolled back", alloc.id, new_alloc.id, exc, ) raise AppException( code="CHANGE_PLAN_GRANT_ENQUEUE_FAILED", message=( "Could not enqueue the vendor-side grant for the new " "tier; the tier change was rolled back so the user " "still has their original seat. Try again or escalate " "to the platform team." ), status_code=503, ) from exc audit = AuditService(db) await audit.log( # Use the per-transition trace_id (admin_change_plan:{id}) so # an audit query for "everything that happened in this # transition" returns BOTH the audit row + the revoke + grant # provisioning tasks under one key. The request-scoped # request.state.trace_id is per-HTTP-request, which fragments # the audit story when the worker subsequently picks up the # tasks. (Pre-launch round 2 logic-scan P1.) trace_id=trace_id, actor_user_id=current_user.id, actor_type="user", action="software_seat.plan_changed", resource_type="software_seat_allocation", resource_id=str(new_alloc.id), status="success", detail={ "target_user_id": str(target.id), "target_email": target.work_email, "from_plan_id": str(old_plan.id), "from_plan_name": old_plan.name, "from_price": str(old_plan.price), "to_plan_id": str(new_plan.id), "to_plan_name": new_plan.name, "to_price": str(new_plan.price), "currency": old_currency, "from_seat_id": str(alloc.id), "to_seat_id": str(new_alloc.id), "revoke_task_id": str(revoke_task_id) if revoke_task_id else None, "grant_task_id": str(grant_task_id) if grant_task_id else None, "reason": body.reason[:500], }, ip_address=get_real_ip(request), ) await db.commit() return success_response( data={ "from_seat_id": str(alloc.id), "to_seat_id": str(new_alloc.id), "from_plan": { "id": str(old_plan.id), "name": old_plan.name, "price": str(old_plan.price), }, "to_plan": { "id": str(new_plan.id), "name": new_plan.name, "price": str(new_plan.price), }, "revoke_task_id": str(revoke_task_id) if revoke_task_id else None, "grant_task_id": str(grant_task_id) if grant_task_id else None, }, request=request, ) async def _query_seat_allocations( db: AsyncSession, *, page: int, page_size: int, software_id: UUID | None = None, user_id: UUID | None = None, department: str | None = None, status: str | None = None, billing_cycle: str | None = None, plan_id: UUID | None = None, keyword: str | None = None, start_date: str | None = None, end_date: str | None = None, has_pending_cancel: bool | None = None, ) -> tuple[list[SoftwareSeatAllocation], int]: """Shared filter/count/order/paginate query for seat allocations. Single source of truth for both ``GET /seat-allocations`` and its ``/export.xlsx`` sibling — the two surfaces cannot drift on WHERE logic. Callers own RBAC and serialization. """ query = ( select(SoftwareSeatAllocation) .join(SoftwareLicensePlan, SoftwareSeatAllocation.plan_id == SoftwareLicensePlan.id) ) # Tri-state: explicit ``true`` filters to scheduled-cancellation # rows; ``false`` filters to NO pending cancel; ``None`` (default) # leaves the result unfiltered. Using ``is True`` (not truthy # check) so a future caller passing ``?has_pending_cancel=false`` # actually narrows instead of being silently treated as None. if has_pending_cancel is True: query = query.where(SoftwareSeatAllocation.cancel_at_period_end.is_not(None)) elif has_pending_cancel is False: query = query.where(SoftwareSeatAllocation.cancel_at_period_end.is_(None)) if software_id: query = query.where(SoftwareLicensePlan.software_id == software_id) if user_id: query = query.where(SoftwareSeatAllocation.user_id == user_id) if status: query = query.where(SoftwareSeatAllocation.status == status) if billing_cycle: query = query.where(SoftwareLicensePlan.billing_cycle == billing_cycle) if plan_id: query = query.where(SoftwareSeatAllocation.plan_id == plan_id) if start_date: try: start_dt_val = _dt.combine(_date_cls.fromisoformat(start_date), _dt.min.time()) except ValueError as exc: raise ValidationError( f"start_date must be ISO format (YYYY-MM-DD), got {start_date!r}", ) from exc query = query.where(SoftwareSeatAllocation.allocated_at >= start_dt_val) if end_date: try: end_dt_val = _dt.combine(_date_cls.fromisoformat(end_date), _dt.max.time()) except ValueError as exc: raise ValidationError( f"end_date must be ISO format (YYYY-MM-DD), got {end_date!r}", ) from exc query = query.where(SoftwareSeatAllocation.allocated_at <= end_dt_val) if department or keyword: from app.identity.person_models import IdentityOrgMembership, IdentityPerson query = query.join(IdentityPerson, SoftwareSeatAllocation.user_id == IdentityPerson.id) if department: query = ( query.join(IdentityOrgMembership, IdentityPerson.id == IdentityOrgMembership.person_id) .where(IdentityOrgMembership.org_node_name == department) .where(IdentityOrgMembership.is_primary.is_(True)) .where(IdentityOrgMembership.deleted_at.is_(None)) ) if keyword: pattern = f"%{keyword}%" query = query.where( or_( SoftwareLicensePlan.name.ilike(pattern), IdentityPerson.display_name.ilike(pattern), IdentityPerson.work_email.ilike(pattern), SoftwareSeatAllocation.reason.ilike(pattern), ) ) from sqlalchemy import func as sa_func count_q = select(sa_func.count()).select_from(query.subquery()) total = (await db.execute(count_q)).scalar() or 0 # When filtering to scheduled cancellations, order by the cancel # boundary ASC so the next-to-fire rows surface first — that is the # natural read order for the Software Cancellations overview ("what # gets auto-revoked next?"). Tri-state aware: only the explicit # ``true`` path re-orders; ``false`` / ``None`` keep the default # ``allocated_at DESC``. if has_pending_cancel is True: query = query.order_by(SoftwareSeatAllocation.cancel_at_period_end.asc()) else: query = query.order_by(SoftwareSeatAllocation.allocated_at.desc()) rows = (await db.execute(query.offset((page - 1) * page_size).limit(page_size))).scalars().all() return list(rows), total @router.get("/seat-allocations") async def list_all_seats( request: Request, page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), software_id: UUID | None = None, user_id: UUID | None = None, department: str | None = None, status: str | None = None, billing_cycle: str | None = None, plan_id: UUID | None = None, keyword: str | None = None, start_date: str | None = None, end_date: str | None = None, has_pending_cancel: bool | None = Query( None, description=( "When true, restrict to seats where " "`cancel_at_period_end IS NOT NULL` (scheduled to auto-revoke " "at the next period boundary). Powers the Software " "Cancellations admin overview." ), ), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List seat allocations. Admin (``can_read_all_assets``) sees every allocation. Employees may only request their own slice (``user_id=``). The IT 宇宙 page on /me hits this endpoint with the caller's id; no other employee flow uses it. """ if not can_read_all_assets(current_user.role_names): if user_id is None or user_id != current_user.id: raise ForbiddenError( "You may only view your own seat allocations.", ) rows, total = await _query_seat_allocations( db, page=page, page_size=page_size, software_id=software_id, user_id=user_id, department=department, status=status, billing_cycle=billing_cycle, plan_id=plan_id, keyword=keyword, start_date=start_date, end_date=end_date, has_pending_cancel=has_pending_cancel, ) enriched = await _enrich_seat_allocations(db, rows) return paginated_response(data=enriched, total=total, page=page, page_size=page_size, request=request) @router.get("/seat-allocations/export.xlsx") async def export_seat_allocations( request: Request, software_id: UUID | None = None, user_id: UUID | None = None, department: str | None = None, status: str | None = None, billing_cycle: str | None = None, plan_id: UUID | None = None, keyword: str | None = None, start_date: str | None = None, end_date: str | None = None, has_pending_cancel: bool | None = Query(None), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Export the filtered seat-allocation list as XLSX. Filter signature and RBAC mirror :func:`list_all_seats` exactly — admins export any slice, employees only their own (``user_id=``). Reuses :func:`_query_seat_allocations` + ``_enrich_seat_allocations`` so the file matches what the list endpoint serves. Capped at :data:`app.core.exporting.EXPORT_ROW_LIMIT` rows. """ from app.core.exporting import EXPORT_ROW_LIMIT, xlsx_export_response if not can_read_all_assets(current_user.role_names): if user_id is None or user_id != current_user.id: raise ForbiddenError( "You may only export your own seat allocations.", ) rows, total = await _query_seat_allocations( db, page=1, page_size=EXPORT_ROW_LIMIT, software_id=software_id, user_id=user_id, department=department, status=status, billing_cycle=billing_cycle, plan_id=plan_id, keyword=keyword, start_date=start_date, end_date=end_date, has_pending_cancel=has_pending_cancel, ) enriched = await _enrich_seat_allocations(db, rows) columns = [ ("software_name", "软件"), ("plan_name", "计划"), ("user_name", "用户"), ("user_email", "邮箱"), ("department", "部门"), ("status", "状态"), ("billing_cycle", "计费周期"), ("price", "周期单价"), ("currency", "币种"), ("allocated_at", "分配时间"), ("revoked_at", "撤销时间"), ("cancel_at_period_end", "周期末取消"), ("reason", "原因"), ("id", "分配ID"), ] return await xlsx_export_response( db, request, current_user, columns=columns, rows=enriched, total_matched=total, filename_stem="software_seat_allocations", sheet_title="Seat Allocations", audit_action="asset.seat_allocations.export", audit_resource_type="software_seat_allocation", filters={ "software_id": software_id, "user_id": user_id, "department": department, "status": status, "billing_cycle": billing_cycle, "plan_id": plan_id, "keyword": keyword, "start_date": start_date, "end_date": end_date, "has_pending_cancel": has_pending_cancel, }, ) # --------------------------------------------------------------------------- # Cost statistics # --------------------------------------------------------------------------- @router.get("/cost-stats/my") async def my_cost_stats( request: Request, start_date: str | None = None, end_date: str | None = None, hide_free: bool = True, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Employee views own software seat cost summary.""" return success_response( data=await _compute_cost_stats(db, user_id=current_user.id, start_date=start_date, end_date=end_date, hide_free=hide_free), request=request, ) @router.get("/cost-stats/subordinates") async def subordinates_cost_stats( request: Request, start_date: str | None = None, end_date: str | None = None, hide_free: bool = True, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Manager view — caller + every direct/transitive report. Requires an explicit manager relationship or admin role. Without this guard any employee whose manager_person_id happened to be set (even stale HRIS data) could see a subordinate's cost data without admin intent. """ is_admin = can_read_all_assets(current_user.role_names) if not is_admin and not current_user.is_manager: # Non-manager, non-admin: fall back to own data only. data = await _compute_cost_stats( db, user_id=current_user.id, start_date=start_date, end_date=end_date, hide_free=hide_free, ) return success_response( data=data, request=request, meta={"user_count": 1}, ) visited = await _resolve_subordinate_ids(db, current_user.id) visited.add(current_user.id) data = await _compute_cost_stats( db, user_ids=list(visited), start_date=start_date, end_date=end_date, hide_free=hide_free, ) return success_response( data=data, request=request, meta={"user_count": len(visited)}, ) @router.get("/cost-stats/department") async def department_cost_stats( request: Request, department: str | None = None, start_date: str | None = None, end_date: str | None = None, hide_free: bool = True, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Manager/admin views department cost summary. Scope enforcement: non-admin callers can only query their own department chain (e.g. someone in "IT Services & Security" can ask for either their leaf dept or any ancestor like "Business Operations" — both subsume their scope). Asking for an unrelated department is 403. Admins bypass the check and can see any dept. Before 2026-04-22 this endpoint accepted any ``?department=`` from any logged-in user, which the new subtree rollup made easier to abuse. """ dept = department or current_user.department if not dept: raise AppException(code="NO_DEPARTMENT", message="Department not specified", status_code=400) if not can_manage_assets(current_user.role_names): # The caller's own ancestor chain is the only thing they're # entitled to see. ``department_chain`` is tenant-stripped and # leaf-last so a direct membership check covers every level. own_chain = set(current_user.department_chain or []) if current_user.department: own_chain.add(current_user.department) if dept not in own_chain: raise AppException( code="FORBIDDEN", message="You can only view cost stats for your own department chain.", status_code=403, ) return success_response( data=await _compute_cost_stats(db, department=dept, start_date=start_date, end_date=end_date, hide_free=hide_free), request=request, ) @router.get("/cost-stats/all") async def all_cost_stats( request: Request, start_date: str | None = None, end_date: str | None = None, software_id: UUID | None = None, bu: str | None = Query(default=None, max_length=128), hide_free: bool = True, current_user: AuthUser = Depends(require_software_read()), db: AsyncSession = Depends(get_db), ): """Admin views company-wide cost summary. ``bu`` filters to a single Business Unit (top-level org node like "Engineering Group" / "Finance"). Empty string is treated as no filter; unknown BU returns empty results rather than 404 so the UI can keep the dropdown stable across syncs. """ return success_response( data=await _compute_cost_stats( db, software_id=software_id, start_date=start_date, end_date=end_date, bu=bu or None, hide_free=hide_free, ), request=request, ) @router.get("/cost-stats/departments") async def list_cost_stat_departments( request: Request, current_user: AuthUser = Depends(require_software_read()), db: AsyncSession = Depends(get_db), ): """Department tree for the seat/cost by-department picker. Returns every ``department`` org node (source_id + name + parent + path + level, leaf-aware via ``org_path``) so the admin / finance cost view can filter dynamically by ANY department subtree, not just a top-level BU (the previous filter only offered top-level org nodes). The ``name`` is what ``/cost-stats/department?department=`` expects — the subtree rollup there matches on it. Gated on ``require_software_read`` (global_admin / asset_admin / finance / auditor / management) — the same audience as the cost endpoints. This is intentionally a superset of the inventory department lookup's ``require_asset_read`` because finance can view software cost but has no hardware-inventory access. """ from sqlalchemy import select as sa_select from app.identity.person_models import IdentityOrgNode rows = (await db.execute( sa_select(IdentityOrgNode).where( IdentityOrgNode.org_node_type == "department", IdentityOrgNode.deleted_at.is_(None), ).order_by(IdentityOrgNode.org_path) )).scalars().all() return success_response( data=[ { "source_id": r.org_node_source_id, "name": r.org_node_name, "parent_source_id": r.parent_org_node_source_id, "org_path": r.org_path, "level": r.org_level, } for r in rows ], request=request, ) # =========================================================================== # Hardware financial lifecycle — depreciation / book value / disposal P&L. # Closes the finance-persona parity gap (finance saw software/cloud/AI/ # procurement cost but had ZERO hardware visibility). Read gates mirror the # software cost endpoints; depreciation-policy writes are asset-admin (the # policy sits next to category config). All routes registered before the # ``/{asset_id}`` catch-alls. # =========================================================================== @router.get("/financial-stats/my") async def my_financial_stats( request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Employee views the net book value of the hardware they hold.""" from app.domains.asset import financial_service data = await financial_service.compute_financial_stats( db, scope="mine", person_ids=[current_user.id], ) return success_response( data=FinancialStatsResponse.model_validate(data).model_dump(mode="json"), request=request, ) @router.get("/financial-stats/subordinates") async def subordinates_financial_stats( request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Manager view — caller + every direct/transitive report's hardware book value. Requires a manager relationship or admin role.""" from app.domains.asset import financial_service is_admin = can_read_all_assets(current_user.role_names) if not is_admin and not current_user.is_manager: data = await financial_service.compute_financial_stats( db, scope="mine", person_ids=[current_user.id], ) return success_response( data=FinancialStatsResponse.model_validate(data).model_dump(mode="json"), request=request, meta={"user_count": 1}, ) visited = await _resolve_subordinate_ids(db, current_user.id) visited.add(current_user.id) data = await financial_service.compute_financial_stats( db, scope="subordinates", person_ids=list(visited), ) return success_response( data=FinancialStatsResponse.model_validate(data).model_dump(mode="json"), request=request, meta={"user_count": len(visited)}, ) @router.get("/financial-stats/department") async def department_financial_stats( request: Request, department: str | None = None, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Manager/admin views a department subtree's hardware book value. Same scope rules as ``/cost-stats/department``: non-admin callers can only query their own department ancestor chain. """ from app.domains.asset import financial_service dept = department or current_user.department if not dept: raise AppException(code="NO_DEPARTMENT", message="Department not specified", status_code=400) if not can_manage_assets(current_user.role_names) and not can_read_all_assets(current_user.role_names): own_chain = set(current_user.department_chain or []) if current_user.department: own_chain.add(current_user.department) if dept not in own_chain: raise AppException( code="FORBIDDEN", message="You can only view financial stats for your own department chain.", status_code=403, ) data = await financial_service.compute_financial_stats( db, scope="department", department=dept, ) return success_response( data=FinancialStatsResponse.model_validate(data).model_dump(mode="json"), request=request, ) @router.get("/financial-stats/all") async def all_financial_stats( request: Request, current_user: AuthUser = Depends(require_software_read()), db: AsyncSession = Depends(get_db), ): """Finance/admin views the company-wide hardware financial summary: net book value by category / department / office, plus disposal P&L.""" from app.domains.asset import financial_service data = await financial_service.compute_financial_stats(db, scope="all") return success_response( data=FinancialStatsResponse.model_validate(data).model_dump(mode="json"), request=request, ) @router.get("/financial-stats/book-value-trend") async def book_value_trend( request: Request, months: int = Query(default=24, ge=1, le=60), current_user: AuthUser = Depends(require_software_read()), db: AsyncSession = Depends(get_db), ): """Monthly fleet net-book-value trend (CNY-normalised) — materialised history + the current month computed live.""" from app.domains.asset import financial_service data = await financial_service.book_value_trend(db, months=months) return success_response( data=BookValueTrendResponse.model_validate(data).model_dump(mode="json"), request=request, ) @router.get("/depreciation-policies") async def list_depreciation_policies( request: Request, current_user: AuthUser = Depends(require_software_read()), db: AsyncSession = Depends(get_db), ): """List depreciation policies (per category + the NULL default).""" from app.domains.asset import financial_service rows = await financial_service.list_policies(db) return success_response( data=[DepreciationPolicyRead.model_validate(r).model_dump(mode="json") for r in rows], request=request, ) @router.put("/depreciation-policies") async def upsert_depreciation_policy( request: Request, payload: DepreciationPolicyUpsert, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Create/update a category's depreciation policy (NULL = default). Asset-admin scope — the policy sits next to category config. Audited. """ from app.domains.asset import financial_service policy = await financial_service.upsert_policy( db, asset_category=payload.asset_category, method=payload.method, useful_life_months=payload.useful_life_months, residual_rate=payload.residual_rate, declining_rate=payload.declining_rate, is_active=payload.is_active, notes=payload.notes, ) await AuditService(db).log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.depreciation_policy.upsert", resource_type="asset_depreciation_policy", resource_id=str(policy.id), status="success", detail={"asset_category": payload.asset_category, "method": payload.method, "useful_life_months": payload.useful_life_months, "residual_rate": str(payload.residual_rate)}, ) await db.commit() return success_response( data=DepreciationPolicyRead.model_validate(policy).model_dump(mode="json"), request=request, ) @router.delete("/depreciation-policies/{policy_id}") async def delete_depreciation_policy( request: Request, policy_id: UUID, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Delete a per-category policy (the NULL default cannot be deleted).""" from app.domains.asset import financial_service try: ok = await financial_service.delete_policy(db, policy_id) except ValueError: raise AppException( code="CANNOT_DELETE_DEFAULT", message="The platform default depreciation policy cannot be deleted; edit it instead.", status_code=400, ) if not ok: raise NotFoundError("DepreciationPolicy", str(policy_id)) await AuditService(db).log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.depreciation_policy.delete", resource_type="asset_depreciation_policy", resource_id=str(policy_id), status="success", ) await db.commit() return success_response(data={"deleted": True}, request=request) # NOTE: these two live ABOVE ``/{asset_id}`` on purpose. FastAPI # resolves routes in registration order, so a literal path declared # after the UUID catch-all never runs — it 422s with "invalid # character" on its own name. Caught by the endpoint test; the # handler itself was fine. # ══════════════════════════════════════════════════════════════════ # Seat usage signals — "when did this person last actually use it?" # (访问对象闭环 PR4, migration 612) # ══════════════════════════════════════════════════════════════════ @router.get("/seat-usage-signals/coverage") async def seat_usage_signal_coverage( request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Per-provider answer to "can we even tell whether these seats are used?" This endpoint exists because the honest answer for several vendors is "no", and that has to be visible rather than inferred from an empty table. A provider is either collectable (with a last-collection time and a row count) or declared unsupported WITH THE REASON — the same thing the code asserts, surfaced to the person deciding whether to trust an idle report. """ from sqlalchemy import func as _func from app.domains.asset.models import SeatUsageSignal from app.integrations.usage_signals import ( NO_USAGE_SIGNAL, STALE_AFTER_HOURS, resolve_signal, usage_collectors, ) if not can_read_all_assets(current_user.role_names): raise AppException( code="FORBIDDEN", message="Not allowed", status_code=403, ) now = _dt.now(UTC) rows = (await db.execute( select( SeatUsageSignal.provider, SeatUsageSignal.state, _func.count(SeatUsageSignal.id), _func.max(SeatUsageSignal.collected_at), ).group_by(SeatUsageSignal.provider, SeatUsageSignal.state) )).all() per_provider: dict[str, dict[str, Any]] = {} for provider, state, count, newest in rows: entry = per_provider.setdefault(provider, { "provider": provider, "supported": True, "reason": None, "rows": 0, "by_state": {}, "collected_at": None, }) entry["rows"] += int(count or 0) # Report the RESOLVED state, not the stored one: a page that says # "412 active" from snapshots nobody refreshed is worse than one # that says "412 unknown", because the first invites a decision. resolved = resolve_signal(state, newest, now=now) entry["by_state"][resolved] = ( entry["by_state"].get(resolved, 0) + int(count or 0) ) if newest and ( entry["collected_at"] is None or newest > entry["collected_at"] ): entry["collected_at"] = newest for provider in usage_collectors(): per_provider.setdefault(provider, { "provider": provider, "supported": True, "reason": None, "rows": 0, "by_state": {}, "collected_at": None, }) for provider, reason in NO_USAGE_SIGNAL.items(): per_provider[provider] = { "provider": provider, "supported": False, "reason": reason, "rows": 0, "by_state": {}, "collected_at": None, } out = sorted(per_provider.values(), key=lambda e: (not e["supported"], e["provider"])) for entry in out: collected = entry.get("collected_at") entry["collected_at"] = collected.isoformat() if collected else None entry["stale"] = bool( entry["supported"] and collected is not None and (now - _as_aware(collected)).total_seconds() > STALE_AFTER_HOURS * 3600 ) return success_response( {"providers": out, "stale_after_hours": STALE_AFTER_HOURS}, request=request, ) def _as_aware(value: _dt) -> _dt: return value if value.tzinfo else value.replace(tzinfo=UTC) @router.get("/seat-usage-signals") async def list_seat_usage_signals( request: Request, page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), provider: str | None = Query(None, max_length=64), state: str | None = Query( None, pattern="^(active|never|unknown|unsupported)$", ), keyword: str | None = Query(None, max_length=120), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """The per-person signal rows, with the freshness verdict applied. ``state`` is the resolved verdict and ``stored_state`` is what the row literally says; both are returned so the page can show "it said active, but that snapshot is four days old" on the ROW rather than in a footnote nobody reads. """ from app.domains.asset.models import SeatUsageSignal from app.identity.person_models import IdentityPerson from app.integrations.usage_signals import STALE_AFTER_HOURS, resolve_signal if not can_read_all_assets(current_user.role_names): raise AppException( code="FORBIDDEN", message="Not allowed", status_code=403, ) now = _dt.now(UTC) query = ( select(SeatUsageSignal, IdentityPerson) .join(IdentityPerson, IdentityPerson.id == SeatUsageSignal.person_id) ) if provider: query = query.where(SeatUsageSignal.provider == provider) if keyword: like = f"%{keyword.strip()}%" query = query.where( or_( IdentityPerson.display_name.ilike(like), IdentityPerson.work_email.ilike(like), ) ) rows = (await db.execute( # Plain ASC: MySQL/TiDB sort NULLs first natively and reject # ``NULLS FIRST`` outright (1064). Never-used and never-seen # rows are exactly the ones worth looking at first. query.order_by(SeatUsageSignal.last_active_at.asc()) )).all() items: list[dict[str, Any]] = [] for sig, person in rows: resolved = resolve_signal(sig.state, sig.collected_at, now=now) # Filtering on the RESOLVED state, after the gate — filtering on # the stored column would let a stale "active" row answer a # request for active rows. if state and resolved != state: continue last_active = sig.last_active_at items.append({ "id": str(sig.id), "person_id": str(person.id), "person_name": person.display_name, "person_email": person.work_email, "employment_status": person.employment_status, "provider": sig.provider, "product_key": sig.product_key, "state": resolved, "stored_state": sig.state, "last_active_at": last_active.isoformat() if last_active else None, "idle_days": ( (now - _as_aware(last_active)).days if last_active else None ), "collected_at": ( sig.collected_at.isoformat() if sig.collected_at else None ), "snapshot_age_hours": ( round((now - _as_aware(sig.collected_at)).total_seconds() / 3600, 1) if sig.collected_at else None ), "evidence": sig.evidence or {}, }) total = len(items) start = (page - 1) * page_size # ``stale_after_hours`` rides in the row payload rather than a meta # key: ``paginated_response`` owns ``meta`` and the page needs the # threshold to render the per-row age annotation anyway. for item in items[start:start + page_size]: item["stale_after_hours"] = STALE_AFTER_HOURS return paginated_response( items[start:start + page_size], total=total, page=page, page_size=page_size, request=request, ) @router.get("/{asset_id}/financial") async def asset_financial_profile( request: Request, asset_id: UUID, # require_software_read (not require_asset_read) so the finance role — # which now reads the fleet aggregate (/financial-stats) and the MCP # twin get_asset_financial_profile (can_read_all_assets) — can also # drill into a single asset's financial timeline. Keeps the HTTP + # MCP gates aligned (review fix, 2026-06-13). current_user: AuthUser = Depends(require_software_read()), db: AsyncSession = Depends(get_db), ): """Per-asset financial timeline: acquisition → depreciation schedule → repair investment → disposal P&L → total cost of ownership.""" from app.domains.asset import financial_service data = await financial_service.get_asset_financial_profile(db, asset_id) if data is None: raise NotFoundError("Asset", str(asset_id)) return success_response( data=AssetFinancialProfile.model_validate(data).model_dump(mode="json"), request=request, ) @router.get("/seat-reconciliation") async def seat_reconciliation_overview( request: Request, current_user: AuthUser = Depends(require_software_read()), db: AsyncSession = Depends(get_db), ): """Company-wide per-software seat reconciliation. One row per integration-backed software: vendor license holders vs employee-occupied seats vs external (unmatched) holders vs scheduled-cancel, plus last-sync time. Feeds the 运营洞察「软件」 对账 table; each row drills into ``/seat-reconciliation/{software_id}``. """ return success_response( data=await _compute_seat_reconciliation_overview(db), request=request, ) @router.get("/seat-reconciliation/{software_id}") async def seat_reconciliation_detail( request: Request, software_id: UUID, page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=200), include_resolved: bool = Query(default=False), category: str | None = Query( default=None, description=( "Filter the holder list: 'anomalies' (the 全部异常 tab — the four " "anomaly categories minus known/expected accounts), a single " "category ('external' / 'leaver_residual' / 'vendor_inactive' / " "'off_paid_site' / 'no_email'), or omit for every row." ), ), current_user: AuthUser = Depends(require_software_read()), db: AsyncSession = Depends(get_db), ): """Per-software reconciliation detail for the drawer: headline counts, per-plan snapshot, paginated external-holder list (with vendor admin deep-link), and the live scheduled-cancel ("下个月不占") seat list. ``include_resolved=true`` also returns external holders that have since fallen off the vendor side or matched an employee (history). ``category`` server-filters the holder list so it matches the tab and its badge count (see ``_compute_seat_reconciliation_detail``).""" return success_response( data=await _compute_seat_reconciliation_detail( db, software_id, page=page, page_size=page_size, include_resolved=include_resolved, category=category, ), request=request, ) # ── Cross-vendor external-account registry (「外部 / 共用账号」) ── # One annotation per email, consumed by every vendor's seat # reconciliation. Generalises the Google orphan-account menu; see # app/domains/asset/external_account_service.py. @router.get("/external-accounts") async def external_accounts_directory( request: Request, classification: str | None = Query(default=None), provider: str | None = Query(default=None), search: str | None = Query(default=None), page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=200), current_user: AuthUser = Depends(require_admin_read()), db: AsyncSession = Depends(get_db), ): """Unified directory of every external account across all vendors. One row per distinct external identity (union of every vendor's ``software_external_holder`` external + no-email rows + the Google orphan inventory), showing which vendors it appears in plus its cross-vendor classification / note / owner. Accounts with no email (e.g. hand-created Feishu consultant accounts) are keyed by their vendor holder_key (``feishu-user:``). """ from app.domains.asset.external_account_service import ( ExternalAccountRegistryService, ) svc = ExternalAccountRegistryService(db) return success_response( data=await svc.list_directory( classification=classification, provider=provider, search=search, page=page, page_size=page_size, ), request=request, ) @router.get("/external-accounts/export.xlsx") async def external_accounts_export( request: Request, classification: str | None = Query(default=None), provider: str | None = Query(default=None), search: str | None = Query(default=None), current_user: AuthUser = Depends(require_admin_read()), db: AsyncSession = Depends(get_db), ): """Export the filtered external-account directory as XLSX. Filter signature + RBAC mirror :func:`external_accounts_directory` exactly and reuse ``list_directory`` (paged to :data:`app.core.exporting.EXPORT_ROW_LIMIT`) so the file matches what the page shows, row-for-row. """ from app.core.exporting import EXPORT_ROW_LIMIT, xlsx_export_response from app.domains.asset.external_account_service import ( ExternalAccountRegistryService, ) svc = ExternalAccountRegistryService(db) result = await svc.list_directory( classification=classification, provider=provider, search=search, page=1, page_size=EXPORT_ROW_LIMIT, ) columns = [ ("account", "账号"), ("display_name", "名称"), ("no_email", "无邮箱"), ("vendor_account_id", "厂商账号ID"), ("providers", "出现厂商"), ("classification", "分类"), ("is_known", "已知/预期"), ("note", "备注"), ("owner_name", "负责人"), ("google_purpose", "Google用途"), ("last_seen_at", "最近出现"), ] rows = [ {**i, "account": i["email"] or i["key"]} for i in result["items"] ] return await xlsx_export_response( db, request, current_user, columns=columns, rows=rows, total_matched=result["total"], filename_stem="external_accounts", sheet_title="External Accounts", audit_action="external_account.export", audit_resource_type="external_account_registry", filters={ "classification": classification, "provider": provider, "search": search, }, ) @router.post("/external-accounts/classify") async def external_accounts_classify( request: Request, body: ExternalAccountClassifyRequest, current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): """Classify + annotate one external email (cross-vendor, upsert). The annotation applies to that email everywhere it appears. A ``known`` classification (shared_service / external_guest / contractor) drops it out of every panel's 「外部未匹配」 anomaly count. """ from app.domains.asset.external_account_service import ( ExternalAccountRegistryService, ) svc = ExternalAccountRegistryService(db) data = await svc.classify( body.email, classification=body.classification, note=body.note, owner_person_id=body.owner_person_id, set_owner="owner_person_id" in body.model_fields_set, trace_id=request.state.trace_id, actor_user_id=current_user.id, ) await db.commit() return success_response(data=data, request=request) @router.delete("/external-accounts/{email}") async def external_accounts_clear( request: Request, email: str, current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): """Clear an email's annotation (reverts it to unclassified).""" from app.domains.asset.external_account_service import ( ExternalAccountRegistryService, ) svc = ExternalAccountRegistryService(db) await svc.delete( email, trace_id=request.state.trace_id, actor_user_id=current_user.id, ) await db.commit() return success_response(data={"email": email.strip().lower()}, request=request) # --------------------------------------------------------------------------- # MDM managed-device ledger (migration 558) # --------------------------------------------------------------------------- # The fleet as the MDM reports it, refreshed nightly by # ``jamf.device_sync``. Read-only apart from one action: confirming that # a disposed device has been removed from the tenant. Everything else on # this page is observation — enrolment belongs to the device, and Helix # does not drive it. # # Route order matters: the literal paths below MUST stay above # ``GET /{asset_id}``, and ``/mdm-devices/meta`` + ``/export.xlsx`` above # ``/mdm-devices/{device_id}``, or FastAPI matches the UUID route first # and answers a 422 for a perfectly good request. def _mdm_summary_from_rows(rows: list[Any], *, now: _dt) -> MdmDeviceSummary: """Counters over the WHOLE ledger, not over the filtered page. A headline that moves when you type in the search box is not a headline. These are computed from an unfiltered fetch so the KPI strip keeps meaning the same thing while the operator narrows down. """ from app.core.coordinators.mdm_device_retirement_coordinator import ( REMINDER_INTERVAL_DAYS, ) from app.domains.asset.models import ( MDM_MATCH_EXTERNAL, MDM_MATCH_LEAVER_RESIDUAL, MDM_MATCH_MATCHED, MDM_MATCH_UNOWNED, MDM_STATE_ENROLLED, MDM_STATE_GONE, MDM_STATE_PENDING_CLEANUP, ) s = MdmDeviceSummary() overdue_cutoff = now - _timedelta(days=REMINDER_INTERVAL_DAYS) for r in rows: s.total += 1 if r.enrollment_state == MDM_STATE_ENROLLED: s.enrolled += 1 elif r.enrollment_state == MDM_STATE_PENDING_CLEANUP: s.pending_cleanup += 1 requested = r.cleanup_requested_at if requested is not None: if requested.tzinfo is None: requested = requested.replace(tzinfo=UTC) if requested <= overdue_cutoff: s.overdue_cleanup += 1 elif r.enrollment_state == MDM_STATE_GONE: s.gone += 1 # Everything below describes the LIVE fleet, so retired devices are # excluded. Two reasons, and the first is a hard house rule: each # of these counters backs a clickable KPI whose filter defaults to # ``include_gone=false``, and a tile that counts rows its own click # then hides is how a ledger stops being believed. The second is # that a retired device's ownership category is history — an # ex-employee's name on a machine that left the tenant a year ago # is not a reading of anything current. if r.enrollment_state == MDM_STATE_GONE: continue if r.match_category == MDM_MATCH_MATCHED: s.matched += 1 elif r.match_category == MDM_MATCH_LEAVER_RESIDUAL: s.leaver_residual += 1 elif r.match_category == MDM_MATCH_EXTERNAL: s.external += 1 elif r.match_category == MDM_MATCH_UNOWNED: s.unowned += 1 if r.asset_id is not None: s.linked_to_asset += 1 else: s.unlinked += 1 return s def _mdm_filtered( rows: list[Any], *, q: str | None, provider: str | None, device_type: str | None, enrollment_state: str | None, match_category: str | None, unlinked: bool | None, include_gone: bool, ) -> list[Any]: """Apply the page's filters in Python over the already-loaded fleet. The whole tenant is a few hundred rows — one indexed fetch is cheaper than round-tripping a filtered query and then a second unfiltered one for the counters, and it guarantees the KPI cards and the table are reading the same snapshot. """ from app.domains.asset.models import MDM_STATE_GONE needle = (q or "").strip().lower() # Asking for the retired devices IS asking to include them. The two # controls were independent, so selecting 「已从 MDM 移除」 from the # state filter dropped every row before the state was even tested and # returned an empty table — which reads as "there are none". show_gone = include_gone or enrollment_state == MDM_STATE_GONE out = [] for r in rows: if not show_gone and r.enrollment_state == MDM_STATE_GONE: continue if provider and r.provider != provider: continue if device_type and r.device_type != device_type: continue if enrollment_state and r.enrollment_state != enrollment_state: continue if match_category and r.match_category != match_category: continue if unlinked is not None and (r.asset_id is None) != unlinked: continue if needle: haystack = " ".join( str(v or "").lower() for v in ( r.serial_number, r.device_name, r.model, r.mdm_assigned_email, r.mdm_assigned_name, r.external_id, ) ) if needle not in haystack: continue out.append(r) return out async def _mdm_load_ledger(db: AsyncSession) -> list[Any]: from app.domains.asset.models import MdmManagedDevice return list( ( await db.execute( select(MdmManagedDevice).order_by( MdmManagedDevice.device_name.asc(), MdmManagedDevice.serial_key.asc(), ) ) ).scalars().all() ) async def _mdm_serialize( db: AsyncSession, rows: list[Any], ) -> list[dict[str, Any]]: """Flatten ledger rows, resolving the FK-less references defensively. The resolution columns carry no foreign keys by design, so a person or asset id can outlive the row it points at between two syncs. Every lookup here is a miss-tolerant dict get: an unresolvable id renders as an absence, never as an exception on a list endpoint. """ from app.identity.person_models import IdentityPerson person_ids = {r.assigned_person_id for r in rows if r.assigned_person_id} asset_ids = {r.asset_id for r in rows if r.asset_id} people: dict[Any, Any] = {} if person_ids: for p in ( await db.execute( select(IdentityPerson).where(IdentityPerson.id.in_(person_ids)) ) ).scalars().all(): people[p.id] = p assets: dict[Any, Any] = {} if asset_ids: for a in ( await db.execute( select(AssetItem).where(AssetItem.id.in_(asset_ids)) ) ).scalars().all(): assets[a.id] = a out: list[dict[str, Any]] = [] for r in rows: person = people.get(r.assigned_person_id) asset = assets.get(r.asset_id) payload = MdmDeviceResponse.model_validate(r).model_dump() payload["assigned_person_name"] = ( person.display_name if person is not None else None ) payload["assigned_person_email"] = ( person.work_email if person is not None else None ) payload["assigned_person_employment_status"] = ( person.employment_status if person is not None else None ) payload["asset_tag"] = asset.asset_tag if asset is not None else None payload["asset_status"] = asset.status if asset is not None else None payload["asset_disposed_at"] = ( asset.disposed_at if asset is not None else None ) out.append(payload) return out @router.get("/mdm-devices/meta", response_model=None) async def get_mdm_devices_meta( request: Request, current_user: AuthUser = Depends(require_asset_read()), db: AsyncSession = Depends(get_db), ): """Vocabularies, fleet counters and the caller's write capability.""" from app.core.rbac import can_manage_assets from app.domains.asset.models import ( MDM_ENROLLMENT_STATE_ORDER, MDM_MATCH_CATEGORIES, ) from app.integrations.models import Integration rows = await _mdm_load_ledger(db) integration = await db.scalar( select(Integration).where( Integration.kind == "mdm", Integration.enabled.is_(True), ) ) last_synced = max( (r.last_seen_at for r in rows if r.last_seen_at is not None), default=None, ) meta = MdmDeviceMetaResponse( providers=sorted({r.provider for r in rows}) or ["jamf"], device_types=["computer", "mobile"], # Served straight from the canonical ordered tuple, which the # membership frozenset is derived from — display order and # validity cannot drift apart, so there is nothing to assert. enrollment_states=list(MDM_ENROLLMENT_STATE_ORDER), match_categories=sorted(MDM_MATCH_CATEGORIES), owner_sources=["mdm", "asset"], # Role-only, deliberately. The write ALSO region-scopes when the # device resolves to an asset, so a regional ``asset_admin`` can # see the confirm button on a device outside their region and get # a 403 on it. Computing the honest flag would mean resolving # every device's location to answer one page-level boolean; # accepted, and stated here so the next reader does not have to # rediscover that the two gates differ. can_manage=can_manage_assets(current_user.role_names), integration_configured=integration is not None, last_synced_at=last_synced, summary=_mdm_summary_from_rows(rows, now=_dt.now(UTC)), ) return success_response(data=meta.model_dump(), request=request) @router.get("/mdm-devices/export.xlsx") async def export_mdm_devices( request: Request, q: str | None = None, provider: str | None = None, device_type: str | None = None, enrollment_state: str | None = None, match_category: str | None = None, unlinked: bool | None = None, include_gone: bool = False, current_user: AuthUser = Depends(require_asset_read()), db: AsyncSession = Depends(get_db), ): """Export the filtered device ledger. Filters mirror the list route.""" from app.core.exporting import EXPORT_ROW_LIMIT, xlsx_export_response rows = _mdm_filtered( await _mdm_load_ledger(db), q=q, provider=provider, device_type=device_type, enrollment_state=enrollment_state, match_category=match_category, unlinked=unlinked, include_gone=include_gone, ) total = len(rows) payload = await _mdm_serialize(db, rows[:EXPORT_ROW_LIMIT]) columns = [ ("serial_number", "序列号"), ("device_name", "设备名"), ("model", "型号"), ("os_version", "系统版本"), ("device_type", "设备类型"), ("provider", "MDM"), ("managed", "受管"), ("enrollment_state", "纳管状态"), ("match_category", "归属分类"), ("assigned_person_name", "使用人"), ("assigned_person_email", "使用人邮箱"), ("owner_source", "归属来源"), ("mdm_assigned_email", "MDM 记录的邮箱"), ("asset_tag", "资产编号"), ("asset_status", "资产状态"), ("asset_disposed_at", "资产处置时间"), ("cleanup_reason", "清理原因"), ("cleanup_requested_at", "清理发起时间"), ("cleanup_confirmed_at", "清理确认时间"), ("cleanup_confirmed_via", "确认来源"), # Exported because the auditor's question is "who said this was # done and was it true", and the answer needs both halves: the # sync CLEARS the confirmation when it refutes one, so a sheet # carrying only the confirmation columns shows a refuted claim # and an untouched request as the same two blank cells. ("cleanup_contradicted_at", "确认被同步推翻时间"), ("last_contact_at", "设备最近签到"), ("last_seen_at", "最近同步可见"), ("gone_at", "从 MDM 消失时间"), ("external_id", "MDM 记录 ID"), ] return await xlsx_export_response( db, request, current_user, columns=columns, rows=payload, total_matched=total, filename_stem="mdm_devices", sheet_title="MDM Devices", audit_action="asset.mdm_devices.export", audit_resource_type="asset_mdm_device", filters={ "q": q, "provider": provider, "device_type": device_type, "enrollment_state": enrollment_state, "match_category": match_category, "unlinked": unlinked, "include_gone": include_gone, }, ) @router.get("/mdm-devices", response_model=None) async def list_mdm_devices( request: Request, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=200), q: str | None = None, provider: str | None = None, device_type: str | None = None, enrollment_state: str | None = None, match_category: str | None = None, unlinked: bool | None = None, include_gone: bool = False, current_user: AuthUser = Depends(require_asset_read()), db: AsyncSession = Depends(get_db), ): """The MDM fleet. ``include_gone`` is off by default: devices the tenant no longer reports are history, and leaving them in the default view would make a ledger that only ever grows. They stay one filter away because "when did this leave the fleet" is a real audit question. """ rows = _mdm_filtered( await _mdm_load_ledger(db), q=q, provider=provider, device_type=device_type, enrollment_state=enrollment_state, match_category=match_category, unlinked=unlinked, include_gone=include_gone, ) total = len(rows) start = (page - 1) * page_size payload = await _mdm_serialize(db, rows[start:start + page_size]) return paginated_response( payload, total=total, page=page, page_size=page_size, request=request, ) @router.get("/mdm-devices/{device_id}", response_model=None) async def get_mdm_device( device_id: UUID, request: Request, current_user: AuthUser = Depends(require_asset_read()), db: AsyncSession = Depends(get_db), ): """One device, with its cleanup todo's current state when it has one.""" from app.domains.asset.models import MdmManagedDevice from app.domains.provisioning.models import ProvisioningTask device = await db.get(MdmManagedDevice, device_id) if device is None: raise NotFoundError("MDM device", str(device_id)) payload = (await _mdm_serialize(db, [device]))[0] payload["cleanup_task"] = None if device.cleanup_task_id is not None: task = await db.scalar( select(ProvisioningTask).where( ProvisioningTask.id == device.cleanup_task_id, ) ) if task is not None: payload["cleanup_task"] = { "id": str(task.id), "status": task.status, "assignee_id": ( str(task.assignee_id) if task.assignee_id else None ), "external_ref": task.external_ref, "created_at": task.created_at, "completed_at": task.completed_at, "context_data": task.context_data or {}, } return success_response(data=payload, request=request) @router.post("/mdm-devices/{device_id}/confirm-cleanup", response_model=None) @idempotent( "asset.mdm_device.cleanup_confirm", key_params=("device_id", "current_user.id"), ) async def confirm_mdm_device_cleanup( device_id: UUID, request: Request, body: MdmDeviceCleanupConfirmRequest | None = None, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Record that this device has been removed from the MDM tenant. Closes the todo and stamps the ledger, but deliberately leaves the device in ``pending_cleanup`` rather than marking it gone: only the tenant's own inventory can establish that, and the next sync will. If the sync keeps finding the device, the reminder loop picks it up again — which is the point of recording a human's claim and the vendor's confirmation as two different things. """ from app.core.coordinators.mdm_device_retirement_coordinator import ( confirm_cleanup_from_console, ) from app.domains.asset.models import ( MDM_STATE_PENDING_CLEANUP, MdmManagedDevice, ) device = await db.get(MdmManagedDevice, device_id) if device is None: raise NotFoundError("MDM device", str(device_id)) # Region-scope the write, the way every other asset mutation in this # router does. Reads here are region-blind on purpose (so is the rest # of the asset read surface), but a write that closes a todo about a # specific machine belongs to whoever administers that machine's # location. Only enforceable when the ledger resolved an asset — an # unmatched device has no location to scope against, and refusing # those would leave exactly the devices Helix knows least about with # no one able to close them. scoped_asset = ( await db.get(AssetItem, device.asset_id) if device.asset_id is not None else None ) if scoped_asset is not None: await assert_can_manage_asset(db, current_user, scoped_asset) # No resolvable asset — no location to scope against, so the entry # gate is the whole check. Both ways of getting here are stated # deliberately: the ledger never matched a serial (a third of a real # fleet), or the asset row was cleaned up after the request was # raised. Refusing them would leave exactly the devices Helix knows # least about with nobody able to close them, which is the worse # failure — but it IS a widening, so it is written down rather than # left as an untaken branch. if device.enrollment_state != MDM_STATE_PENDING_CLEANUP: raise AppException( code="MDM_CLEANUP_NOT_PENDING", message=( f"设备当前状态为 {device.enrollment_state},没有待确认的清理" "待办 —— 只有资产处置后仍在 MDM 中的设备才需要确认。" ), status_code=400, ) await confirm_cleanup_from_console( db, device=device, actor_user_id=current_user.id, # Hardcoded because the route gate above already guarantees it: # ``require_asset_admin`` admits only global_admin / asset_admin, # and the region check has run. Every other caller in the repo # derives this from a role predicate, so the constant is the odd # one out — kept because deriving it here would re-ask a question # the dependency already answered. actor_is_admin=True, note=(body.note if body else None), trace_id=request.state.trace_id, ) await AuditService(db).log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.mdm_device.cleanup_confirm", status="success", resource_type="asset_mdm_device", resource_id=str(device.id), detail={ "serial_number": device.serial_number, "provider": device.provider, "cleanup_reason": device.cleanup_reason, "task_id": ( str(device.cleanup_task_id) if device.cleanup_task_id else None ), "note": (body.note if body else None), }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() return success_response( data=(await _mdm_serialize(db, [device]))[0], request=request, ) # --------------------------------------------------------------------------- # Software monthly billing — frozen snapshots per period (migration 063) # --------------------------------------------------------------------------- # Scope model mirrors /cost-stats: # * /software-billing/periods — admin + anyone can see the # month list; details are gated # per-scope endpoint. # * /software-billing/{period}/my — own rows only # * /software-billing/{period}/subordinates — caller + direct/transitive reports # * /software-billing/{period}/department — dept subtree # * /software-billing/{period}/all — admin, entire company # * POST /software-billing/close | reopen — admin # # The implementation lives in ``billing_service.py`` — router just does # RBAC + response wrapping. ``_resolve_subordinate_ids`` (defined at # the top of this module) does the BFS walk that asset list / export / # /team / cost-stats / billing-subordinates all share. @router.get("/software-billing/periods") async def list_billing_periods( request: Request, months: int = Query(12, ge=1, le=36), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Recent months with close status. Visible to every logged-in user so an employee can pick "March" to see their own bill — close-status is non-sensitive (just "was this month frozen"), and the actual bill rows are still scoped by the /my | /subordinates | /department | /all endpoints.""" from app.domains.asset import billing_service data = await billing_service.list_known_periods(db, months=months) return success_response(data=data, request=request) async def _resolve_range_summary_scope( db: AsyncSession, current_user: AuthUser, view: str, department: str | None, ) -> tuple[list[UUID] | None, str | None]: """Scope resolution for the cross-month range summary. Returns ``(user_ids, department)`` to hand to ``billing_service.summarize_range``: ``user_ids=None`` means whole company. Each branch mirrors the RBAC of the matching per-period endpoint (``/software-billing/{period}/{view}``) exactly — this surface aggregates the SAME rows over more months, so it must not open a wider door than the monthly view it summarises. """ if view == "my": return [current_user.id], None if view == "subordinates": # Same rule as ``/subordinates``: admin or an actual manager gets # the subtree; anyone else silently narrows to self rather than 403 # (an employee landing on the tab still sees their own numbers). if not can_read_all_assets(current_user.role_names) and not current_user.is_manager: return [current_user.id], None visited = await _resolve_subordinate_ids(db, current_user.id) visited.add(current_user.id) return list(visited), None if view == "department": dept = department or current_user.department if not dept: raise AppException( code="NO_DEPARTMENT", message="Department not specified", status_code=400, ) if not can_manage_assets(current_user.role_names): own_chain = set(current_user.department_chain or []) if current_user.department: own_chain.add(current_user.department) if dept not in own_chain: raise AppException( code="FORBIDDEN", message=( "You can only view billing for your own department chain." ), status_code=403, ) return None, dept # view == "all" — company-wide. ``can_read_all_assets`` carries the same # role set as ``require_software_read`` (global_admin / asset_admin / # finance / auditor / management), which is what the per-period ``/all`` # endpoint is gated on; checked inline because the gate depends on the # ``view`` argument and so can't be a route dependency. if not can_read_all_assets(current_user.role_names): raise ForbiddenError( "Company-wide software billing requires the software read scope.", ) return None, department or None @router.get("/software-billing/range-summary") async def software_billing_range_summary( request: Request, start: str = Query(..., description="Start period, YYYY-MM"), end: str = Query(..., description="End period, YYYY-MM"), view: Literal["my", "subordinates", "department", "all"] = "all", software_id: list[UUID] = Query(default_factory=list), department: str | None = Query(default=None, max_length=128), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Per-software spend totals across a month RANGE (finance's "这些软件这段时间分别一共花了多少"). Same caliber as the per-period views it aggregates: closed months read the frozen snapshot, unclosed months are live-previewed (capped and reported, never silently dropped), amounts stay in native currency bucketed per currency. Scope mirrors ``/software-billing/{period}/{view}`` — see :func:`_resolve_range_summary_scope`. """ from app.domains.asset import billing_service user_ids, dept = await _resolve_range_summary_scope( db, current_user, view, department, ) data = await billing_service.summarize_range( db, start, end, user_ids=user_ids, software_ids=list(software_id), department=dept, ) return success_response(data=data, request=request) @router.get("/software-billing/range-summary/export.xlsx") async def export_software_billing_range_summary( request: Request, start: str = Query(..., description="Start period, YYYY-MM"), end: str = Query(..., description="End period, YYYY-MM"), view: Literal["my", "subordinates", "department", "all"] = "all", software_id: list[UUID] = Query(default_factory=list), department: str | None = Query(default=None, max_length=128), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """XLSX of the range summary — one row per software × currency, one column per month plus the range total. Currency is its own column (never a mixed-currency sum) and each month header carries its close state, so the spreadsheet carries the same caveats the screen does. Filter signature + RBAC mirror :func:`software_billing_range_summary`. """ from app.core.exporting import xlsx_export_response from app.domains.asset import billing_service user_ids, dept = await _resolve_range_summary_scope( db, current_user, view, department, ) data = await billing_service.summarize_range( db, start, end, user_ids=user_ids, software_ids=list(software_id), department=dept, ) periods: list[str] = data["periods"] status_label = { "finalized": "已关账", "live": "实时", "excluded": "未纳入", "future": "未来", } columns = [ ("software_name", "软件"), ("currency", "币种"), ("range_total", f"区间合计 ({data['start']} ~ {data['end']})"), ("distinct_users", "计费人数(去重)"), *[ (f"m_{p}", f"{p} ({status_label.get(data['period_status'].get(p, ''), '')})") for p in periods ], ] rows: list[dict[str, Any]] = [] for sw in data["software"]: for currency, total in sw["by_currency"].items(): row: dict[str, Any] = { "software_name": sw["software_name"] or "—", "currency": currency, "range_total": total, # Distinct people are counted per software, not per currency; # repeating the number on a second currency row would read as # double the headcount, so only the first row carries it. "distinct_users": ( sw["distinct_users"] if currency == next(iter(sw["by_currency"])) else "" ), } for p in periods: row[f"m_{p}"] = sw["months"].get(p, {}).get(currency, "") rows.append(row) return await xlsx_export_response( db, request, current_user, columns=columns, rows=rows, total_matched=len(rows), filename_stem=f"software_cost_{data['start']}_{data['end']}", sheet_title="Software Cost Range", audit_action="asset.software_billing.range_export", audit_resource_type="software_monthly_bill", filters={ "start": data["start"], "end": data["end"], "view": view, "software_id": [str(s) for s in software_id], "department": dept, "live_periods": data["live_periods"], "excluded_periods": data["excluded_periods"], }, ) @router.get("/software-billing/{period}/my") async def my_billing( period: str, request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Employee views own bill for a period.""" from app.domains.asset import billing_service data = await billing_service.list_period_bills( db, period, user_ids=[current_user.id], ) return success_response(data=data, request=request) @router.get("/software-billing/{period}/subordinates") async def subordinates_billing( period: str, request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Manager view — caller + every direct/transitive report. Requires an explicit manager relationship or admin role; non-manager non-admin callers receive only their own billing data. """ from app.domains.asset import billing_service is_admin = can_read_all_assets(current_user.role_names) if not is_admin and not current_user.is_manager: data = await billing_service.list_period_bills( db, period, user_ids=[current_user.id], ) return success_response( data=data, request=request, meta={"user_count": 1}, ) visited = await _resolve_subordinate_ids(db, current_user.id) visited.add(current_user.id) data = await billing_service.list_period_bills(db, period, user_ids=list(visited)) return success_response( data=data, request=request, meta={"user_count": len(visited)}, ) @router.get("/software-billing/{period}/department") async def department_billing( period: str, request: Request, department: str | None = None, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Department view — mirrors /cost-stats/department scope rules. Non-admin callers can only query their own department chain; an admin can query any department. Unlike cost-stats we don't do subtree expansion here because the bill snapshots already store the leaf department name for each seat; an admin who wants "Business Operations + descendants" should close at the parent level before asking (or use /all).""" dept = department or current_user.department if not dept: raise AppException( code="NO_DEPARTMENT", message="Department not specified", status_code=400, ) if not can_manage_assets(current_user.role_names): own_chain = set(current_user.department_chain or []) if current_user.department: own_chain.add(current_user.department) if dept not in own_chain: raise AppException( code="FORBIDDEN", message="You can only view billing for your own department chain.", status_code=403, ) from app.domains.asset import billing_service data = await billing_service.list_period_bills(db, period, department=dept) return success_response(data=data, request=request) @router.get("/software-billing/{period}/all") async def all_billing( period: str, request: Request, software_id: UUID | None = None, bu: str | None = Query(default=None, max_length=128), current_user: AuthUser = Depends(require_software_read()), db: AsyncSession = Depends(get_db), ): """Admin views company-wide bill for a period (finalised or draft). ``bu`` narrows to a single top-level org node (Business Unit). The expansion uses the **current** org tree, not the bill's frozen department string — same trade-off as ``/cost-stats/all``. """ from app.domains.asset import billing_service from app.identity.bu_helpers import resolve_bu_user_ids user_ids: list[UUID] | None = None if bu: bu_ids = await resolve_bu_user_ids(db, bu) if not bu_ids: # Unknown BU → return empty payload with the canonical shape. return success_response( data={ "period": period, "status": "draft", "total_amount": "0.00", "active_seats": 0, "vendor_seats_by_software": {}, "finalized_at": None, "finalized_by": None, "items": [], }, request=request, ) user_ids = list(bu_ids) data = await billing_service.list_period_bills( db, period, software_id=software_id, user_ids=user_ids, ) return success_response(data=data, request=request) @router.post("/software-billing/close") async def close_billing_month( body: CloseMonthRequest, request: Request, current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): """Finalise a past period. Refuses current/future months. Refuses already-closed periods — caller must reopen first if correcting. Pre-launch hardening 2026-05-05: gated to ``global_admin`` only (was ``asset_admin``). Closing a financial-ledger period is a finance-bookkeeping action; the broader ``asset_admin`` scope is appropriate for inventory/seat ops but too wide for a step that freezes the company's monthly software spend.""" from app.domains.asset import billing_service result = await billing_service.close_month(db, body.period, actor_id=current_user.id) # Audit: closing a month is a financial-bookkeeping action worth # a trace row so a later "who closed March?" question has an answer. audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="software_billing.close", resource_type="software_monthly_bill", resource_id=body.period, status="success", detail={ "period": body.period, "line_count": result["line_count"], # ``total_amount`` is None when the period mixes currencies; # ``by_currency`` is the authoritative breakdown in that # case. Audit captures both so the post-hoc record survives. "total_amount": ( str(result["total_amount"]) if result.get("total_amount") is not None else None ), "by_currency": result.get("by_currency", {}), "currency_warning": result.get("currency_warning", False), }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() return success_response(data=result, request=request) @router.post("/software-billing/reopen") async def reopen_billing_month( body: CloseMonthRequest, request: Request, current_user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ): """Reopen a closed period so it can be re-closed with corrected data. Snapshots the prior rows into ``software_monthly_bill_history`` before clearing — finance audit can still reconstruct the pre-correction view by querying the history table. Pre-launch hardening 2026-05-05: gated to ``global_admin`` only (was ``asset_admin``); see ``close`` endpoint for rationale. Reopen + re-close is the finance-correction primitive; combined with the snapshot, every version of every period stays reconstructible — no silent rewrite of frozen ledgers.""" from app.domains.asset import billing_service result = await billing_service.reopen_month( db, body.period, actor_id=current_user.id, ) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="software_billing.reopen", resource_type="software_monthly_bill", resource_id=body.period, status="success", detail={ "period": body.period, "deleted": result["deleted"], "snapshot_count": result.get("snapshot_count", 0), "snapshot_total": str(result.get("snapshot_total", "0.00")), "snapshot_supersedes_seq": result.get("supersedes_seq"), }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() return success_response(data=result, request=request) # --------------------------------------------------------------------------- # Existing detail/update/delete endpoints (must come after all /path routes) # --------------------------------------------------------------------------- @router.get("/{asset_id}") async def get_asset_detail( request: Request, asset_id: UUID, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Get asset detail with history. Pre-launch hardening 2026-05-04: * Returns 404 for unauthorized access (instead of distinct 403) so an authenticated employee cannot probe ``/assets/`` to enumerate which UUIDs exist (and which state they're in) vs which don't. * Operation/history rows for third-party borrowers/transferers outside the viewer's scope are filtered out, so a manager asking about their report's laptop doesn't learn the names of cross-department borrowers. """ service = AssetService(db) try: asset = await service.get_asset(asset_id) except NotFoundError: # Re-raise as-is. raise is_admin = can_read_all_assets(current_user.role_names) # Build the viewer-scope set: self + (if manager) full BFS subordinate # tree. Uses _resolve_subordinate_ids (same helper as the list endpoint) # so skip-level managers (VP → director → engineer) see the same assets # in detail view as they do in the list view. The previous direct-reports- # only query created an inconsistency where a list hit returned a 404 on # the detail call for the same asset. viewer_scope: set[UUID] = {current_user.id} if not is_admin and current_user.is_manager: viewer_scope |= await _resolve_subordinate_ids(db, current_user.id) if not is_admin: # Unified denial → 404, not 403, to defeat existence-probing. # Allowed if asset.assigned_to OR asset.borrowed_by is in # viewer_scope; idle/unassigned rows are admin-only. owner = asset.assigned_to borrower = asset.borrowed_by owner_in_scope = owner is not None and owner in viewer_scope borrower_in_scope = borrower is not None and borrower in viewer_scope if not (owner_in_scope or borrower_in_scope): raise NotFoundError("Asset", str(asset_id)) # Round 6 audit fix-up 2026-05-04: pass viewer_scope into # enrichment so cross-scope assigned_to/borrowed_by names + emails # are redacted (admin sees everything; non-admin sees only the # users they're allowed to see by org-membership). enriched = await service.enrich_with_users( [asset], viewer_scope_user_ids=None if is_admin else viewer_scope, ) asset_dict = enriched[0] # Get and enrich history if hasattr(asset, "history") and asset.history: history_enriched = await service.enrich_history_with_users(asset.history) if not is_admin: history_enriched = [ row for row in history_enriched if _row_user_in_scope(row, viewer_scope) ] asset_dict["history"] = history_enriched # Get and enrich operations. ``admin_view=is_admin`` redacts the # detail dict's HR-internal ``location_raw`` key for non-admin # viewers (manager / owner / borrower scope) — a manager browsing # a subordinate's asset history shouldn't see the raw # ``identity_persons.work_location_name`` values that operators # submitted before the resolver canonicalised them. The # ``_row_user_in_scope`` filter below already trims rows by user # FKs; this additional ``admin_view`` parameter trims the JSON # payload of the rows that survive. Round-3 review medium. if hasattr(asset, "operations") and asset.operations: ops_enriched = await service.enrich_operations_with_users( asset.operations, admin_view=is_admin, ) if not is_admin: ops_enriched = [ row for row in ops_enriched if _row_user_in_scope(row, viewer_scope) ] asset_dict["operations"] = ops_enriched return success_response( data=AssetDetailResponse(**asset_dict), request=request, ) def _row_user_in_scope(row: dict, scope: set[UUID]) -> bool: """True if any of the row's user-FK fields lies in the viewer scope. Used to filter operation/history rows so a non-admin sees only transitions where they (or their subordinates) were source / destination / operator. Otherwise an admin who borrowed the asset on a colleague's behalf would leak that colleague's name into the manager-or-owner detail view. """ for key in ("from_user_id", "to_user_id", "operator_id", "from_user", "to_user"): v = row.get(key) if v is None: continue try: if UUID(str(v)) in scope: return True except (ValueError, TypeError): continue return False @router.patch("/{asset_id}") async def update_asset( request: Request, asset_id: UUID, body: AssetUpdateRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Update asset details (IT Admin only).""" await _enforce_asset_region(db, current_user, asset_id) # Post-mutation region check: an asset_admin must NOT be able to # PATCH ``holder_office_id`` / ``location`` / ``assigned_to`` to # values that would move the asset outside their region. The # pre-update gate validates the current row; if the body changes # any of those three fields we re-validate the resulting state # against the same gate. global_admin bypasses naturally inside # ``assert_can_manage_asset``. body_dict = body.model_dump(exclude_unset=True) region_changing = ( "holder_office_id" in body_dict or "location" in body_dict or "assigned_to" in body_dict ) service = AssetService(db) asset = await service.update_asset(asset_id, body, current_user.id) if region_changing: # Re-resolve and re-check on the in-memory (pre-commit) row. await assert_can_manage_asset(db, current_user, asset) # If a new assigned_to was set, also enforce target-person region. new_assignee = body_dict.get("assigned_to") if new_assignee: await _assert_target_person_in_region( db, current_user, UUID(str(new_assignee)), ) # Audit log audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.update", resource_type="asset", resource_id=str(asset.id), status="success", detail=body.model_dump(mode="json", exclude_unset=True), ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() # Enrich with user info enriched = await service.enrich_with_users([asset]) return success_response( data=AssetResponse(**enriched[0]), request=request, ) @router.delete("/{asset_id}") async def delete_asset( request: Request, asset_id: UUID, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Soft-delete an asset (asset admin). Refuses if asset is in use. Round-27 docstring fix (2026-05-25): previously claimed "Hard-delete" but ``AssetService.delete_asset`` stamps ``deleted_at`` and preserves the row + history. The mis-statement risked a future code reader (or AI agent) designing reactivation logic on the wrong premise. The behaviour is unchanged — only the docstring. """ await _enforce_asset_region(db, current_user, asset_id) service = AssetService(db) asset = await service.delete_asset(asset_id, current_user.id) # Snapshot identifying fields before the row is gone. deleted_id = str(asset.id) detail = { "asset_tag": asset.asset_tag, "asset_name": asset.asset_name, "category": asset.category, "serial_number": asset.serial_number, "status_at_delete": asset.status, } # Audit log audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.delete", resource_type="asset", resource_id=deleted_id, status="success", detail=detail, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() return success_response( data={"id": deleted_id, "deleted": True}, request=request, ) @router.post("/{asset_id}/restore") async def restore_asset( request: Request, asset_id: UUID, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Restore a soft-deleted asset from the recycle bin (Asset Admin only).""" # Region check on a soft-deleted asset: bypass the # ``deleted_at IS NULL`` filter in ``_enforce_asset_region`` by # loading directly. asset_for_check = await db.get(AssetItem, asset_id) if asset_for_check is None: raise NotFoundError("Asset", str(asset_id)) await assert_can_manage_asset(db, current_user, asset_for_check) service = AssetService(db) asset = await service.restore_asset(asset_id, current_user.id) audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.restore", resource_type="asset", resource_id=str(asset.id), status="success", detail={ "asset_tag": asset.asset_tag, "asset_name": asset.asset_name, "serial_number": asset.serial_number, }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() return success_response( data={"id": str(asset.id), "restored": True}, request=request, ) @router.post("/{asset_id}/assign") async def assign_asset( request: Request, asset_id: UUID, body: AssetAssignRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Assign asset to a user (IT Admin only).""" await _enforce_asset_region(db, current_user, asset_id) await _assert_target_person_in_region(db, current_user, body.user_id) service = AssetService(db) asset = await service.assign_asset(asset_id, body, current_user.id) # Audit log audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.assign", resource_type="asset", resource_id=str(asset.id), status="success", detail={ "asset_tag": asset.asset_tag, "assigned_to": str(body.user_id), }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() # Enrich with user info enriched = await service.enrich_with_users([asset]) return success_response( data=AssetResponse(**enriched[0]), request=request, ) @router.post("/{asset_id}/unassign") async def unassign_asset( request: Request, asset_id: UUID, notes: str | None = None, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Unassign (reclaim) an asset (IT Admin only).""" await _enforce_asset_region(db, current_user, asset_id) service = AssetService(db) asset = await service.unassign_asset(asset_id, current_user.id, notes) # Audit log audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.unassign", resource_type="asset", resource_id=str(asset.id), status="success", detail={"asset_tag": asset.asset_tag}, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() # Enrich with user info enriched = await service.enrich_with_users([asset]) return success_response( data=AssetResponse(**enriched[0]), request=request, ) @router.post("/{asset_id}/transfer") async def transfer_asset( request: Request, asset_id: UUID, body: AssetTransferRequest, current_user: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """Transfer asset between users (IT Admin only).""" await _enforce_asset_region(db, current_user, asset_id) await _assert_target_person_in_region(db, current_user, body.to_user_id) service = AssetService(db) asset = await service.transfer_asset(asset_id, body, current_user.id) # Audit log audit = AuditService(db) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="asset.transfer", resource_type="asset", resource_id=str(asset.id), status="success", detail={ "asset_tag": asset.asset_tag, "to_user": str(body.to_user_id), }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) await db.commit() # Enrich with user info enriched = await service.enrich_with_users([asset]) return success_response( data=AssetResponse(**enriched[0]), request=request, ) # --------------------------------------------------------------------------- # Helpers for seat allocation enrichment & cost computation # --------------------------------------------------------------------------- async def _enrich_seat_allocations( db: AsyncSession, allocations: list[SoftwareSeatAllocation], ) -> list[dict]: if not allocations: return [] from app.identity.person_models import IdentityPerson user_ids = {a.user_id for a in allocations} users: dict = {} if user_ids: from sqlalchemy.orm import selectinload result = (await db.execute( select(IdentityPerson) .options(selectinload(IdentityPerson.org_memberships)) .where(IdentityPerson.id.in_(user_ids)) )).scalars().all() users = {r.id: r for r in result} # Batch-load per-software connector binding so the response can carry # the ``seat_revocable`` flag for each allocation. The flag is set # ``false`` on Marketplace add-on plans (Jira Tempo / eazyBI / # ScriptRunner) that share the base product's group binding — a # per-add-on cancel button on those plans is a UX trap (the cascade # makes the click cancel the whole product family). The frontend # uses this signal to disable the button + render a "cancel via # base product" tooltip; backend cascade still enforces correctness # if someone bypasses the UI. # # ``None`` (no binding / driver doesn't ship the flag) means "no # opinion, default to revocable" — matches the existing # ``_seat_revocable`` helper used by idle_reclaim. software_ids = { a.plan.software.id for a in allocations if a.plan is not None and a.plan.software is not None } seat_revocable_by_software: dict = {} base_plan_name_by_software: dict = {} if software_ids: from app.domains.provisioning.models import ( SoftwareConnector as _SCRow, ) connectors = (await db.execute( select(_SCRow).where( _SCRow.software_id.in_(software_ids), _SCRow.is_active.is_(True), ) )).scalars().all() for sc in connectors: raw = (sc.binding_config or {}).get("seat_revocable") if raw is None: value = True elif isinstance(raw, bool): value = raw elif isinstance(raw, str): value = raw.strip().lower() not in ("false", "0", "no", "off") elif isinstance(raw, (int, float)): value = bool(raw) else: value = True seat_revocable_by_software[sc.software_id] = value # For non-revocable add-ons, also surface the **base # product's name** so the tooltip can point at it # ("Cancel on Jira Software instead"). The base plan # within an Atlassian integration is the one whose # ``software_name`` doesn't carry add-on token in PingCAP's # convention — but we can't reliably infer base-vs-add-on # from the connector alone. Surface the same integration's # base ``software_name`` via a sibling-plan lookup below. # Resolve the per-add-on base-product name: for each # non-revocable software, find ANY revocable sibling on the # same integration with the same product_key — that's the # base plan the cascade ends at. Single batched query. non_revocable_software_ids = [ sid for sid, ok in seat_revocable_by_software.items() if not ok ] if non_revocable_software_ids: from sqlalchemy import func as _func from app.domains.asset.models import ( AssetSoftwareCatalog as _SwCat, ) # Build (integration_id, product_key) for each non-revocable # add-on, then find a sibling software_catalog row with the # same pair and seat_revocable=true (or unset → default true). addon_keys: dict = {} for sc in connectors: if not seat_revocable_by_software.get(sc.software_id, True): pk = str( (sc.binding_config or {}).get("product_key") or "", ).strip().lower() if pk: addon_keys[sc.software_id] = (sc.integration_id, pk) if addon_keys: # Distinct (integration, product_key) pairs to look up. for sw_id, (integ_id, pk) in addon_keys.items(): base_row = (await db.execute( select(_SwCat.name) .select_from(_SCRow) .join(_SwCat, _SwCat.id == _SCRow.software_id) .where( _SCRow.integration_id == integ_id, _SCRow.is_active.is_(True), _func.json_unquote( _func.json_extract( _SCRow.binding_config, "$.product_key", ), ) == pk, # Base plan: seat_revocable not explicitly false. # MySQL JSON: missing field is `null`; treat as # default-true via NOT-equal-to-false. _func.json_unquote( _func.json_extract( _SCRow.binding_config, "$.seat_revocable", ), ) != "false", _SCRow.software_id != sw_id, ) .limit(1) )).scalar_one_or_none() if base_row: base_plan_name_by_software[sw_id] = base_row enriched = [] for a in allocations: user = users.get(a.user_id) plan = a.plan sw = plan.software if plan else None row = SeatAllocationResponse( id=a.id, plan_id=a.plan_id, plan_name=plan.name if plan else None, software_id=plan.software_id if plan else None, software_name=sw.name if sw else None, billing_cycle=plan.billing_cycle if plan else None, price=plan.price if plan else None, # Plan-level currency, normalised like the billing snapshot # (``(plan.currency or 'CNY').upper()``) so the live cost-stats # rows group by the same currency keys the frozen bill uses. currency=(plan.currency or "CNY").upper() if plan else None, user_id=a.user_id, user_name=user.display_name if user else None, user_email=user.work_email if user else None, user_employee_no=user.employee_no if user else None, user_employment_status=user.employment_status if user else None, department=user.department if user else None, status=a.status, reason=a.reason, approval_request_id=a.approval_request_id, allocated_at=a.allocated_at, revoked_at=a.revoked_at, ).model_dump() # Surface the self-cancel state so the frontend can show # "Pending — auto-revoke 2026-06-01" + an Undo button without # a second roundtrip. ``model_dump()`` strips unknown fields, # so we tack these on manually instead of widening the # response schema for fields that only matter here. row["cancel_at_period_end"] = ( a.cancel_at_period_end.isoformat() if a.cancel_at_period_end else None ) row["cancel_requested_at"] = ( a.cancel_requested_at.isoformat() if a.cancel_requested_at else None ) row["cancel_requested_by"] = ( str(a.cancel_requested_by) if a.cancel_requested_by else None ) row["cancel_reason"] = a.cancel_reason # Idle-reclaim state so the frontend can show "未使用 — N 天后回收" # + a 保留坐席 button. Same tack-on convention as the cancel # fields above. ``idle_warned_at`` set (with no future # ``idle_kept_until``) means the seat is mid-grace-buffer. row["idle_warned_at"] = ( a.idle_warned_at.isoformat() if a.idle_warned_at else None ) row["idle_kept_until"] = ( a.idle_kept_until.isoformat() if a.idle_kept_until else None ) row["idle_manual_review_at"] = ( a.idle_manual_review_at.isoformat() if a.idle_manual_review_at else None ) # Per-seat revocable flag + base-plan pointer for the add-on # case. Frontend renders the cancel button disabled with a # tooltip pointing at ``base_product_name`` when # ``seat_revocable`` is false. sw_id_local = ( plan.software_id if plan is not None else None ) if sw_id_local is not None: row["seat_revocable"] = bool( seat_revocable_by_software.get(sw_id_local, True), ) base_name = base_plan_name_by_software.get(sw_id_local) row["base_product_name"] = base_name if base_name else None else: row["seat_revocable"] = True row["base_product_name"] = None # Self-cancel policy from the software catalog. ``False`` = # IT-managed (Google / Feishu): the seats tab hides/disables the # holder's own Cancel button (admins still see it — admin-on- # behalf). Defaults to True when the catalog row is missing. row["allow_self_cancel"] = bool(sw.allow_self_cancel) if sw else True # Top-level BU (org_path first segment, tenant root stripped) so # the cost view can roll leaf departments up to their business # unit. ``department_chain`` is leaf-last with the tenant root # removed, so ``[0]`` is the BU; it falls back to the leaf name # when the person sits directly under the tenant root. Tacked on # as a dict key (same convention as the cancel_*/idle_* fields # above) rather than widening the response schema. chain = user.department_chain if user else [] row["bu"] = chain[0] if chain else None enriched.append(row) return enriched async def _compute_cost_stats( db: AsyncSession, user_id: UUID | None = None, user_ids: list[UUID] | None = None, department: str | None = None, software_id: UUID | None = None, start_date: str | None = None, end_date: str | None = None, bu: str | None = None, hide_free: bool = True, ) -> dict: from datetime import date as date_cls from decimal import Decimal as D from app.identity.bu_helpers import resolve_bu_user_ids from app.identity.person_models import IdentityOrgMembership, IdentityPerson def _empty_summary() -> dict: """Canonical empty payload — keeps the response shape stable across short-circuit returns so callers never see a missing key. Nested helper so it captures ``D`` from the enclosing scope without re-importing. """ return CostSummaryResponse( total_monthly_cost=D("0"), total_yearly_cost=D("0"), total_one_time_cost=D("0"), total_cost=D("0"), by_currency={}, total_currency_count=0, currency_warning=False, active_seats=0, items=[], ).model_dump() # BU filter narrows the user-id set BEFORE the seat query so the # rest of the function doesn't need a second join. Unknown BU # short-circuits to "no rows" — important so an unknown BU name # doesn't accidentally widen scope (e.g. ignoring the param). if bu: bu_ids = await resolve_bu_user_ids(db, bu) if not bu_ids: return _empty_summary() if user_ids is not None: user_ids = [u for u in user_ids if u in bu_ids] if not user_ids: return _empty_summary() elif user_id is not None: if user_id not in bu_ids: return _empty_summary() else: user_ids = list(bu_ids) # "当月(实时)" = a LIVE preview of THIS MONTH's bill. It must select # exactly the seats the close snapshot will bill — anyone active at # any point this month, INCLUDING seats cancelled or tier-changed # mid-month (monthly SaaS bills the whole month; no refund mid-cycle). # Filtering ``status == 'active'`` here was the bug: it silently # dropped mid-month-revoked seats so the live total under-counted what # the period would actually bill ("账对不上"). We now reuse the close # path's predicate (billing_service.period_overlap_clauses) so the two # NEVER diverge on the billable set — they differ only on price (live # reads the current plan price; the snapshot freezes it at close). from app.domains.asset import billing_service period = billing_service._current_period() period_start, period_end_excl = billing_service._parse_period(period) query = ( select(SoftwareSeatAllocation) .join(SoftwareLicensePlan, SoftwareSeatAllocation.plan_id == SoftwareLicensePlan.id) .where(*billing_service.period_overlap_clauses(period_start, period_end_excl)) ) if user_id: query = query.where(SoftwareSeatAllocation.user_id == user_id) if user_ids: query = query.where(SoftwareSeatAllocation.user_id.in_(user_ids)) if department: # Subtree match: a request for "Business Operations" should pick # up every descendant ("IT Services & Security", etc.). Shared # matcher (``app.core.org_scope``) keeps this identical to the # users-list and cloud-overview department filters. from app.core.org_scope import department_membership_subtree_clause query = ( query.join(IdentityPerson, SoftwareSeatAllocation.user_id == IdentityPerson.id) .join(IdentityOrgMembership, IdentityPerson.id == IdentityOrgMembership.person_id) .where(IdentityOrgMembership.is_primary == True) # noqa: E712 .where(IdentityOrgMembership.deleted_at.is_(None)) .where(department_membership_subtree_clause(department)) ) if software_id: query = query.where(SoftwareLicensePlan.software_id == software_id) # Free-text query params — guard the ISO parse so a malformed date # returns a clean 422 instead of a 500 (mirrors the seat-list handler). if start_date: try: start_d = date_cls.fromisoformat(start_date) except ValueError as exc: raise ValidationError( f"start_date must be ISO format (YYYY-MM-DD), got {start_date!r}", ) from exc query = query.where(SoftwareSeatAllocation.allocated_at >= start_d) if end_date: try: end_d = date_cls.fromisoformat(end_date) except ValueError as exc: raise ValidationError( f"end_date must be ISO format (YYYY-MM-DD), got {end_date!r}", ) from exc query = query.where(SoftwareSeatAllocation.allocated_at <= end_d) rows = (await db.execute(query)).scalars().all() # Per-seat this-period contribution, computed with the SAME helper the # close path uses (monthly=price, yearly=price/12, one_time=price only # in its allocation month) — but against the CURRENT plan price, which # is the one legitimate difference between the live view and a frozen # snapshot. Zero-contribution rows (one_time outside its allocation # month, or a 0-price plan) are dropped, mirroring the snapshot's # omission, so the live row set + totals stay 1:1 with the eventual bill. amount_by_id: dict[UUID, D] = {} billable_rows: list[SoftwareSeatAllocation] = [] for a in rows: plan = a.plan if plan is None: continue amt = billing_service._compute_amount( price=plan.price, billing_cycle=plan.billing_cycle, period_start=period_start, period_end_excl=period_end_excl, allocated_at=a.allocated_at, ) if amt == D("0.00"): # ``hide_free=False`` keeps ¥0-plan seats (e.g. NexusCRM — no # per-seat cost but seat-holders must stay visible for # offboarding) so they show with a ¥0 本期合计 + real 生效坐席. # Paid rows that are merely zero THIS period (a one_time license # outside its allocation month) are still dropped in both modes: # they aren't "free seats", and surfacing them would diverge # from the bill. Totals unaffected (free contributes 0). is_free_plan = (plan.price or D("0")) == D("0") if hide_free or not is_free_plan: continue amount_by_id[a.id] = amt billable_rows.append(a) enriched = await _enrich_seat_allocations(db, billable_rows) total_monthly = D("0") total_yearly = D("0") total_one_time = D("0") # "生效坐席" = vendor-authoritative seat count, not bill-line count. # See ``billing_service.preview_current_or_draft`` for the full # rationale — PR #579's ``len(billable_rows)`` interpretation inflated # to ~2.4× the real seat count on multi-tier products (JSM 175 vs # vendor's 72) because tier swaps and re-grants spawn extra rows for # the same person. The workbench cell now agrees with the drill-down # drawer's "厂商许可" — both read ``software_seat_sync_stat``. sw_ids_present: set[UUID] = { a.plan.software_id for a in billable_rows if a.plan and a.plan.software_id } # The vendor-authoritative seat count (``vendor_license_holders`` from # ``software_seat_sync_stat``) is an ORG-WIDE number — it mirrors the # vendor's admin-console total for the whole company. It is only # meaningful, and only safe to expose, on the whole-company admin view. # On a scoped view (``/cost-stats/my``, ``/subordinates``, # ``/department``, or ``/all?bu=`` which narrows to one BU) returning it # would (a) leak the company-wide paid-seat count of every product the # caller holds to a self-scope endpoint, and (b) report a wildly wrong # "生效坐席" (an employee viewing their own software cost would see the # org-wide seat total instead of 1). Mirrors the same gate in # ``billing_service.list_period_bills`` (Round-56). Scoped views fall # back to the in-scope distinct-user count and omit # ``vendor_seats_by_software``. scope_is_global = ( user_id is None and user_ids is None and department is None and bu is None ) vendor_seats = ( await billing_service._vendor_seats_by_software(db, sw_ids_present) if scope_is_global else {} ) active_seats = billing_service._seat_count_from_lines( [ {"software_id": a.plan.software_id, "user_id": a.user_id} for a in billable_rows if a.plan ], vendor_seats, ) # Currency lines feed the same aggregation primitive the close / # preview path uses, so the live view and the frozen bill agree on # the per-currency split (and the "mix → no single total" rule). currency_lines: list[dict] = [] for item in enriched: amt = amount_by_id.get(item["id"], D("0")) # Stamp the per-period amount so the row lines up 1:1 with a # monthly-bill snapshot row (the frontend renders ``amount`` and # sums it for the period total). SeatAllocationResponse carries an # optional ``amount`` field for exactly this. item["amount"] = amt currency_lines.append({"currency": item.get("currency"), "amount": amt}) cycle = item.get("billing_cycle", "") if cycle == "monthly": total_monthly += amt elif cycle == "yearly": total_yearly += amt elif cycle == "one_time": total_one_time += amt # ``by_currency`` is authoritative; ``single_total`` is the sum only # when one currency is present (``None`` on a mix), mirroring # billing_service.preview_current_or_draft. The per-cycle totals are # meaningful only single-currency — they'd otherwise add CNY + USD — # so they collapse to ``None`` alongside ``total_cost`` on a mix. by_currency, single_total, currency_warning = ( billing_service._aggregate_by_currency(currency_lines) ) return CostSummaryResponse( total_monthly_cost=None if currency_warning else total_monthly, total_yearly_cost=None if currency_warning else total_yearly, total_one_time_cost=None if currency_warning else total_one_time, # This-period total (NOT annualised) — single-currency only: the # per-cycle cards sum to it exactly, matching the "本期合计" on a # real bill. ``None`` when currencies mix (read ``by_currency``). total_cost=single_total, by_currency=by_currency, total_currency_count=len(by_currency), currency_warning=currency_warning, active_seats=active_seats, vendor_seats_by_software=billing_service._flatten_vendor_seats( vendor_seats, ), items=[SeatAllocationResponse(**item) for item in enriched], ).model_dump() # ── Software seat reconciliation ──────────────────────────────────── # Surfaces the "vendor bills N license holders but Helix only shows M # employee seats" gap (the "72 vs 69 vs 3" question) as structured, # drill-downable data instead of a raw JSON blob buried in the # scheduled-job runs page. Reads the two tables every ``*.seat_sync`` # handler now populates: ``software_seat_sync_stat`` (per-plan headline # snapshot) + ``software_external_holder`` (per-external-holder rows). # Occupancy + scheduled-cancel are queried LIVE from # ``software_seat_allocations`` (they change between syncs). def _vendor_admin_url( provider: str | None, vendor_account_id: str | None, email: str | None, ) -> str | None: """Best-effort deep-link into the vendor admin console for an external holder. Per-user where the vendor exposes a stable id-keyed URL (Google, Zoom); otherwise the admin members landing page so the admin can search by ``email``. ``None`` for vendors with no stable console URL we can construct without extra config (avoids a dead link).""" if provider == "google": if vendor_account_id: return f"https://admin.google.com/ac/users/{vendor_account_id}" return "https://admin.google.com/ac/users" if provider == "zoom": if vendor_account_id: return f"https://zoom.us/user/{vendor_account_id}/profile" return "https://zoom.us/account/user" if provider == "jira": return "https://admin.atlassian.com" if provider == "slack": return "https://slack.com/admin" if provider == "feishu": return "https://www.feishu.cn/admin/index" return None # Seat-sync crons run daily (``0 3 * * *``). A snapshot older than this # means at least one daily run silently produced nothing — the panel is # showing stale numbers as if current. Surfaced as ``sync_stale`` so the # UI can warn instead of presenting month-old data confidently (the # 2026-06-30 incident: Feishu connector flipped to user_suspend → cron # no-op → panel froze for a month with zero signal). 48h allows one # missed run + timezone slack before flagging. _SEAT_SYNC_STALE_AFTER_HOURS = 48 def _seat_sync_staleness(synced_at: Any) -> tuple[bool, int | None]: """Return ``(is_stale, stale_days)`` for a snapshot ``synced_at``. ``synced_at`` reads back **naive** from MySQL/TiDB even though the column is tz-aware — comparing a naive value against ``datetime.now(UTC)`` raises ``TypeError`` (memo'd bug class ``feedback_mysql_datetime_naive_on_fresh_load``), so coerce to aware-UTC first. ``None`` synced_at (never synced) is NOT flagged stale here — that's a distinct "no data yet" state the UI shows via the absent ``synced_at``. """ if synced_at is None: return (False, None) aware = synced_at if synced_at.tzinfo else synced_at.replace(tzinfo=UTC) age_hours = (_dt.now(UTC) - aware).total_seconds() / 3600 if age_hours <= _SEAT_SYNC_STALE_AFTER_HOURS: return (False, None) return (True, int(age_hours // 24)) async def _compute_seat_reconciliation_overview(db: AsyncSession) -> list[dict]: """Company-wide per-software reconciliation feeding the 运营洞察 table. One row per software that has a sync stat OR open external holders: vendor license holders, employee-occupied seats (live), external unmatched (live open count), scheduled-cancel (live), and the most recent sync time. """ from sqlalchemy import case from sqlalchemy import func as sa_func from sqlalchemy import select as sa_select from app.domains.asset.models import ( AssetSoftwareCatalog, SoftwareExternalHolder, SoftwareLicensePlan, SoftwareSeatAllocation, SoftwareSeatSyncStat, ) agg: dict[Any, dict] = {} def _blank(sid: Any) -> dict: return { "software_id": str(sid), "software_name": None, "software_status": None, "provider": None, "vendor_license_holders": 0, # Contracted/billable seats = vendor_license_holders clamped to # the plan's purchased ceiling (max_seats). Equals # vendor_license_holders for healthy products; lower when broad # non-licensing access inflates the holder count (Confluence). "licensed_seats": 0, # True when active access (vendor_license_holders) exceeds the # contracted ceiling — an under-licensing governance signal. "under_licensed": False, "holders_matched": 0, "holders_no_email": 0, "external_unmatched": 0, # External accounts an admin classified as known/expected # (subtracted from external_unmatched above; still listed). "external_known": 0, # Six-bucket anomaly counters — live open counts (since # migration 219). Each is the number of unresolved # software_external_holder rows in that category across # all of this software's plans. "leaver_residual": 0, # Leaver-residual holders an admin classified as known / # expected — a documented keep (offboarding's own retention # path writes exactly this row). Subtracted from # ``leaver_residual`` above, same contract as # ``external_known`` ⇄ ``external_unmatched``. "leaver_residual_known": 0, "vendor_inactive": 0, "off_paid_site": 0, "occupied_seats": 0, "scheduled_cancel": 0, "synced_at": None, # Seat-sync staleness signal — True when the latest snapshot # is older than the daily cadence allows (silent-freeze guard). "sync_stale": False, "sync_stale_days": None, } # ``vendor_license_holders`` (+ the bucket counts that sum to it) is a # family-keyed snapshot written once per license plan, so a plain # ``SUM`` over plans double-counts whenever a software carries two plans # of the same ``product_key``. Dedup to ONE representative row per # (software, product_key) — keep the row with the max vendor count (the # ``max`` is load-bearing; see ``billing_service._stat_family_key``) so # the bucket counts stay internally consistent — then sum across # DISTINCT families. NULL product_key (e.g. Slack) keys per plan so # genuinely distinct unkeyed plans still sum. ``licensed_seats`` clamps # each family's holder count to its contracted ceiling (max_seats) so # the workbench headline shows paid seats, not raw access (Confluence: # ~100 access vs 10 contracted). Mirrors # ``billing_service._vendor_seats_by_software`` / ``_effective_seats``. from app.domains.asset.billing_service import _effective_seats stat_rows = (await db.execute( sa_select( SoftwareSeatSyncStat.software_id, SoftwareSeatSyncStat.product_key, SoftwareSeatSyncStat.plan_id, SoftwareSeatSyncStat.provider, SoftwareSeatSyncStat.vendor_license_holders, SoftwareSeatSyncStat.holders_matched, SoftwareSeatSyncStat.holders_no_email, SoftwareSeatSyncStat.synced_at, SoftwareLicensePlan.max_seats, ).join( SoftwareLicensePlan, SoftwareLicensePlan.id == SoftwareSeatSyncStat.plan_id, ), )).all() # software_id -> {family_key: representative row dict}; plus per-sw # provider / latest synced_at tracked across ALL rows of the software. _sw_families: dict[Any, dict[str, dict]] = {} _sw_meta: dict[Any, dict] = {} for sid, pk, plan_id, provider, vh, hm, hne, synced, max_seats in stat_rows: fkey = pk if pk else f"__plan__:{plan_id}" fams = _sw_families.setdefault(sid, {}) cand = { "vh": int(vh or 0), "hm": int(hm or 0), "hne": int(hne or 0), "max_seats": max_seats, } prev = fams.get(fkey) if prev is None or cand["vh"] > prev["vh"]: fams[fkey] = cand meta = _sw_meta.setdefault(sid, {"provider": None, "synced": None}) if meta["provider"] is None and provider is not None: meta["provider"] = provider if synced is not None and ( meta["synced"] is None or synced > meta["synced"] ): meta["synced"] = synced for sid, fams in _sw_families.items(): row = _blank(sid) meta = _sw_meta.get(sid, {}) row["provider"] = meta.get("provider") row["vendor_license_holders"] = sum(f["vh"] for f in fams.values()) row["licensed_seats"] = sum( _effective_seats(f["vh"], f["max_seats"]) for f in fams.values() ) row["under_licensed"] = any( f["max_seats"] is not None and f["vh"] > int(f["max_seats"]) for f in fams.values() ) row["holders_matched"] = sum(f["hm"] for f in fams.values()) row["holders_no_email"] = sum(f["hne"] for f in fams.values()) synced = meta.get("synced") row["synced_at"] = synced.isoformat() if synced else None row["sync_stale"], row["sync_stale_days"] = _seat_sync_staleness(synced) agg[sid] = row # Live open-counts per category — the snapshot counts can drift # between syncs (a leaver finally offboarded → row flipped to # resolved → row count drops while snapshot still says N), so the # overview table trusts the live count for the per-software badge. cat_rows = (await db.execute( sa_select( SoftwareExternalHolder.software_id, SoftwareExternalHolder.category, sa_func.count(), sa_func.min(SoftwareExternalHolder.provider), ).where(SoftwareExternalHolder.resolved == False) # noqa: E712 .group_by( SoftwareExternalHolder.software_id, SoftwareExternalHolder.category, ), )).all() # Map category → row key. _cat_to_key = { "external": "external_unmatched", "leaver_residual": "leaver_residual", "vendor_inactive": "vendor_inactive", "off_paid_site": "off_paid_site", } for sid, category, cnt, provider in cat_rows: if sid not in agg: agg[sid] = _blank(sid) agg[sid]["provider"] = provider key = _cat_to_key.get(category) if key is None: continue # unknown future category — skip rather than miscount agg[sid][key] = int(cnt or 0) # Subtract admin-classified "known/expected" accounts from each # software's anomaly badges (cross-vendor registry). Same two netted # categories as the detail endpoint — see the longer rationale # there; the overview table must not flag a documented keep that the # drawer nets out, or the row badge and the drawer disagree. from app.domains.asset.models import ( EXTERNAL_ACCOUNT_KNOWN_CLASSIFICATIONS as _KNOWN_O, ) from app.domains.asset.models import ( ExternalAccountRegistry as _RegO, ) known_rows = (await db.execute( sa_select( SoftwareExternalHolder.software_id, SoftwareExternalHolder.category, sa_func.count(), ) .join( _RegO, sa_func.lower(_RegO.email) == sa_func.lower(SoftwareExternalHolder.email), ) .where( SoftwareExternalHolder.category.in_( ("external", "leaver_residual"), ), SoftwareExternalHolder.resolved == False, # noqa: E712 _RegO.classification.in_(_KNOWN_O), ) .group_by( SoftwareExternalHolder.software_id, SoftwareExternalHolder.category, ), )).all() _badge_key = { "external": ("external_unmatched", "external_known"), "leaver_residual": ("leaver_residual", "leaver_residual_known"), } for sid, category, kcnt in known_rows: if sid not in agg: continue keys = _badge_key.get(category) if keys is None: continue badge_key, known_key = keys raw = int(agg[sid].get(badge_key, 0)) agg[sid][known_key] = int(kcnt or 0) agg[sid][badge_key] = max(0, raw - int(kcnt or 0)) if not agg: return [] seat_rows = (await db.execute( sa_select( SoftwareLicensePlan.software_id, sa_func.count(), sa_func.coalesce( sa_func.sum( case( ( SoftwareSeatAllocation.cancel_at_period_end.is_not( None, ), 1, ), else_=0, ), ), 0, ), ).join( SoftwareLicensePlan, SoftwareLicensePlan.id == SoftwareSeatAllocation.plan_id, ).where(SoftwareSeatAllocation.status == "active") .group_by(SoftwareLicensePlan.software_id), )).all() for sid, occ, sc in seat_rows: if sid in agg: agg[sid]["occupied_seats"] = int(occ or 0) agg[sid]["scheduled_cancel"] = int(sc or 0) cats = (await db.execute( sa_select( AssetSoftwareCatalog.id, AssetSoftwareCatalog.name, AssetSoftwareCatalog.status, ).where(AssetSoftwareCatalog.id.in_(list(agg.keys()))), )).all() names = {cid: (nm, st) for cid, nm, st in cats} for sid, row in agg.items(): nm, st = names.get(sid, (None, None)) row["software_name"] = nm row["software_status"] = st # Most operationally-urgent first: leaver_residual (a real leak) # outranks vendor_inactive (transitional) outranks off_paid_site # (informational); fall through to external_unmatched and tenant # size for the rest. return sorted( agg.values(), key=lambda r: ( -r["leaver_residual"], -r["vendor_inactive"], -r["off_paid_site"], -r["external_unmatched"], -r["vendor_license_holders"], ), ) async def _compute_seat_reconciliation_detail( db: AsyncSession, software_id: UUID, *, page: int, page_size: int, include_resolved: bool, category: str | None = None, ) -> dict: """Per-software reconciliation detail for the drawer: headline + per-plan snapshot rows + paginated external-holder list + the live scheduled-cancel ("下个月不占") seat list.""" from sqlalchemy import case from sqlalchemy import func as sa_func from sqlalchemy import select as sa_select from app.domains.asset.models import ( AssetSoftwareCatalog, SoftwareExternalHolder, SoftwareLicensePlan, SoftwareSeatAllocation, SoftwareSeatSyncStat, ) from app.identity.person_models import IdentityPerson software = (await db.execute( sa_select( AssetSoftwareCatalog.name, AssetSoftwareCatalog.status, ).where(AssetSoftwareCatalog.id == software_id), )).first() plan_stats = (await db.execute( sa_select( SoftwareSeatSyncStat, SoftwareLicensePlan.name, SoftwareLicensePlan.max_seats, ) .join( SoftwareLicensePlan, SoftwareLicensePlan.id == SoftwareSeatSyncStat.plan_id, ) .where(SoftwareSeatSyncStat.software_id == software_id) .order_by(SoftwareLicensePlan.name.asc()), )).all() from app.domains.asset.billing_service import _effective_seats per_plan: list[dict] = [] provider: str | None = None synced_at = None # ``vendor_license_holders`` (and the six buckets that sum to it) is a # family-keyed snapshot written once per plan. Two plans on this # software sharing a ``product_key`` carry the same family count, so # summing them inflates the drawer's headline. Dedup to one # representative row per ``product_key`` (max vendor count — keeps the # six-bucket identity intact since all buckets come from the SAME row) # and sum across distinct families. NULL product_key keys per plan so # distinct unkeyed plans still sum. ``licensed_seats`` clamps each # family's holder count to its contracted ceiling (max_seats); the # drawer shows licensed_seats as the headline and vendor_license_holders # as the raw "active access" the six buckets sum to. The per-plan rows # below are NOT deduped — the table intentionally shows every plan. _fam_rows: dict[str, dict] = {} for stat, plan_name, max_seats in plan_stats: per_plan.append({ "plan_id": str(stat.plan_id), "plan_name": plan_name, "provider": stat.provider, "product_key": stat.product_key, "vendor_license_holders": stat.vendor_license_holders, "max_seats": max_seats, "licensed_seats": _effective_seats( stat.vendor_license_holders, max_seats, ), "under_licensed": ( max_seats is not None and int(stat.vendor_license_holders or 0) > int(max_seats) ), "holders_matched": stat.holders_matched, "holders_external": stat.holders_external, "holders_no_email": stat.holders_no_email, # Six-bucket detail (since migration 219). "holders_leaver_residual": stat.holders_leaver_residual, "holders_vendor_inactive": stat.holders_vendor_inactive, "holders_off_paid_site": stat.holders_off_paid_site, "synced_at": stat.synced_at.isoformat() if stat.synced_at else None, }) fkey = ( stat.product_key if stat.product_key else f"__plan__:{stat.plan_id}" ) cand = { "vendor": stat.vendor_license_holders, "max_seats": max_seats, "matched": stat.holders_matched, "external": stat.holders_external, "no_email": stat.holders_no_email, "leaver_residual": stat.holders_leaver_residual, "vendor_inactive": stat.holders_vendor_inactive, "off_paid_site": stat.holders_off_paid_site, } prev = _fam_rows.get(fkey) if prev is None or cand["vendor"] > prev["vendor"]: _fam_rows[fkey] = cand provider = provider or stat.provider if stat.synced_at and (synced_at is None or stat.synced_at > synced_at): synced_at = stat.synced_at vendor_total = sum(f["vendor"] for f in _fam_rows.values()) licensed_total = sum( _effective_seats(f["vendor"], f["max_seats"]) for f in _fam_rows.values() ) under_licensed_any = any( f["max_seats"] is not None and int(f["vendor"] or 0) > int(f["max_seats"]) for f in _fam_rows.values() ) matched_total = sum(f["matched"] for f in _fam_rows.values()) external_total = sum(f["external"] for f in _fam_rows.values()) no_email_total = sum(f["no_email"] for f in _fam_rows.values()) leaver_residual_total = sum(f["leaver_residual"] for f in _fam_rows.values()) vendor_inactive_total = sum( f["vendor_inactive"] for f in _fam_rows.values() ) off_paid_site_total = sum(f["off_paid_site"] for f in _fam_rows.values()) # Live per-category open counts — the drawer's per-tab badge needs # this independent of the snapshot since a holder can be marked # ``resolved=True`` between syncs (e.g. when the leaver finally got # offboarded for real). open_by_category = dict((await db.execute( sa_select( SoftwareExternalHolder.category, sa_func.count(), ).where( SoftwareExternalHolder.software_id == software_id, SoftwareExternalHolder.resolved == False, # noqa: E712 ).group_by(SoftwareExternalHolder.category), )).all()) open_external_raw = int(open_by_category.get("external", 0)) open_leaver_residual = int(open_by_category.get("leaver_residual", 0)) open_vendor_inactive = int(open_by_category.get("vendor_inactive", 0)) open_off_paid_site = int(open_by_category.get("off_paid_site", 0)) # Subtract emails an admin classified as "known/expected" (service # accounts, shared mailboxes, legit guests, contractors) from the # anomaly counts — they still appear in the holder list, just no # longer flagged as needing attention. Cross-vendor: one # classification in external_account_registry suppresses the # email on EVERY vendor's panel. # # Applies to ``leaver_residual`` as well as ``external`` since # 2026-07-29. A KNOWN classification is the platform's canonical # "documented keep" marker — offboarding's own retention path # (``ensure_retained_classification``, e.g. a leaver kept because # they're in the Feishu CBG department) writes exactly this row so # #1983's leaver-assurance stops paging IT. Netting only ``external`` # meant the same documented keep turned into a red 「离职残留」 alert # the moment its handler learned to emit the leaver bucket — the # panel would contradict the guard that reads the same registry. from app.domains.asset.models import ( EXTERNAL_ACCOUNT_KNOWN_CLASSIFICATIONS as _KNOWN_CLS, ) from app.domains.asset.models import ( ExternalAccountRegistry as _Reg, ) _netted_categories = ("external", "leaver_residual") known_by_cat = dict((await db.execute( sa_select( SoftwareExternalHolder.category, sa_func.count(), ) .select_from(SoftwareExternalHolder) .join( _Reg, sa_func.lower(_Reg.email) == sa_func.lower(SoftwareExternalHolder.email), ) .where( SoftwareExternalHolder.software_id == software_id, SoftwareExternalHolder.category.in_(_netted_categories), SoftwareExternalHolder.resolved == False, # noqa: E712 _Reg.classification.in_(_KNOWN_CLS), ) .group_by(SoftwareExternalHolder.category) )).all()) external_known = int(known_by_cat.get("external", 0)) leaver_residual_known = int(known_by_cat.get("leaver_residual", 0)) open_external = max(0, open_external_raw - external_known) open_leaver_residual_raw = open_leaver_residual open_leaver_residual = max( 0, open_leaver_residual_raw - leaver_residual_known, ) # The equation's 「已知保留」 addend must cover every netted category # or the displayed identity stops balancing (it adds back exactly # what was subtracted). known_reserved_total = external_known + leaver_residual_known occ_row = (await db.execute( sa_select( sa_func.count(), sa_func.coalesce( sa_func.sum( case( ( SoftwareSeatAllocation.cancel_at_period_end.is_not( None, ), 1, ), else_=0, ), ), 0, ), ).join( SoftwareLicensePlan, SoftwareLicensePlan.id == SoftwareSeatAllocation.plan_id, ).where( SoftwareLicensePlan.software_id == software_id, SoftwareSeatAllocation.status == "active", ), )).first() occupied = int(occ_row[0] or 0) if occ_row else 0 scheduled_cancel_count = int(occ_row[1] or 0) if occ_row else 0 # External-holder list (paginated). Hidden-by-default resolved rows # sort FIRST when ``include_resolved`` — the operator just flipped # the switch to see them, and appending them behind N pages of open # rows made the switch read as a no-op (prod: 46 open rows at # page_size 20 buried 5 resolved rows at the tail of page 3 while # the toggle reset pagination back to page 1). holder_q = sa_select(SoftwareExternalHolder).where( SoftwareExternalHolder.software_id == software_id, ) # Server-side category filter so the paginated list matches the tab the # operator is on AND the badge counts (client-side filtering silently # broke pagination — a page of 20 mixed-category rows could show zero of # the tab's rows while the real ones sat on page 2). # ``anomalies`` — the "全部异常" tab: exactly the rows summed into the # anomaly badge = the four anomaly categories, minus external # accounts an admin classified as known/expected (netted out of the # count, so not an anomaly). Known/no-email rows stay visible under # their own tabs (外部账号 / 无邮箱), just not mixed in here. # a single category name — that category's rows only. # None — every row (legacy "show all" behaviour). anomaly_cats = ( "external", "leaver_residual", "vendor_inactive", "off_paid_site", ) category_filters: list[Any] = [] if category == "anomalies": _known_emails = sa_select(sa_func.lower(_Reg.email)).where( _Reg.classification.in_(_KNOWN_CLS), _Reg.email.is_not(None), ) category_filters = [ SoftwareExternalHolder.category.in_(anomaly_cats), # NULL-email rows must stay listed: lower(NULL) IN (…) is # SQL NULL and ~NULL is still NULL, which the OR would # silently drop while the badge keeps counting the row (the # exact badge≠list mismatch this filter exists to prevent). # No handler emits external+NULL email today — this keeps it # correct when one does. # # Category set mirrors ``_netted_categories`` above: both # ``external`` and ``leaver_residual`` net out KNOWN- # classified emails, so both must hide them here too. SoftwareExternalHolder.category.notin_(("external", "leaver_residual")) | SoftwareExternalHolder.email.is_(None) | ~sa_func.lower(SoftwareExternalHolder.email).in_(_known_emails), ] elif category in ( "external", "leaver_residual", "vendor_inactive", "off_paid_site", "no_email", ): category_filters = [SoftwareExternalHolder.category == category] if category_filters: holder_q = holder_q.where(*category_filters) # The "包含已解决 (N)" switch reveals resolved rows on the CURRENT # tab — count with the same category filter or the label overstates # what flipping it adds. resolved_count = int((await db.execute( sa_select(sa_func.count()).select_from(SoftwareExternalHolder).where( SoftwareExternalHolder.software_id == software_id, SoftwareExternalHolder.resolved == True, # noqa: E712 *category_filters, ), )).scalar() or 0) if not include_resolved: holder_q = holder_q.where( SoftwareExternalHolder.resolved == False, # noqa: E712 ) total_holders = (await db.execute( sa_select(sa_func.count()).select_from(holder_q.subquery()), )).scalar() or 0 holder_rows = (await db.execute( holder_q.order_by( SoftwareExternalHolder.resolved.desc() if include_resolved else SoftwareExternalHolder.resolved.asc(), SoftwareExternalHolder.last_observed_at.desc(), ).offset((page - 1) * page_size).limit(page_size), )).scalars().all() holders = [ { "id": str(h.id), "email": h.email, # Dedup/annotation key — == email for per-user vendors, # device serial for Jamf, ``feishu-user:`` for no-email # Feishu rows. The registry annotates no-email holders by # this key (they have no email). "holder_key": h.holder_key, "display_name": h.display_name, "provider": h.provider, "product_key": h.product_key, "site": h.site, "category": h.category, "person_id": str(h.person_id) if h.person_id else None, "vendor_account_id": h.vendor_account_id, "account_status": h.account_status, "last_active_on": ( h.last_active_on.isoformat() if h.last_active_on else None ), "first_observed_at": ( h.first_observed_at.isoformat() if h.first_observed_at else None ), "last_observed_at": ( h.last_observed_at.isoformat() if h.last_observed_at else None ), "resolved": h.resolved, "vendor_admin_url": _vendor_admin_url( h.provider, h.vendor_account_id, h.email, ), } for h in holder_rows ] # Annotate this page's holders with their cross-vendor registry # classification / note (read-time join; no sync handler touched). from app.domains.asset.external_account_service import ( ExternalAccountRegistryService, ) from app.domains.asset.models import ( EXTERNAL_ACCOUNT_KNOWN_CLASSIFICATIONS as _KNOWN_CLS_H, ) _reg_map = await ExternalAccountRegistryService( db, ).get_annotations_for_emails([ h["email"] or h["holder_key"] for h in holders if h["email"] or h["holder_key"] ]) for _h in holders: _a = _reg_map.get( (_h["email"] or _h["holder_key"] or "").strip().lower(), ) _h["registry_classification"] = _a.classification if _a else None _h["registry_note"] = _a.note if _a else None _h["registry_is_known"] = ( _a.classification in _KNOWN_CLS_H if _a else False ) # Scheduled-cancel ("下个月不占") seats — live. sc_rows = (await db.execute( sa_select( SoftwareSeatAllocation, SoftwareLicensePlan.name, IdentityPerson.display_name, IdentityPerson.work_email, ).join( SoftwareLicensePlan, SoftwareLicensePlan.id == SoftwareSeatAllocation.plan_id, ).join( IdentityPerson, IdentityPerson.id == SoftwareSeatAllocation.user_id, ).where( SoftwareLicensePlan.software_id == software_id, SoftwareSeatAllocation.status == "active", SoftwareSeatAllocation.cancel_at_period_end.is_not(None), ).order_by(SoftwareSeatAllocation.cancel_at_period_end.asc()), )).all() requester_ids = { seat.cancel_requested_by for seat, _pn, _dn, _we in sc_rows if seat.cancel_requested_by is not None } requester_names: dict[Any, str] = {} if requester_ids: rn = (await db.execute( sa_select(IdentityPerson.id, IdentityPerson.display_name) .where(IdentityPerson.id.in_(list(requester_ids))), )).all() requester_names = {rid: name for rid, name in rn} scheduled_cancels = [ { "allocation_id": str(seat.id), "plan_name": plan_name, "user_id": str(seat.user_id), "user_name": display_name, "user_email": work_email, "cancel_at_period_end": ( seat.cancel_at_period_end.isoformat() if seat.cancel_at_period_end else None ), "cancel_requested_at": ( seat.cancel_requested_at.isoformat() if seat.cancel_requested_at else None ), "cancel_requested_by_name": requester_names.get( seat.cancel_requested_by, ), "cancel_reason": seat.cancel_reason, } for seat, plan_name, display_name, work_email in sc_rows ] return { "software_id": str(software_id), "software_name": software[0] if software else None, "software_status": software[1] if software else None, "provider": provider, "synced_at": synced_at.isoformat() if synced_at else None, "sync_stale": _seat_sync_staleness(synced_at)[0], "sync_stale_days": _seat_sync_staleness(synced_at)[1], "summary": { # Raw "active access" total (all status, all sites) — the count # of accounts holding the product per the vendor API. The six # bucket counters below sum to THIS, not to licensed_seats. "vendor_license_holders": vendor_total, # Contracted/billable seats = vendor_license_holders clamped to # the purchased ceiling (max_seats). The workbench headline. For # healthy products == vendor_license_holders; lower when broad # non-licensing access inflates holders (Confluence: ~100 access # vs 10 contracted → licensed_seats=10, under_licensed=True). "licensed_seats": licensed_total, "under_licensed": under_licensed_any, "holders_matched": matched_total, "holders_no_email": no_email_total, # Live open-row count per row-persisted category — trumps # the snapshot counts because resolution can flip between # syncs (e.g. leaver got properly offboarded → no longer # holds a residual seat). Snapshot fields below keep the # "as of last sync" picture for trend analysis. "external_unmatched": open_external, # Raw external count before subtracting admin-classified # "known/expected" accounts, + how many were subtracted, so # the UI can show "外部未匹配 X(另已知保留 Y)". "external_unmatched_raw": open_external_raw, "external_known": external_known, "leaver_residual": open_leaver_residual, # Same raw/known/netted triple as external, so the drawer can # render "离职残留 0(另已知保留 6)" instead of a bare 0 that # hides six documented keeps. "leaver_residual_raw": open_leaver_residual_raw, "leaver_residual_known": leaver_residual_known, # Sum across every netted category — the equation's # 「已知保留」 addend. "known_reserved_total": known_reserved_total, "vendor_inactive": open_vendor_inactive, "off_paid_site": open_off_paid_site, "occupied_seats": occupied, "scheduled_cancel": scheduled_cancel_count, # Resolved (gone-from-vendor / matched) holder rows hidden # behind the include_resolved toggle — lets the UI label the # switch with what it would reveal. "resolved_count": resolved_count, # Snapshot-time per-bucket counts (frozen at last sync) so # the UI can show "as of " sums that match the # 6-bucket identity even when a holder's category / # resolution status changed since. # Deduped by product family (mirrors ``vendor_license_holders``) # so the snapshot six-bucket identity still holds for a # software carrying multiple plans of one ``product_key``. "snapshot_holders_external": int(external_total), "snapshot_holders_leaver_residual": leaver_residual_total, "snapshot_holders_vendor_inactive": vendor_inactive_total, "snapshot_holders_off_paid_site": off_paid_site_total, }, "per_plan": per_plan, "external_holders": { "items": holders, "total": int(total_holders), "page": page, "page_size": page_size, }, "scheduled_cancels": scheduled_cancels, }