from datetime import date, datetime from decimal import Decimal from typing import Any from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, field_validator from app.core.fixed_currency import SUPPORTED_FIXED_CURRENCIES from app.core.storage_uploads import sanitize_object_key_list from app.domains.asset.models import ASSET_STATUSES # --------------------------------------------------------------------------- # Shared constants # --------------------------------------------------------------------------- ALLOWED_CATEGORIES = [ "laptop", "desktop", "monitor", "printer", "server", "network_device", "accessory", "conference_display", ] # Derived from the ORM's canonical tuple (2026-08-07) so the request # validator and the write-time guard on ``AssetItem.status`` can never # disagree — two hand-maintained copies is how ``in_stock`` survived in # the data while being absent from this list. See # ``models.ASSET_STATUSES`` for what each status means and for the five # tokens retired in the same pass (maintenance / retired / faulty / # testing / legacy). ALLOWED_STATUSES = list(ASSET_STATUSES) ALLOWED_HOLDER_KINDS = ["personal", "office"] ALLOWED_DEVICE_STATUSES = ["normal", "faulty", "under_repair", "pending_repair"] ALLOWED_SOURCE_TYPES = ["purchased", "leased", "other"] ALLOWED_CURRENCIES = ["CNY", "USD", "EUR", "JPY", "SGD", "MYR", "THB"] ALLOWED_DISPOSAL_TYPES = [ "scrapped", "sold", "donated", "lost", "exchanged", "lease_returned", "other", ] ALLOWED_OPERATION_TYPES = [ "claim", "return", "borrow", "borrow_return", "transfer", "disposal", "repair", "repair_complete", "info_change", "intake", # Office-held mode (migration 183). "mark_held_by_leaver", "recover_from_leaver", "recycle_to_office", "release_from_office", ] ALLOWED_OPERATION_STATUSES = ["pending", "confirmed", "rejected", "cancelled"] ALLOWED_ORDER_TYPES = ["purchase", "lease"] ALLOWED_ORDER_STATUSES = [ "draft", "pending", "approved", "rejected", "completed", "cancelled", ] ALLOWED_REQUEST_TYPES = [ "claim", "self_claim", "self_borrow", "repair", "handover", "return", # Feishu-backed asset templates + api_key_request + software_seat # flow through the approval service, not this validator — but if a # future admin ever posts directly to POST /api/v1/assets/requests # with one of these types we should still accept. "laptop_replacement", "computer_borrow", "asset_return", "offboarding_buyback", "software_seat", "api_key_request", # Google self-service request types (no approval, direct-execute). "google_mail_group", "google_email_alias", "google_password_reset", # Multi-cloud account lifecycle — employee requests access to a # cloud scope. Approval path is *in-Helix* (scope owner clicks # approve in the owner console), not Feishu. See # app.domains.cmdb.lifecycle_service for the implementation. "cloud_access_request", ] ALLOWED_REQUEST_STATUSES = [ "pending", "approved", "rejected", "completed", "cancelled", ] def _validate_image_url_list(urls: list[str] | None) -> list[str] | None: """Defence-in-depth https validator for asset image URL lists. Antd ```` already renders ``javascript:`` inert in modern browsers, but accepting non-https here lets an admin accidentally save mixed-content URLs that fail when loaded over https in production. Cap individual URL length to 2048 chars. """ if urls is None: return urls cleaned: list[str] = [] for raw in urls: if not isinstance(raw, str): raise ValueError("image_urls entries must be strings") url = raw.strip() if not url: continue if len(url) > 2048: raise ValueError("image_urls entry exceeds 2048 chars") if not url.lower().startswith("https://"): raise ValueError("image_urls entries must be https:// URLs") cleaned.append(url) return cleaned # --------------------------------------------------------------------------- # Asset CRUD schemas (backward compatible) # --------------------------------------------------------------------------- class AssetCreateRequest(BaseModel): asset_tag: str = Field(..., max_length=64) asset_name: str | None = Field(None, max_length=128) category: str = Field(..., max_length=32) brand: str | None = Field(None, max_length=64) model: str | None = Field(None, max_length=128) brand_id: UUID | None = None model_id: UUID | None = None serial_number: str | None = Field(None, max_length=128) # Canonical stock status. Was ``available`` — the legacy twin — # which made a plain ``POST /assets`` land 「可用」 while the real # intake path (``/assets/intake``, which forces idle) landed 「闲置」. status: str = Field(default="idle", max_length=32) device_status: str = Field(default="normal", max_length=16) # Office-held mode (migration 183, 2026-05-19). When holder_kind= # 'office', the service auto-forces status to 'held_by_office' and # clears assigned_to; holder_office_id MUST be set. ``personal`` is # the default and matches all pre-183 behaviour. holder_kind: str = Field(default="personal", max_length=16) holder_office_id: UUID | None = None source_type: str | None = Field(None, max_length=16) order_number: str | None = Field(None, max_length=128) purchase_date: date | None = None purchase_cost: Decimal | None = Field(None, decimal_places=2, max_digits=10, ge=Decimal("0")) currency: str | None = Field(None, max_length=8) invoice_number: str | None = Field(None, max_length=128) warranty_until: date | None = None usage_limit_months: int | None = Field(None, ge=1, le=600) supplier_name: str | None = Field(None, max_length=128) supplier_contact: str | None = Field(None, max_length=128) supplier_phone: str | None = Field(None, max_length=64) maintenance_note: str | None = None location: str | None = Field(None, max_length=256) admin_id: UUID | None = None image_urls: list[str] | None = Field(None, max_length=20) custom_fields: dict[str, Any] | None = None epandian_id: int | None = None owner_company: str | None = Field(None, max_length=128) usage_company: str | None = Field(None, max_length=128) expected_return_date: date | None = None repair_count: int = Field(default=0, ge=0, le=10000) repair_total_cost: Decimal | None = Field(None, decimal_places=2, max_digits=10, ge=Decimal("0")) print_status: str | None = Field(None, max_length=16) rfid: str | None = Field(None, max_length=128) purchase_entity: str | None = Field(None, max_length=128) notes: str | None = Field(None, max_length=2000) @field_validator("category") @classmethod def validate_category(cls, v: str) -> str: if v not in ALLOWED_CATEGORIES: raise ValueError(f"Category must be one of: {', '.join(ALLOWED_CATEGORIES)}") return v @field_validator("status") @classmethod def validate_status(cls, v: str) -> str: if v not in ALLOWED_STATUSES: raise ValueError(f"Status must be one of: {', '.join(ALLOWED_STATUSES)}") return v @field_validator("device_status") @classmethod def validate_device_status(cls, v: str) -> str: if v not in ALLOWED_DEVICE_STATUSES: raise ValueError(f"Device status must be one of: {', '.join(ALLOWED_DEVICE_STATUSES)}") return v @field_validator("source_type") @classmethod def validate_source_type(cls, v: str | None) -> str | None: if v is not None and v not in ALLOWED_SOURCE_TYPES: raise ValueError(f"Source type must be one of: {', '.join(ALLOWED_SOURCE_TYPES)}") return v @field_validator("currency") @classmethod def validate_currency(cls, v: str | None) -> str | None: if v is not None and v not in ALLOWED_CURRENCIES: raise ValueError(f"Currency must be one of: {', '.join(ALLOWED_CURRENCIES)}") return v @field_validator("custom_fields") @classmethod def validate_custom_fields_size(cls, v: dict[str, Any] | None) -> dict[str, Any] | None: # JSON column DoS guard — without a cap a caller could push # a 1MB blob into custom_fields and bloat every list query. if v is not None: import json as _json if len(_json.dumps(v, ensure_ascii=False)) > 32_000: raise ValueError("custom_fields exceeds 32KB limit") return v @field_validator("image_urls") @classmethod def validate_image_urls(cls, v: list[str] | None) -> list[str] | None: return _validate_image_url_list(v) @field_validator("holder_kind") @classmethod def validate_holder_kind(cls, v: str) -> str: if v not in ALLOWED_HOLDER_KINDS: raise ValueError( f"holder_kind must be one of: {', '.join(ALLOWED_HOLDER_KINDS)}" ) return v class AssetUpdateRequest(BaseModel): asset_name: str | None = Field(None, max_length=128) category: str | None = Field(None, max_length=32) brand: str | None = Field(None, max_length=64) model: str | None = Field(None, max_length=128) brand_id: UUID | None = None model_id: UUID | None = None serial_number: str | None = Field(None, max_length=128) # ``status`` removed from the update payload (pre-launch hardening # 2026-05-04). Letting admin PATCH ``status`` directly let any # claimed asset be flipped to 'idle' (orphaning assigned_to → # ghost owner), 'disposed' (no AssetOperation row, broken audit # chain), or 'borrowed' (no borrowed_by tracking). Status now only # changes through the lifecycle endpoints (claim / return / borrow # / borrow_return / transfer / dispose). device_status: str | None = Field(None, max_length=16) source_type: str | None = Field(None, max_length=16) order_number: str | None = Field(None, max_length=128) purchase_date: date | None = None purchase_cost: Decimal | None = Field(None, decimal_places=2, max_digits=10, ge=Decimal("0")) currency: str | None = Field(None, max_length=8) invoice_number: str | None = Field(None, max_length=128) warranty_until: date | None = None usage_limit_months: int | None = Field(None, ge=1, le=600) supplier_name: str | None = Field(None, max_length=128) supplier_contact: str | None = Field(None, max_length=128) supplier_phone: str | None = Field(None, max_length=64) maintenance_note: str | None = None location: str | None = Field(None, max_length=256) admin_id: UUID | None = None image_urls: list[str] | None = Field(None, max_length=20) custom_fields: dict[str, Any] | None = None owner_company: str | None = Field(None, max_length=128) usage_company: str | None = Field(None, max_length=128) expected_return_date: date | None = None repair_count: int | None = Field(None, ge=0, le=10000) repair_total_cost: Decimal | None = Field(None, decimal_places=2, max_digits=10, ge=Decimal("0")) print_status: str | None = Field(None, max_length=16) rfid: str | None = Field(None, max_length=128) purchase_entity: str | None = Field(None, max_length=128) notes: str | None = Field(None, max_length=2000) @field_validator("custom_fields") @classmethod def validate_custom_fields_size(cls, v: dict[str, Any] | None) -> dict[str, Any] | None: if v is not None: import json as _json if len(_json.dumps(v, ensure_ascii=False)) > 32_000: raise ValueError("custom_fields exceeds 32KB limit") return v @field_validator("image_urls") @classmethod def validate_image_urls(cls, v: list[str] | None) -> list[str] | None: return _validate_image_url_list(v) @field_validator("category") @classmethod def validate_category(cls, v: str | None) -> str | None: if v is None: return v if v not in ALLOWED_CATEGORIES: raise ValueError(f"Category must be one of: {', '.join(ALLOWED_CATEGORIES)}") return v @field_validator("device_status") @classmethod def validate_device_status(cls, v: str | None) -> str | None: if v is None: return v if v not in ALLOWED_DEVICE_STATUSES: raise ValueError(f"Device status must be one of: {', '.join(ALLOWED_DEVICE_STATUSES)}") return v class AssetAssignRequest(BaseModel): user_id: UUID notes: str | None = Field(None, max_length=2000) class AssetTransferRequest(BaseModel): to_user_id: UUID notes: str | None = Field(None, max_length=2000) # --------------------------------------------------------------------------- # Asset intake (new) # --------------------------------------------------------------------------- class AssetIntakeRequest(BaseModel): # ``asset_tag`` is optional starting 2026-05-08 — when omitted, the # intake service auto-generates one via ``generate_asset_tag`` using # ``purchase_entity_id`` + ``category``. Admins may still supply an # explicit tag (e.g. for migrating legacy assets that already carry # an external label) and the service honours it as-is. asset_tag: str | None = Field(None, max_length=64) asset_name: str | None = Field(None, max_length=128) category: str = Field(..., max_length=32) # Office-held mode (migration 183). When ``holder_kind='office'`` # the intake row lands directly in ``status='held_by_office'`` with # ``holder_office_id`` set — bypasses the idle → claim path because # a meeting-room device or shared dock never had a personal claimant. holder_kind: str = Field(default="personal", max_length=16) holder_office_id: UUID | None = None # Brand / model: free-text mirrors of the lookup row. The intake # service auto-creates a lookup entry when only the text is sent # (combobox AutoComplete value), so the admin UI can mix curated # selections with on-the-fly typing without blocking on lookup # curation. ``brand_id`` / ``model_id`` come from a Select-from- # catalogue choice; ``brand`` / ``model`` come from free-text. brand: str | None = Field(None, max_length=64) model: str | None = Field(None, max_length=128) brand_id: UUID | None = None model_id: UUID | None = None serial_number: str | None = Field(None, max_length=128) source_type: str = Field(default="purchased", max_length=16) device_status: str = Field(default="normal", max_length=16) order_number: str | None = Field(None, max_length=128) purchase_date: date | None = None purchase_cost: Decimal | None = Field(None, decimal_places=2, max_digits=10, ge=Decimal("0")) currency: str | None = Field(None, max_length=8) invoice_number: str | None = Field(None, max_length=128) warranty_until: date | None = None usage_limit_months: int | None = Field(None, ge=1, le=600) supplier_name: str | None = Field(None, max_length=128) supplier_contact: str | None = Field(None, max_length=128) supplier_phone: str | None = Field(None, max_length=64) maintenance_note: str | None = None location: str | None = Field(None, max_length=256) admin_id: UUID | None = None # ``assigned_to`` removed from intake (pre-launch hardening # 2026-05-04). Intake forces status='idle' but the previous schema # also accepted ``assigned_to`` from the request, producing # contradictory rows (status='idle' AND assigned_to=user) that the # /my/inventory query happily showed while admin status filters # hid them. Intake now creates an idle asset; assignment goes # through the dedicated claim_asset endpoint. image_urls: list[str] | None = Field(None, max_length=20) custom_fields: dict[str, Any] | None = None epandian_id: int | None = None owner_company: str | None = Field(None, max_length=128) usage_company: str | None = Field(None, max_length=128) expected_return_date: date | None = None repair_count: int = Field(default=0, ge=0, le=10000) repair_total_cost: Decimal | None = Field(None, decimal_places=2, max_digits=10, ge=Decimal("0")) print_status: str | None = Field(None, max_length=16) rfid: str | None = Field(None, max_length=128) purchase_entity: str | None = Field(None, max_length=128) # ``purchase_entity_id`` (added 2026-05-08): optional pointer at an # ``asset_legal_entities`` row. When provided, the intake service # (a) resolves it to ``entity.name`` and writes that into the # free-text ``purchase_entity`` column (single source of truth via # the 公司主体信息 admin tab) and (b) uses ``entity.code`` as the # 2nd segment of the auto-generated asset tag. Required at the # service layer when ``asset_tag`` is omitted, otherwise tag # generation has no entity code to embed. purchase_entity_id: UUID | None = None notes: str | None = Field(None, max_length=2000) @field_validator("category") @classmethod def validate_category(cls, v: str) -> str: if v not in ALLOWED_CATEGORIES: raise ValueError(f"Category must be one of: {', '.join(ALLOWED_CATEGORIES)}") return v @field_validator("source_type") @classmethod def validate_source_type(cls, v: str) -> str: if v not in ALLOWED_SOURCE_TYPES: raise ValueError(f"Source type must be one of: {', '.join(ALLOWED_SOURCE_TYPES)}") return v @field_validator("device_status") @classmethod def validate_device_status(cls, v: str) -> str: if v not in ALLOWED_DEVICE_STATUSES: raise ValueError(f"Device status must be one of: {', '.join(ALLOWED_DEVICE_STATUSES)}") return v @field_validator("currency") @classmethod def validate_currency(cls, v: str | None) -> str | None: if v is not None and v not in ALLOWED_CURRENCIES: raise ValueError(f"Currency must be one of: {', '.join(ALLOWED_CURRENCIES)}") return v @field_validator("custom_fields") @classmethod def validate_custom_fields_size(cls, v: dict[str, Any] | None) -> dict[str, Any] | None: if v is not None: import json as _json if len(_json.dumps(v, ensure_ascii=False)) > 32_000: raise ValueError("custom_fields exceeds 32KB limit") return v @field_validator("image_urls") @classmethod def validate_image_urls(cls, v: list[str] | None) -> list[str] | None: return _validate_image_url_list(v) @field_validator("holder_kind") @classmethod def validate_holder_kind(cls, v: str) -> str: if v not in ALLOWED_HOLDER_KINDS: raise ValueError( f"holder_kind must be one of: {', '.join(ALLOWED_HOLDER_KINDS)}" ) return v # --------------------------------------------------------------------------- # Asset operation request schemas # --------------------------------------------------------------------------- def _reject_blank_location(v: str | None) -> str | None: """Shared ``location`` guard for the 出库 / 退库 request schemas. ``None`` stays ``None`` — see the contract note on ``AssetClaimRequest`` below: system callers legitimately have no operator-supplied location. A whitespace-only string is NOT the same thing: it can only come from a form post that dodged the UI rule, and would otherwise land as a placeholder value in ``asset_items.location``. """ if v is None: return v stripped = v.strip() if not stripped: raise ValueError("location must not be blank/whitespace when provided") return stripped class AssetClaimRequest(BaseModel): to_user_id: UUID claim_date: date | None = None # defaults to today in service usage_company: str | None = Field(None, max_length=128) usage_department: str | None = Field(None, max_length=128) # ``location`` (领用后位置) is REQUIRED FROM THE UI per user # direction 2026-08-27 — every 出库 / 退库 surface (领用 / 借出 / # 批量领用 / 批量借出 / 退库 / 变更持有 / 移动端退库) enforces a # Form rule. Same shape as ``AssetReturnRequest.location`` (the # 2026-05-29 direction for 退库): the schema field stays OPTIONAL # so system callers can construct the request without an operator: # * transfer-confirm auto-claim (``AssetService.confirm_operation``) # * MCP ``claim_asset`` tool (app/mcp/tools.py) # The validator rejects whitespace-only values so a hand-rolled # POST can't slip a placeholder into the column. location: str | None = Field(None, max_length=256) notes: str | None = Field(None, max_length=2000) @field_validator("location") @classmethod def validate_location_non_whitespace(cls, v: str | None) -> str | None: return _reject_blank_location(v) class AssetReturnRequest(BaseModel): return_date: date | None = None # defaults to today in service admin_id: UUID | None = None usage_company: str | None = Field(None, max_length=128) usage_department: str | None = Field(None, max_length=128) # ``location`` (退库后位置) is REQUIRED FROM THE UI per user # direction 2026-05-29 (extended to every 出库/退库 surface on # 2026-08-27) — the 退库 modal, ChangeHolderModal's 退回库存 # target and the mobile 退库 action all enforce it + auto-fill # from the selected admin's office. The schema field stays # OPTIONAL so system callers can construct ``AssetReturnRequest`` # without an operator-supplied location: # * Feishu approval auto-return (app/approval/service.py) # * MCP ``return_asset`` tool (app/mcp/tools.py) # * Offboarding T+0 force-return paths # The validator below rejects whitespace-only values so an # empty form post can't slip placeholder strings (the legacy # ``"国内"`` class of bug that migration 218's data backfill # cleans up) into the column. location: str | None = Field(None, max_length=256) notes: str | None = Field(None, max_length=2000) # Where the returned asset goes after the personal holder hands it # back (migration 183). ``idle`` (default) = back into available # stock; ``office`` = becomes office-held under ``target_office_id``. # When ``return_target='office'`` and ``target_office_id`` is None, # the service falls back to resolving the returner's home office # from ``identity_persons.work_location_name``. return_target: str | None = Field(None, max_length=16) target_office_id: UUID | None = None @field_validator("return_target") @classmethod def validate_return_target(cls, v: str | None) -> str | None: if v is None: return v if v not in ("idle", "office"): raise ValueError("return_target must be 'idle' or 'office'") return v @field_validator("location") @classmethod def validate_location_non_whitespace(cls, v: str | None) -> str | None: return _reject_blank_location(v) class AssetBorrowRequest(BaseModel): to_user_id: UUID borrow_date: date | None = None # defaults to today in service # Required (pre-launch hardening 2026-05-04). Without an expected # return date, borrowed assets used to sit in 'borrowed' state # forever — the daily ``cron.asset.borrow_overdue`` reaper has no # signal to escalate on. Front-end now exposes a date picker that # defaults to T+30 but requires the field; admins must set a date. expected_return_date: date usage_company: str | None = Field(None, max_length=128) usage_department: str | None = Field(None, max_length=128) # 借用后位置 — UI-required (2026-08-27), optional here for the # Feishu approval auto-borrow path. See ``AssetClaimRequest``. location: str | None = Field(None, max_length=256) notes: str | None = Field(None, max_length=2000) @field_validator('expected_return_date') @classmethod def must_be_future(cls, v: date) -> date: if v <= date.today(): raise ValueError('expected_return_date must be a future date') return v @field_validator("location") @classmethod def validate_location_non_whitespace(cls, v: str | None) -> str | None: return _reject_blank_location(v) class AssetBorrowReturnRequest(BaseModel): notes: str | None = Field(None, max_length=2000) class AssetBorrowExtensionRequest(BaseModel): """Extend (or, for admins, adjust) a borrowed asset's expected return date. No approval — the date is mutated immediately and the borrower, their lead, and (for employee self-service) the asset admins are notified. ``reason`` is required so the stakeholder notification can explain *why* the loan is being held longer. """ expected_return_date: date reason: str = Field(..., min_length=1, max_length=2000) @field_validator("expected_return_date") @classmethod def must_be_future(cls, v: date) -> date: if v <= date.today(): raise ValueError("expected_return_date must be a future date") return v class AssetChangeAssigneeRequest(BaseModel): to_user_id: UUID claim_date: date | None = None # defaults to today in service usage_company: str | None = Field(None, max_length=128) usage_department: str | None = Field(None, max_length=128) location: str | None = Field(None, max_length=256) notes: str | None = Field(None, max_length=2000) class AssetRepairRequest(BaseModel): notes: str | None = Field(None, max_length=2000) detail: dict[str, Any] | None = None class AssetRepairCompleteRequest(BaseModel): notes: str | None = Field(None, max_length=2000) detail: dict[str, Any] | None = None class AssetMarkHeldByLeaverRequest(BaseModel): reason: str = Field(..., min_length=1, max_length=2000) # Required only when the asset is currently idle / available (no # holder to attribute to). Ignored when the asset already has a # holder — service uses asset.assigned_to in that case. leaver_person_id: UUID | None = None class AssetRecoverFromLeaverRequest(BaseModel): notes: str | None = Field(None, max_length=2000) class AssetRecycleToOfficeRequest(BaseModel): """Recycle a (currently personal-held or idle) asset to a city office. Sets ``holder_kind='office'``, ``holder_office_id=office_id``, ``assigned_to=NULL`` and ``status='held_by_office'`` atomically. Used both manually by asset admins ("张三 离职了 / 调岗了, 这台备用机 放回上海办公室") and programmatically by the offboarding T+0 handler when ``hardware_disposition.action == 'recycle_to_office'``. """ office_id: UUID reason: str = Field(..., min_length=1, max_length=2000) class AssetBulkRecycleToOfficeRequest(BaseModel): """Bulk recycle for the asset list page batch-action. ``idempotency_key`` follows the same convention as bulk_claim / bulk_borrow (see ``_check_bulk_idempotency``) — submitting the same key twice returns the original result instead of double-applying. """ asset_ids: list[UUID] = Field(..., min_length=1, max_length=500) office_id: UUID reason: str = Field(..., min_length=1, max_length=2000) idempotency_key: str = Field(..., min_length=8, max_length=128) class AssetReleaseFromOfficeRequest(BaseModel): """Release an office-held asset back to idle stock. Inverse of ``recycle_to_office``. The asset becomes ``status='idle'`` with ``holder_kind='personal'`` and no holder references — ready to be claimed by an individual via the existing claim flow. """ notes: str | None = Field(None, max_length=2000) # Upper bound on disposal_photos. A disposal is photographed from a few # angles at most (回收单 + 机器 + 序列号), so 10 is generous headroom while # keeping the stored JSON trivially bounded. MAX_DISPOSAL_PHOTOS = 10 def sanitize_disposal_photo_keys(raw: list[str] | None) -> list[str] | None: """Normalise + bound-check disposal evidence object keys. Shared between this request schema and ``AssetService.dispose_asset`` so an internal caller that bypasses the schema (tests / scripted flows / a future in-process disposal path) still can't land garbage on ``asset_items.disposal_photos``. Raises ``ValueError``. """ return sanitize_object_key_list( raw, field="disposal_photos", max_items=MAX_DISPOSAL_PHOTOS, ) class AssetDisposeRequest(BaseModel): # Reject any field not declared here so a future schema drift # can't smuggle in an admin-only knob via the request body. model_config = ConfigDict(extra="forbid") disposal_type: str = Field(..., max_length=32) notes: str | None = Field(None, max_length=2000) # Cash recovered on disposal (sale / lease-return). 0 / omitted for # scrapped / lost / donated. Drives the disposal gain/loss = proceeds # − net book value (frozen at disposal). Non-negative. disposal_proceeds: Decimal | None = Field(None, ge=0, max_digits=12, decimal_places=2) # The cash receipt can be settled by a different legal entity from the # original purchase. Restrict it to the four company finance currencies. disposal_currency: str | None = Field(default=None, max_length=8) # S3 object keys for disposal evidence the admin uploaded through # ``/api/v1/uploads/presign-put`` with purpose # ``asset_disposal_evidence`` (回收单 / 付款截图 / 归还回执 / 销毁照片). # Optional for every disposal type — the disposal is the record, the # photo is the proof when there is one. disposal_photos: list[str] | None = Field( default=None, max_length=MAX_DISPOSAL_PHOTOS, ) @field_validator("disposal_currency") @classmethod def validate_disposal_currency(cls, value: str | None) -> str | None: if value is None: return None currency = value.upper() if currency not in SUPPORTED_FIXED_CURRENCIES: raise ValueError(f"Disposal currency must be one of: {', '.join(SUPPORTED_FIXED_CURRENCIES)}") return currency @field_validator("disposal_type") @classmethod def validate_disposal_type(cls, v: str) -> str: if v not in ALLOWED_DISPOSAL_TYPES: raise ValueError(f"Disposal type must be one of: {', '.join(ALLOWED_DISPOSAL_TYPES)}") return v @field_validator("disposal_photos") @classmethod def validate_disposal_photos(cls, v: list[str] | None) -> list[str] | None: return sanitize_disposal_photo_keys(v) class OperationConfirmRequest(BaseModel): action: str = Field(..., description="confirm or reject") notes: str | None = Field(None, max_length=2000) @field_validator("action") @classmethod def validate_action(cls, v: str) -> str: if v not in ("confirm", "reject"): raise ValueError("Action must be 'confirm' or 'reject'") return v # --------------------------------------------------------------------------- # Procurement order schemas # --------------------------------------------------------------------------- class ProcurementOrderCreateRequest(BaseModel): order_type: str = Field(..., max_length=16) department: str | None = Field(None, max_length=128) supplier_id: UUID | None = None supplier_name: str | None = Field(None, max_length=128) total_amount: Decimal | None = Field(None, decimal_places=2, max_digits=12) items: list[dict[str, Any]] | None = None notes: str | None = Field(None, max_length=2000) @field_validator("order_type") @classmethod def validate_order_type(cls, v: str) -> str: if v not in ALLOWED_ORDER_TYPES: raise ValueError(f"Order type must be one of: {', '.join(ALLOWED_ORDER_TYPES)}") return v class ProcurementOrderUpdateRequest(BaseModel): order_type: str | None = Field(None, max_length=16) status: str | None = Field(None, max_length=16) department: str | None = Field(None, max_length=128) supplier_id: UUID | None = None supplier_name: str | None = Field(None, max_length=128) total_amount: Decimal | None = Field(None, decimal_places=2, max_digits=12) items: list[dict[str, Any]] | None = None notes: str | None = Field(None, max_length=2000) @field_validator("order_type") @classmethod def validate_order_type(cls, v: str | None) -> str | None: if v is not None and v not in ALLOWED_ORDER_TYPES: raise ValueError(f"Order type must be one of: {', '.join(ALLOWED_ORDER_TYPES)}") return v @field_validator("status") @classmethod def validate_status(cls, v: str | None) -> str | None: if v is not None and v not in ALLOWED_ORDER_STATUSES: raise ValueError(f"Status must be one of: {', '.join(ALLOWED_ORDER_STATUSES)}") return v # --------------------------------------------------------------------------- # Employee request schemas # --------------------------------------------------------------------------- class EmployeeRequestCreateRequest(BaseModel): request_type: str = Field(..., max_length=32) asset_id: UUID | None = None category: str | None = Field(None, max_length=32) target_user_id: UUID | None = None reason: str | None = Field(None, max_length=2000) detail: dict[str, Any] | None = None @field_validator("request_type") @classmethod def validate_request_type(cls, v: str) -> str: if v not in ALLOWED_REQUEST_TYPES: raise ValueError(f"Request type must be one of: {', '.join(ALLOWED_REQUEST_TYPES)}") return v @field_validator("category") @classmethod def validate_category(cls, v: str | None) -> str | None: if v is not None and v not in ALLOWED_CATEGORIES: raise ValueError(f"Category must be one of: {', '.join(ALLOWED_CATEGORIES)}") return v class EmployeeRequestProcessRequest(BaseModel): action: str = Field(..., description="approve or reject") notes: str | None = Field(None, max_length=2000) @field_validator("action") @classmethod def validate_action(cls, v: str) -> str: if v not in ("approve", "reject"): raise ValueError("Action must be 'approve' or 'reject'") return v class SupplementUploadFile(BaseModel): """One supplemented file: token + display metadata. ``token`` is the approval-upload ``code``; ``name`` is the original filename; ``url`` is the signed Feishu CDN link the upload endpoint returned — it's what the approval-comment attachment renders from, but it EXPIRES (~14 h signature TTL observed in prod), so it's the hot-review-window link, not durable storage. ``object_key`` is the Helix S3 copy the upload endpoint persisted (purpose ``approval_supplement``) — the durable source the detail drawer presigns from long after the Feishu link died. ``size`` is the file size in bytes (the comment schema's ``fileSize``). """ token: str = Field(..., min_length=1, max_length=256) name: str | None = Field(None, max_length=255) url: str | None = Field(None, max_length=2048) object_key: str | None = Field(None, max_length=512) size: int | None = Field(None, ge=0) model_config = ConfigDict(extra="forbid") class EmployeeRequestSupplementUploadsRequest(BaseModel): """Requester (or admin) adds attachments to an in-flight approval. Used for upload widgets that were left empty at submission time — e.g. laptop_replacement 回购流程 的 ``转账成功截图`` / ``上传恢复出厂设置视频``. When the flow is parked on the requester's own 提交人本人 node (upload widget marked editable in the template) the files are written into the form widget itself via ``tasks/approve`` + ``form`` and the node completes; otherwise they ride the approval-instance comment channel so the approver sees them in the approval timeline. ``uploads`` maps Feishu widget_id → list of uploaded files. Each entry is either a bare token string (legacy callers) or a :class:`SupplementUploadFile` carrying the original filename and the viewable Feishu URL the upload endpoint returned — both get persisted in the supplement history so the file can actually be OPENED later. Comment-delivered files never appear in ``latest_instance_detail.form`` where the form widgets' signed URLs come from; the supplement history is the only place their viewable URLs survive. """ uploads: dict[str, list[SupplementUploadFile | str]] = Field( default_factory=dict, ) note: str | None = Field(None, max_length=1000) model_config = ConfigDict(extra="forbid") @field_validator("uploads") @classmethod def _bound_uploads( cls, v: dict[str, list[SupplementUploadFile | str]], ) -> dict[str, list[SupplementUploadFile | str]]: if not v: raise ValueError("uploads must include at least one widget") if len(v) > 16: raise ValueError(f"uploads may target at most 16 widgets; got {len(v)}") for widget_id, entries in v.items(): if not isinstance(entries, list) or not entries: raise ValueError( f"uploads[{widget_id!r}] must be a non-empty list of files", ) if len(entries) > 32: raise ValueError( f"uploads[{widget_id!r}] has {len(entries)} files (max 32)", ) for entry in entries: tok = entry if isinstance(entry, str) else entry.token if not isinstance(tok, str) or not tok.strip() or len(tok) > 256: raise ValueError( f"uploads[{widget_id!r}] contains an invalid token", ) return v # --------------------------------------------------------------------------- # Response schemas # --------------------------------------------------------------------------- class AssetHistoryResponse(BaseModel): id: UUID action: str from_user: UUID | None to_user: UUID | None operator_id: UUID | None operator_name: str | None = None notes: str | None created_at: datetime model_config = {"from_attributes": True} class AssetOperationResponse(BaseModel): id: UUID operation_number: str asset_id: UUID # Denormalised asset labels — same bug pattern as the operator/from/to # name+email fields below: the ORM has no ``asset_tag`` / ``asset_name`` # columns on ``AssetOperation``, and until 2026-04-24 the response never # populated them, so 操作记录 page rendered 资产编码 = "-" on every row # despite the FK being populated. ``serialize_operations`` batch-loads # the referenced ``AssetItem`` rows and fills these in. asset_tag: str | None = None asset_name: str | None = None operation_type: str status: str from_user_id: UUID | None = None to_user_id: UUID | None = None operator_id: UUID # 2026-04-24 bug fix: these name + email fields never populated # because ``AssetOperation`` has no ORM relationship to identity_persons # (only FK columns). ``model_validate`` would just leave them as None, # so every row in /admin/assets/operations rendered operator = "-". # The router now uses ``serialize_operations`` (service.py) which # batch-loads the referenced people and fills these fields manually. # Kept as ``str | None`` so single-row callers that forget to enrich # still serialize, they just show the empty name — the UI already # gracefully renders "-" in that case. operator_name: str | None = None operator_email: str | None = None # 工号 + 雇佣状态 per person reference (2026-06-18): the 操作记录 used # to render a bare display name (or, for legacy 易盘点 imports whose # display_name == the 工号, just "P0343"), which is useless once the # person has left. The frontend PersonTag now strikes through + tags # 已离职 and surfaces "姓名(工号)" — but only if the serialiser hands it # the employee_no + employment_status alongside the name/email. operator_employee_no: str | None = None operator_employment_status: str | None = None from_user_name: str | None = None from_user_email: str | None = None from_user_employee_no: str | None = None from_user_employment_status: str | None = None to_user_name: str | None = None to_user_email: str | None = None to_user_employee_no: str | None = None to_user_employment_status: str | None = None notes: str | None = Field(None, max_length=2000) detail: dict[str, Any] | None = None created_at: datetime confirmed_at: datetime | None = None confirmed_by: UUID | None = None model_config = {"from_attributes": True} class AssetResponse(BaseModel): id: UUID asset_tag: str asset_name: str | None = None category: str brand: str | None model: str | None brand_id: UUID | None = None model_id: UUID | None = None serial_number: str | None status: str device_status: str | None = None source_type: str | None = None order_number: str | None = None purchase_date: date | None purchase_cost: Decimal | None # Provenance of ``purchase_cost`` (migration 604). ``estimated_model`` # means the amount is a same-model median, not a recorded figure — # every read surface labels it so an estimate is never mistaken for # an invoice amount. purchase_cost_source: str = "recorded" currency: str | None = None invoice_number: str | None = None warranty_until: date | None usage_limit_months: int | None = None supplier_name: str | None = None location: str | None = None # Office resolved for display from the asset's managed location. # Informative for every asset, including employee-held ones. resolved_office_name: str | None = None resolved_office_city: str | None = None department: str | None = None admin_id: UUID | None = None assigned_to: UUID | None assigned_to_name: str | None = None assigned_to_email: str | None = None # 工号 + 雇佣状态 so the UI can flag a departed assignee (held_by_leaver # and the broader "this owner has left" case) with 姓名(工号) + 已离职. assigned_to_employee_no: str | None = None assigned_to_employment_status: str | None = None assigned_at: datetime | None # Round 2b audit fix-up 2026-05-04: surface ``borrowed_by`` so the # frontend can render "On loan to X" for borrowed assets. Pre-fix, # the response only carried ``assigned_to`` (the original owner) # and there was no API-level signal for who currently has the # asset on loan. ``borrowed_by_*`` mirrors the ``assigned_to_*`` # enrichment shape. borrowed_by: UUID | None = None borrowed_by_name: str | None = None borrowed_by_email: str | None = None borrowed_by_employee_no: str | None = None borrowed_by_employment_status: str | None = None borrow_started_at: datetime | None = None # Office-held mode (migration 183, 2026-05-19). Surfaced so the list # / detail UI can render "🏢 上海办公室 (Jenny Xiao)" instead of just # "未分配", and so MCP tools can avoid suggesting "transfer this # office asset to a person" without first releasing it. holder_kind: str = "personal" holder_office_id: UUID | None = None holder_office_name: str | None = None holder_office_city: str | None = None disposal_type: str | None = None disposed_at: datetime | None = None # S3 object keys, NOT URLs — the client resolves each to a # short-lived presigned GET via ``/api/v1/uploads/presign-get``, # which re-checks the caller's asset-read role. disposal_photos: list[str] | None = None image_urls: list[str] | None = None custom_fields: dict[str, Any] | None = None epandian_id: int | None = None owner_company: str | None = None usage_company: str | None = None expected_return_date: date | None = None repair_count: int = 0 repair_total_cost: Decimal | None = None print_status: str | None = None rfid: str | None = None purchase_entity: str | None = None user_entity: str | None = None notes: str | None # Asset inventory denormalisation (migration 085) — surfaces the # most recent confirmed/issue response so the list page can render # a "最近盘点" column + filter without joining to inventory tables. last_inventory_at: datetime | None = None last_inventory_cycle_id: UUID | None = None last_inventory_status: str | None = None created_at: datetime updated_at: datetime model_config = {"from_attributes": True} class AssetDetailResponse(AssetResponse): history: list[AssetHistoryResponse] = [] operations: list[AssetOperationResponse] = [] class AssetListResponse(BaseModel): id: UUID asset_tag: str asset_name: str | None = None category: str brand: str | None model: str | None brand_id: UUID | None = None model_id: UUID | None = None serial_number: str | None = None status: str device_status: str | None = None source_type: str | None = None assigned_to: UUID | None = None assigned_to_name: str | None = None assigned_to_email: str | None = None assigned_to_employee_no: str | None = None assigned_to_employment_status: str | None = None # Round 2b audit fix-up 2026-05-04: list response also carries # the borrower so the asset table can show "Borrowed by X" badges. borrowed_by: UUID | None = None borrowed_by_name: str | None = None borrowed_by_email: str | None = None borrowed_by_employee_no: str | None = None borrowed_by_employment_status: str | None = None borrow_started_at: datetime | None = None department: str | None = None purchase_date: date | None = None purchase_cost: Decimal | None = None currency: str | None = None warranty_until: date | None = None location: str | None = None owner_company: str | None = None usage_company: str | None = None assigned_at: datetime | None = None purchase_entity: str | None = None expected_return_date: date | None = None admin_id: UUID | None = None print_status: str | None = None last_inventory_at: datetime | None = None last_inventory_cycle_id: UUID | None = None last_inventory_status: str | None = None created_at: datetime updated_at: datetime deleted_at: datetime | None = None model_config = {"from_attributes": True} class AssetStatsResponse(BaseModel): total_assets: int by_status: dict[str, int] by_category: dict[str, int] by_brand: dict[str, int] = {} by_location: dict[str, int] = {} by_usage_company: dict[str, int] = {} by_model: dict[str, int] = {} # Geographic distribution for the 资产分布 map. ``by_country`` keys on the # ISO alpha-2 code (resolved from the holding office's region, else the # assigned person's country); ``by_china_city`` keys on the raw city name # for assets resolved to China. ``by_country['CN']`` == sum(by_china_city). by_country: dict[str, int] = {} by_china_city: dict[str, int] = {} total_value: Decimal assigned_count: int available_count: int warranty_expiring_soon: int # Next 30 days recently_added: int # Last 30 days class ProcurementOrderResponse(BaseModel): id: UUID order_number: str order_type: str status: str applicant_id: UUID supplier_id: UUID | None = None department: str | None supplier_name: str | None total_amount: Decimal | None items: list[dict[str, Any]] | None notes: str | None requested_at: datetime | None approved_at: datetime | None approved_by: UUID | None completed_at: datetime | None created_at: datetime updated_at: datetime model_config = {"from_attributes": True} class EmployeeRequestResponse(BaseModel): id: UUID request_number: str request_type: str status: str requester_id: UUID asset_id: UUID | None category: str | None target_user_id: UUID | None reason: str | None detail: dict[str, Any] | None created_at: datetime processed_at: datetime | None processed_by: UUID | None notes: str | None model_config = {"from_attributes": True} # --------------------------------------------------------------------------- # Asset settings schemas (locations, categories) # --------------------------------------------------------------------------- class LocationAdminEntry(BaseModel): """One row in a location's asset-administrator list.""" person_id: UUID person_name: str | None = None work_email: str | None = None is_primary: bool = False class AssetLocationResponse(BaseModel): id: UUID code: str name: str sort_order: int is_active: bool created_at: datetime updated_at: datetime # Present only when the caller asked for ``with_admins=true`` — the # plain dictionary reads (intake, list, detail) would otherwise pay # for a join they never look at. admins: list[LocationAdminEntry] | None = None model_config = {"from_attributes": True} class AssetLocationCreateRequest(BaseModel): code: str = Field(..., max_length=16) name: str = Field(..., max_length=128) sort_order: int = Field(default=0) # Administrators may be set at creation so a new location is never # published in an uncovered state by accident. admin_person_ids: list[UUID] | None = None primary_person_id: UUID | None = None class AssetLocationUpdateRequest(BaseModel): code: str | None = Field(None, max_length=16) name: str | None = Field(None, max_length=128) sort_order: int | None = None is_active: bool | None = None class AssetLocationAdminsRequest(BaseModel): """Full replacement of a location's asset-administrator list. Replacement, not a patch: the caller sends the list it wants to exist. An empty list clears every administrator — the endpoint is the ``asset_admin`` grant surface, so "who should be here" is the only question with an unambiguous answer. ``primary_person_id`` must be one of ``admin_person_ids``; when omitted the first entry becomes primary. """ admin_person_ids: list[UUID] = Field(default_factory=list) primary_person_id: UUID | None = None class AssetCategoryConfigResponse(BaseModel): id: UUID code: str asset_category: str | None name: str parent_code: str | None usage_limit_months: int | None unit: str | None notes: str | None sort_order: int is_active: bool created_at: datetime updated_at: datetime model_config = {"from_attributes": True} class AssetCategoryConfigCreateRequest(BaseModel): code: str = Field(..., min_length=1, max_length=16) name: str = Field(..., max_length=128) parent_code: str | None = Field(None, max_length=16) usage_limit_months: int | None = None unit: str | None = Field(None, max_length=32) notes: str | None = Field(None, max_length=2000) sort_order: int = Field(default=0) class AssetCategoryConfigUpdateRequest(BaseModel): code: str | None = Field(None, min_length=1, max_length=16) name: str | None = Field(None, max_length=128) parent_code: str | None = Field(None, max_length=16) usage_limit_months: int | None = None unit: str | None = Field(None, max_length=32) notes: str | None = Field(None, max_length=2000) sort_order: int | None = None is_active: bool | None = None class GenerateAssetTagRequest(BaseModel): """Body for ``POST /assets/generate-tag``. Format: ``IT-{purchase_entity.code}-{code}-{YYYYMMDDHHMMSS}`` where ``code`` is resolved from the ``asset_category_configs`` row whose ``asset_category`` matches ``category`` (admin-managed via 分类管理 — e.g. ``desktop`` → ``DT``). Example: ``IT-CN-NB-20260508151325``. """ purchase_entity_id: UUID category: str = Field(..., max_length=32) # --------------------------------------------------------------------------- # Brand / model lookup schemas # --------------------------------------------------------------------------- class AssetBrandResponse(BaseModel): id: UUID name: str display_name: str | None = None notes: str | None = Field(None, max_length=2000) sort_order: int is_active: bool auto_created: bool created_at: datetime updated_at: datetime # Joined-in by the list endpoint so the settings UI can render # "Apple — 4 models" without a per-row count query. model_count: int | None = None model_config = {"from_attributes": True} class AssetBrandCreateRequest(BaseModel): name: str = Field(..., min_length=1, max_length=64) display_name: str | None = Field(None, max_length=128) notes: str | None = Field(None, max_length=2000) sort_order: int = 0 class AssetBrandUpdateRequest(BaseModel): name: str | None = Field(None, min_length=1, max_length=64) display_name: str | None = Field(None, max_length=128) notes: str | None = Field(None, max_length=2000) sort_order: int | None = None is_active: bool | None = None class AssetModelResponse(BaseModel): id: UUID brand_id: UUID brand_name: str | None = None name: str notes: str | None = Field(None, max_length=2000) sort_order: int is_active: bool auto_created: bool created_at: datetime updated_at: datetime model_config = {"from_attributes": True} class AssetModelCreateRequest(BaseModel): brand_id: UUID name: str = Field(..., min_length=1, max_length=128) notes: str | None = Field(None, max_length=2000) sort_order: int = 0 class AssetModelUpdateRequest(BaseModel): brand_id: UUID | None = None name: str | None = Field(None, min_length=1, max_length=128) notes: str | None = Field(None, max_length=2000) sort_order: int | None = None is_active: bool | None = None # --------------------------------------------------------------------------- # Bulk asset mutation schemas # --------------------------------------------------------------------------- class BulkAssetRowResult(BaseModel): row_number: int asset_tag: str | None = None asset_id: UUID | None = None status: str message: str | None = None operation_number: str | None = None class BulkAssetMutationResponse(BaseModel): total_rows: int successful_rows: int failed_rows: int results: list[BulkAssetRowResult] class BulkClaimRequest(BaseModel): asset_ids: list[UUID] = Field(..., min_length=1, max_length=100) to_user_id: UUID claim_date: date | None = None usage_company: str | None = Field(None, max_length=128) usage_department: str | None = Field(None, max_length=128) # UI-required (2026-08-27) — see ``AssetClaimRequest.location``. location: str | None = Field(None, max_length=256) notes: str | None = Field(None, max_length=2000) idempotency_key: str | None = Field(None, max_length=64) @field_validator("location") @classmethod def validate_location_non_whitespace(cls, v: str | None) -> str | None: return _reject_blank_location(v) class BulkBorrowRequest(BaseModel): asset_ids: list[UUID] = Field(..., min_length=1, max_length=100) to_user_id: UUID borrow_date: date | None = None expected_return_date: date | None = None usage_company: str | None = Field(None, max_length=128) usage_department: str | None = Field(None, max_length=128) # UI-required (2026-08-27) — see ``AssetClaimRequest.location``. location: str | None = Field(None, max_length=256) notes: str | None = Field(None, max_length=2000) idempotency_key: str | None = Field(None, max_length=64) @field_validator('expected_return_date') @classmethod def must_be_future(cls, v: date | None) -> date | None: if v is not None and v <= date.today(): raise ValueError('expected_return_date must be a future date') return v @field_validator("location") @classmethod def validate_location_non_whitespace(cls, v: str | None) -> str | None: return _reject_blank_location(v) class BulkRestoreRequest(BaseModel): """Body for ``POST /assets/bulk-restore`` (recycle-bin batch restore). ``idempotency_key`` is REQUIRED (unlike bulk-claim/borrow where it is opt-in) — the endpoint replays the cached response on key reuse via ``app.core.idempotency.IdempotencyService``, so a double-click / retry never restores twice. ``asset_ids`` cap of 500 follows the platform batch-endpoint convention. """ asset_ids: list[UUID] = Field(..., min_length=1, max_length=500) idempotency_key: str = Field(..., min_length=1, max_length=64) # --------------------------------------------------------------------------- # Software catalog schemas # --------------------------------------------------------------------------- class SoftwareCatalogCreateRequest(BaseModel): name: str = Field(..., max_length=128) asset_type: str | None = Field(None, max_length=64) owner: str | None = Field(None, max_length=64) owner_person_id: UUID | None = None it_contact: str | None = Field(None, max_length=64) it_contact_person_id: UUID | None = None status: str = Field(default="active", max_length=32) remark: str | None = None # 「详细介绍」— long-form write-up (MEDIUMTEXT column). 20k chars is a # generous ceiling (~10 pages) that still fits an xlsx export cell. description: str | None = Field(None, max_length=20000) version: str | None = Field(None, max_length=64) department: str | None = Field(None, max_length=128) login_url: str | None = Field(None, max_length=512) expiry_date: date | None = None license_info: str | None = None attachment: str | None = Field(None, max_length=256) # Offboarding behaviour — defaults to sso_cascade (the safest / # zero-effort choice). Switch to auto_revoke once a connector is # configured for this software. offboarding_policy: str = Field(default="sso_cascade", max_length=32) offboarding_config: dict | None = None # May the seat holder self-cancel? Default True (normal self-service). # Set False for IT-managed identity software (Google / Feishu) so the # employee can't drop their own seat; admins can still cancel for them. allow_self_cancel: bool = True # Software category — drives both the catalog UX (filterable tag) # and the AI Spending dashboard (which filters on category='ai'). # Recognised values: ai / im / mail / cloud / dev / office / # security / other. Free-form so admins can invent new buckets. category: str = Field(default="other", max_length=32) class SoftwareCatalogUpdateRequest(BaseModel): name: str | None = Field(None, max_length=128) asset_type: str | None = Field(None, max_length=64) owner: str | None = Field(None, max_length=64) owner_person_id: UUID | None = None it_contact: str | None = Field(None, max_length=64) it_contact_person_id: UUID | None = None status: str | None = Field(None, max_length=32) # ``remark`` and ``license_info`` are TEXT columns. Without a # max_length they would silently accept up to 64KB on MySQL TEXT # / arbitrary on TiDB — bloating audit_logs.detail when the patch # is summarised, and giving an LLM client a write-amp surface. # 4000 matches the announcement / approval-reason caps. remark: str | None = Field(None, max_length=4000) # 「详细介绍」— long-form, so a far higher cap than remark/license_info. # MEDIUMTEXT column; 20k chars still fits an xlsx export cell (32767). description: str | None = Field(None, max_length=20000) version: str | None = Field(None, max_length=64) department: str | None = Field(None, max_length=128) login_url: str | None = Field(None, max_length=512) expiry_date: date | None = None license_info: str | None = Field(None, max_length=4000) attachment: str | None = Field(None, max_length=256) offboarding_policy: str | None = Field(None, max_length=32) offboarding_config: dict | None = None allow_self_cancel: bool | None = None category: str | None = Field(None, max_length=32) class SoftwareCatalogResponse(BaseModel): id: UUID name: str asset_type: str | None = None owner: str | None = None owner_person_id: UUID | None = None owner_person_name: str | None = None owner_person_email: str | None = None owner_person_employee_no: str | None = None owner_person_employment_status: str | None = None it_contact: str | None = None it_contact_person_id: UUID | None = None it_contact_person_name: str | None = None it_contact_person_email: str | None = None it_contact_person_employee_no: str | None = None it_contact_person_employment_status: str | None = None status: str remark: str | None = None description: str | None = None version: str | None = None department: str | None = None login_url: str | None = None expiry_date: date | None = None license_info: str | None = None attachment: str | None = None offboarding_policy: str = "sso_cascade" offboarding_config: dict | None = None allow_self_cancel: bool = True category: str = "other" created_at: datetime updated_at: datetime model_config = {"from_attributes": True} # --------------------------------------------------------------------------- # Software license plan schemas # --------------------------------------------------------------------------- # ISO 4217 codes Helix actually expects to see in production. Adding # a code here is intentional — keeps a paste of "JPY" or "EUR" from # accidentally onboarding an unsupported currency that the billing # UI / FX-conversion layer hasn't been validated against. Add as # new vendors land. (Round 4 logic-scan P0.) _ALLOWED_CURRENCIES: frozenset[str] = frozenset({"CNY", "USD", "HKD"}) def _validate_currency(value: str | None) -> str | None: if value is None: return None code = value.strip().upper() if not code: return None if code not in _ALLOWED_CURRENCIES: raise ValueError( f"unsupported currency {value!r}; allowed: " f"{sorted(_ALLOWED_CURRENCIES)}", ) return code class LicensePlanCreateRequest(BaseModel): software_id: UUID | None = None name: str = Field(..., max_length=128) price: Decimal = Field(..., ge=0, max_digits=12, decimal_places=2) billing_cycle: str = Field(..., pattern=r"^(monthly|yearly|one_time)$") description: str | None = None status: str = Field(default="active", max_length=16) # Seat quota (added migration 039). NULL = unlimited. Enforced # by the provisioning coordinator at seat-request time. max_seats: int | None = Field(default=None, ge=0) over_quota_policy: str = Field( default="reject", pattern=r"^(reject|evict_lru)$", ) # ISO 4217 currency for ``price``. Defaults to CNY to preserve # historical aggregation behaviour for plans created before the # field existed. Cross-currency aggregation is refused at the # billing service layer; same-currency tier-changes are required # by the change-plan endpoint. (Round 4 logic-scan P0.) currency: str = Field(default="CNY", max_length=3) @field_validator("currency", mode="before") @classmethod def _coerce_currency(cls, v: str | None) -> str: return _validate_currency(v) or "CNY" class LicensePlanUpdateRequest(BaseModel): name: str | None = Field(None, max_length=128) price: Decimal | None = Field(None, ge=0, max_digits=12, decimal_places=2) billing_cycle: str | None = Field(None, pattern=r"^(monthly|yearly|one_time)$") description: str | None = None status: str | None = Field(None, max_length=16) max_seats: int | None = Field(default=None, ge=0) over_quota_policy: str | None = Field( default=None, pattern=r"^(reject|evict_lru)$", ) currency: str | None = Field(default=None, max_length=3) @field_validator("currency", mode="before") @classmethod def _coerce_currency(cls, v: str | None) -> str | None: return _validate_currency(v) class LicensePlanResponse(BaseModel): id: UUID software_id: UUID software_name: str | None = None name: str # Optional because the list endpoints redact ``price -> None`` for # non-finance / non-admin / non-auditor / non-management callers (see # ``list_license_plans`` + ``list_all_license_plans``). Before this # was ``Decimal`` (non-Optional) the redaction silently 500'd via # Pydantic ValidationError on every regular employee opening the # seat-request modal — fix shipped together with the redaction. price: Decimal | None = None billing_cycle: str description: str | None = None status: str max_seats: int | None = None over_quota_policy: str = "reject" currency: str = "CNY" # Populated by the list endpoint via a batch COUNT query so the # UI can render "X / Y" seat usage without N+1 lookups. active_seat_count: int | None = None created_at: datetime updated_at: datetime model_config = {"from_attributes": True} # --------------------------------------------------------------------------- # Software one-time add-on (增值项) schemas # --------------------------------------------------------------------------- class AddonItemCreateRequest(BaseModel): """Admin defines a one-time add-on item on a software (AI credit pack, vendor person-day, …). ``unit_price`` is per unit; the employee picks a quantity within the [min, max] step envelope and pays unit_price × qty.""" name: str = Field(..., max_length=128) unit_label: str = Field(..., max_length=64) unit_price: Decimal = Field(..., ge=0, max_digits=12, decimal_places=2) currency: str = Field(default="CNY", max_length=3) min_quantity: int = Field(default=1, ge=1) step: int = Field(default=1, ge=1) max_quantity: int | None = Field(default=None, ge=1) description: str | None = Field(default=None, max_length=2000) status: str = Field(default="active", pattern=r"^(active|inactive)$") @field_validator("currency", mode="before") @classmethod def _coerce_currency(cls, v: str | None) -> str: return _validate_currency(v) or "CNY" class AddonItemUpdateRequest(BaseModel): name: str | None = Field(None, max_length=128) unit_label: str | None = Field(None, max_length=64) unit_price: Decimal | None = Field(None, ge=0, max_digits=12, decimal_places=2) currency: str | None = Field(None, max_length=3) min_quantity: int | None = Field(None, ge=1) step: int | None = Field(None, ge=1) max_quantity: int | None = Field(None, ge=1) description: str | None = Field(None, max_length=2000) status: str | None = Field(None, pattern=r"^(active|inactive)$") @field_validator("currency", mode="before") @classmethod def _coerce_currency(cls, v: str | None) -> str | None: return _validate_currency(v) class AddonItemResponse(BaseModel): id: UUID software_id: UUID software_name: str | None = None name: str unit_label: str # Optional so list endpoints can redact price for non-privileged # callers, mirroring LicensePlanResponse.price. unit_price: Decimal | None = None currency: str = "CNY" min_quantity: int = 1 step: int = 1 max_quantity: int | None = None description: str | None = None status: str created_at: datetime updated_at: datetime model_config = {"from_attributes": True} class AddonChargeRequestCreate(BaseModel): item_id: UUID quantity: int = Field(..., ge=1) reason: str | None = Field(None, max_length=2000) class AddonChargeResponse(BaseModel): id: UUID item_id: UUID item_name: str | None = None software_id: UUID software_name: str | None = None user_id: UUID user_name: str | None = None quantity: int unit_price: Decimal | None = None currency: str = "CNY" amount: Decimal | None = None status: str reason: str | None = None department: str | None = None period: str | None = None approval_request_id: UUID | None = None created_at: datetime approved_at: datetime | None = None model_config = {"from_attributes": True} # --------------------------------------------------------------------------- # Software seat allocation schemas # --------------------------------------------------------------------------- class SeatRequestCreate(BaseModel): plan_id: UUID reason: str | None = Field(None, max_length=2000) answers: dict[str, Any] | None = None class AdminSeatGrantRequest(BaseModel): """Admin-initiated seat grant — no approval loop, no Feishu hop. Used by ``POST /assets/seat-allocations`` to let IT skip the employee self-request path when they need to hand someone a seat directly (e.g. onboarding grant, Zoom license issued as part of a support ticket, etc.). The row is created in ``status='active'`` and a grant provisioning task is enqueued the same way the approval callback does it. """ target_user_id: UUID plan_id: UUID reason: str | None = Field(None, max_length=2000) class AdminSeatRevokeRequest(BaseModel): """Admin-initiated seat revoke. ``successor_user_id`` is required when the bound driver needs vendor-side data handoff (Zoom delete / Google user_suspend). When omitted for a driver that requires one, the endpoint falls back to the target user's manager; if no manager is set, the call fails with a clear error so the admin picks one in the UI. ``confirm_cross_department`` must be set explicitly to True when the successor sits in a different department from the seat holder. The endpoint blocks cross-department transfers without this flag because they are a known data-exfiltration vector (Zoom ``action_on_offboard=delete`` moves meetings + recordings to the successor). When the flag is set, only a global_admin may proceed — asset_admin is restricted to same-department successors. """ successor_user_id: UUID | None = None confirm_cross_department: bool = False class AdminSeatChangePlanRequest(BaseModel): """Admin-initiated seat-tier change. Lets IT change a holder's seat tier without going through a revoke + employee re-request loop. Common cases: * Zoom Basic ↔ Licensed when a contractor moves to FTE * Slack Multi-Channel Guest ↔ Full Member * Jira Software ↔ Service Desk The endpoint atomically revokes the existing seat and creates a new ``status='active'`` row on ``new_plan_id``. Both actions are audited under the same trace_id and reference each other in ``context_data`` so finance / compliance can reconstruct the transition. ``allow_demote=True`` is propagated to the driver grant context, which — combined with the audit row — makes a Zoom licensed→basic change a deliberate, traceable action rather than a silent vendor-side downgrade (the regular grant path still refuses silent demotes). """ new_plan_id: UUID reason: str = Field(..., min_length=1, max_length=2000) class SeatCancelNextMonthRequest(BaseModel): """Self-service "cancel at next month boundary" payload. The actual revoke runs at the start of the next calendar month via the ``software_seat.auto_revoke_pending_cancels`` cron. The reason is optional but encouraged so the admin sweep digest tells the IT owner *why* a paid seat freed up (canceled-by-employee vs left company vs role change). """ reason: str | None = Field(default=None, max_length=500) class SeatKeepRequest(BaseModel): """Self-service "keep my idle seat" payload. Sent when an employee (or admin on their behalf) responds to an idle-seat warning by opting to keep the seat. Sets ``idle_kept_until`` so the ``software_seat.idle_reclaim`` job skips the seat for the configured keep window. Reason is optional. """ reason: str | None = Field(default=None, max_length=500) class SeatRevokePreviewResponse(BaseModel): """Preview payload for the revoke Modal — tells the UI whether a successor picker must be shown and, if so, suggests the target's manager as a default pick.""" requires_successor: bool provider: str | None = None suggested_successor_user_id: UUID | None = None suggested_successor_display_name: str | None = None class SeatAllocationResponse(BaseModel): id: UUID plan_id: UUID plan_name: str | None = None software_id: UUID | None = None software_name: str | None = None billing_cycle: str | None = None price: Decimal | None = None user_id: UUID user_name: str | None = None user_email: str | None = None # 工号 + 在职状态:让前端 PersonTag 把离职员工标灰显示。 user_employee_no: str | None = None user_employment_status: str | None = None department: str | None = None # Top-level BU (org_path first segment, tenant root stripped) resolved # from the holder's current primary org membership. Lets the cost view # roll leaf departments up to their business unit. ``None`` when the # person has no primary membership / sits under the tenant root. bu: str | None = None status: str reason: str | None = Field(None, max_length=2000) approval_request_id: UUID | None = None allocated_at: datetime revoked_at: datetime | None = None # This-period billing contribution (monthly=price, yearly=price/12, # one_time=price only in the allocation month). Populated only by the # live cost-stats view (``_compute_cost_stats``) so its rows line up # 1:1 with the frozen monthly-bill snapshot; ``None`` on the plain # seat-allocation list endpoints, which carry no period context. amount: Decimal | None = None # Plan-level currency (ISO code, upper-cased; defaults ``CNY`` for # legacy plans predating migration 116). Carried on every row so a # client can group amounts by currency instead of summing across # currencies as if all were one — same field the frozen monthly-bill # rows expose, keeping live view and snapshot at parity. currency: str | None = None model_config = {"from_attributes": True} class CostSummaryResponse(BaseModel): # Per-cycle period totals. Populated with the single-currency sum when # every billable seat shares one currency; ``None`` when the set mixes # currencies — read ``by_currency`` instead. This mirrors # ``billing_service.preview_current_or_draft`` (``total_amount=None`` on # mixed currency) so the live view and the closed bill present currency # identically; summing CNY + USD into one decimal would be a silent lie. total_monthly_cost: Decimal | None = None total_yearly_cost: Decimal | None = None total_one_time_cost: Decimal | None = None total_cost: Decimal | None = None # ``{currency: period_total}`` — authoritative breakdown. Always present # (one key for a single-currency fleet, empty when no billable seats). by_currency: dict[str, Decimal] = {} total_currency_count: int = 0 currency_warning: bool = False # Top-line seat count: sum across software in scope, where each # software contributes its vendor seat-sync count (authoritative, # matches the drawer's "厂商许可") or — for software never synced — # its distinct ``user_id`` count among the billable rows. active_seats: int # Per-software ``vendor_license_holders`` snapshot, keyed by # stringified ``software_id`` so JSON survives intact. Empty when # no software in the response has been seat-synced (catalog-only # entries). The frontend reads this for the per-row "生效坐席" # column so the table cell matches the drill-down drawer. vendor_seats_by_software: dict[str, int] = {} items: list[SeatAllocationResponse] # --------------------------------------------------------------------------- # Monthly billing schemas (migration 063) # --------------------------------------------------------------------------- class MonthlyBillLine(BaseModel): """One frozen bill row — either finalised (from software_monthly_bills) or the computed-on-the-fly "current month" preview row. Both share this shape so the frontend can render finalised & preview uniformly.""" id: UUID | None = None period: str # "YYYY-MM" allocation_id: UUID software_id: UUID | None = None software_name: str | None = None plan_id: UUID plan_name: str | None = None user_id: UUID user_name: str | None = None user_email: str | None = None department: str | None = None # Top-level BU — see ``SeatAllocationResponse.bu``. Resolved on-read # from the holder's current primary membership (the frozen snapshot # stores only the leaf ``department``), consistent with how the ``bu`` # filter already expands against today's org tree. bu: str | None = None price: Decimal billing_cycle: str amount: Decimal reason: str | None = Field(None, max_length=2000) allocated_at: datetime revoked_at: datetime | None = None status: str | None = None # preview only — "active"/"revoked" as of period end finalized_at: datetime | None = None model_config = {"from_attributes": True} class MonthlyBillSummaryResponse(BaseModel): """Aggregated bill for one period. ``status`` is 'finalized' for a closed period, 'draft' for the live preview of the current month.""" period: str status: str # "finalized" | "draft" total_amount: Decimal active_seats: int # See ``CostSummaryResponse.vendor_seats_by_software`` — same shape, # same semantics (vendor authoritative seat count per software). vendor_seats_by_software: dict[str, int] = {} finalized_at: datetime | None = None finalized_by: UUID | None = None items: list[MonthlyBillLine] class BillingPeriodSummary(BaseModel): """Row for the "close history" listing.""" period: str status: str # "finalized" | "draft" | "available" (past month not yet closed) total_amount: Decimal | None = None active_seats: int | None = None finalized_at: datetime | None = None finalized_by: UUID | None = None class CloseMonthRequest(BaseModel): period: str = Field(..., pattern=r"^\d{4}-\d{2}$") # --------------------------------------------------------------------------- # Business payment schemas # --------------------------------------------------------------------------- class BusinessPaymentCreateRequest(BaseModel): software_catalog_id: UUID | None = None procurement_order_id: UUID | None = None supplier_id: UUID | None = None software_name: str | None = Field(None, max_length=128) software_type: str | None = Field(None, max_length=64) payment_frequency: str | None = Field(None, max_length=64) payment_method: str | None = Field(None, max_length=64) signing_entity: str | None = Field(None, max_length=128) po_number: str | None = Field(None, max_length=64) contract_expiry: str | None = Field(None, max_length=128) requester: str | None = Field(None, max_length=64) requester_person_id: UUID | None = None amount: Decimal | None = Field(None, decimal_places=2, max_digits=12) remark: str | None = None class BusinessPaymentUpdateRequest(BaseModel): software_catalog_id: UUID | None = None procurement_order_id: UUID | None = None supplier_id: UUID | None = None software_name: str | None = Field(None, max_length=128) software_type: str | None = Field(None, max_length=64) payment_frequency: str | None = Field(None, max_length=64) payment_method: str | None = Field(None, max_length=64) signing_entity: str | None = Field(None, max_length=128) po_number: str | None = Field(None, max_length=64) contract_expiry: str | None = Field(None, max_length=128) requester: str | None = Field(None, max_length=64) requester_person_id: UUID | None = None amount: Decimal | None = Field(None, decimal_places=2, max_digits=12) remark: str | None = None class BusinessPaymentResponse(BaseModel): id: UUID software_catalog_id: UUID | None = None procurement_order_id: UUID | None = None supplier_id: UUID | None = None software_name: str software_type: str | None = None payment_frequency: str | None = None payment_method: str | None = None signing_entity: str | None = None po_number: str | None = None contract_expiry: str | None = None requester: str | None = None requester_person_id: UUID | None = None requester_person_name: str | None = None requester_person_email: str | None = None amount: Decimal | None = None remark: str | None = None created_at: datetime updated_at: datetime model_config = {"from_attributes": True} # --------------------------------------------------------------------------- # Supplier schemas # --------------------------------------------------------------------------- class SupplierCreateRequest(BaseModel): name: str = Field(..., max_length=128) contact_name: str | None = Field(None, max_length=64) contact_phone: str | None = Field(None, max_length=32) remark: str | None = None class SupplierUpdateRequest(BaseModel): name: str | None = Field(None, max_length=128) contact_name: str | None = Field(None, max_length=64) contact_phone: str | None = Field(None, max_length=32) remark: str | None = None is_active: bool | None = None class SupplierResponse(BaseModel): id: UUID name: str contact_name: str | None = None contact_phone: str | None = None remark: str | None = None is_active: bool created_at: datetime updated_at: datetime model_config = {"from_attributes": True} # --------------------------------------------------------------------------- # Office address schemas # --------------------------------------------------------------------------- class OfficeAdminEntry(BaseModel): """One row in an office's 当地 Admin list. A local IT contact for the people who sit there. Carries no asset authority — that is ``LocationAdminEntry``. """ person_id: UUID person_name: str | None = None work_email: str | None = None is_primary: bool = False class OfficeAddressCreateRequest(BaseModel): city: str = Field(..., max_length=64) office_name: str | None = Field(None, max_length=128) country_or_region: str | None = Field(None, max_length=64) contact_name: str | None = Field(None, max_length=64) contact_phone: str | None = Field(None, max_length=32) address: str | None = Field(None, max_length=512) address_en: str | None = Field(None, max_length=512) # ``admin_person_id`` stays for back-compat (single primary). # New writes SHOULD use ``admin_person_ids`` (full list, first # entry becomes primary). When both are sent, ``admin_person_ids`` # wins. admin_person_id: UUID | None = None admin_person_ids: list[UUID] | None = None legal_entity_id: UUID | None = None class OfficeAddressUpdateRequest(BaseModel): city: str | None = Field(None, max_length=64) office_name: str | None = Field(None, max_length=128) country_or_region: str | None = Field(None, max_length=64) contact_name: str | None = Field(None, max_length=64) contact_phone: str | None = Field(None, max_length=32) address: str | None = Field(None, max_length=512) address_en: str | None = Field(None, max_length=512) admin_person_id: UUID | None = None admin_person_ids: list[UUID] | None = None legal_entity_id: UUID | None = None is_active: bool | None = None class OfficeAddressResponse(BaseModel): id: UUID city: str office_name: str | None = None country_or_region: str | None = None contact_name: str | None = None contact_phone: str | None = None address: str | None = None address_en: str | None = None admin_person_id: UUID | None = None admin_person_name: str | None = None # Full 当地 Admin list. Always present; empty when the office has # none. The primary (mirrored into ``admin_person_id``) is the row # with ``is_primary=True`` — ordering puts it first. admins: list[OfficeAdminEntry] = [] legal_entity_id: UUID | None = None legal_entity_name: str | None = None is_active: bool created_at: datetime updated_at: datetime model_config = {"from_attributes": True} # --------------------------------------------------------------------------- # Legal entity schemas # --------------------------------------------------------------------------- _LEGAL_ENTITY_CODE_PATTERN = r"^[A-Z0-9]{1,16}$" class LegalEntityCreateRequest(BaseModel): name: str = Field(..., max_length=128) # Tag-safe slug embedded in asset-tag auto-generation. Constrained # to A-Z and 0-9 (no dashes, no spaces) so the tag's ``-`` separator # remains unambiguous when the parser splits ``IT-CN-NB-…`` into # 4 fields. code: str | None = Field(None, max_length=16, pattern=_LEGAL_ENTITY_CODE_PATTERN) country_or_region: str | None = Field(None, max_length=64) tax_identifier: str | None = Field(None, max_length=128) registered_address: str | None = Field(None, max_length=512) phone: str | None = Field(None, max_length=64) bank_name: str | None = Field(None, max_length=128) bank_account: str | None = Field(None, max_length=128) bank_account_name: str | None = Field(None, max_length=128) bank_address: str | None = Field(None, max_length=512) swift_code: str | None = Field(None, max_length=32) currency: str | None = Field(None, max_length=8) account_type: str | None = Field(None, max_length=64) notes: str | None = Field(None, max_length=2000) class LegalEntityUpdateRequest(BaseModel): name: str | None = Field(None, max_length=128) code: str | None = Field(None, max_length=16, pattern=_LEGAL_ENTITY_CODE_PATTERN) country_or_region: str | None = Field(None, max_length=64) tax_identifier: str | None = Field(None, max_length=128) registered_address: str | None = Field(None, max_length=512) phone: str | None = Field(None, max_length=64) bank_name: str | None = Field(None, max_length=128) bank_account: str | None = Field(None, max_length=128) bank_account_name: str | None = Field(None, max_length=128) bank_address: str | None = Field(None, max_length=512) swift_code: str | None = Field(None, max_length=32) currency: str | None = Field(None, max_length=8) account_type: str | None = Field(None, max_length=64) notes: str | None = Field(None, max_length=2000) is_active: bool | None = None class LegalEntityResponse(BaseModel): id: UUID name: str code: str | None = None country_or_region: str | None = None tax_identifier: str | None = None registered_address: str | None = None phone: str | None = None bank_name: str | None = None bank_account: str | None = None bank_account_name: str | None = None bank_address: str | None = None swift_code: str | None = None currency: str | None = None account_type: str | None = None notes: str | None = Field(None, max_length=2000) is_active: bool created_at: datetime updated_at: datetime # True when the sensitive 开票/银行 fields (tax_identifier, phone, # bank_account, swift_code) in this payload are masked for the caller. # Foundation admins read plaintext (they edit the row); everyone else # gets masked values plus an audited reveal. Drives the "显示完整信息" # button in the read-only company directory. sensitive_masked: bool = False model_config = {"from_attributes": True} class LegalEntityRevealResponse(BaseModel): """Plaintext sensitive fields returned by the audited reveal endpoint. Mirrors the ``identity.person.contact_reveal`` pattern: the masked list/detail response is the default, and unmasking goes through a dedicated, audit-logged call so every disclosure is attributable. """ id: UUID tax_identifier: str | None = None phone: str | None = None bank_account: str | None = None swift_code: str | None = None # =========================================================================== # Hardware-asset financial lifecycle (depreciation / book value / disposal P&L) # =========================================================================== ALLOWED_DEPRECIATION_METHODS = ["straight_line", "declining_balance"] class DepreciationPolicyRead(BaseModel): model_config = ConfigDict(from_attributes=True) id: UUID asset_category: str | None = None # NULL = platform default fallback method: str useful_life_months: int residual_rate: Decimal declining_rate: Decimal | None = None is_active: bool notes: str | None = None created_at: datetime updated_at: datetime class DepreciationPolicyUpsert(BaseModel): model_config = ConfigDict(extra="forbid") # NULL / omitted ⇒ the platform default fallback row. asset_category: str | None = Field(None, max_length=32) method: str = Field("straight_line", max_length=32) useful_life_months: int = Field(36, ge=1, le=600) residual_rate: Decimal = Field(Decimal("0.05"), ge=0, le=1, max_digits=5, decimal_places=4) declining_rate: Decimal | None = Field(None, ge=0, le=1, max_digits=6, decimal_places=4) is_active: bool = True notes: str | None = Field(None, max_length=2000) @field_validator("method") @classmethod def validate_method(cls, v: str) -> str: if v not in ALLOWED_DEPRECIATION_METHODS: raise ValueError( f"Method must be one of: {', '.join(ALLOWED_DEPRECIATION_METHODS)}" ) return v class FinancialBucket(BaseModel): """One aggregation bucket (by category / department / office).""" key: str label: str | None = None asset_count: int = 0 undepreciable_count: int = 0 original_cost: Decimal = Decimal("0") accumulated_depreciation: Decimal = Decimal("0") net_book_value: Decimal = Decimal("0") repair_total_cost: Decimal = Decimal("0") # Currency-exact breakdowns (None total ⇒ mixed currencies). by_currency_original: dict[str, Decimal] = Field(default_factory=dict) by_currency_nbv: dict[str, Decimal] = Field(default_factory=dict) class DisposalPnl(BaseModel): disposed_count: int = 0 # ``total_*`` are single-currency rollups — None when the disposed set # mixes currencies (mirrors FinancialStatsResponse). The # ``by_currency_*`` maps are always authoritative. total_proceeds: Decimal | None = Decimal("0") total_book_value_at_disposal: Decimal | None = Decimal("0") total_gain_loss: Decimal | None = Decimal("0") by_currency_proceeds: dict[str, Decimal] = Field(default_factory=dict) by_currency_book_value: dict[str, Decimal] = Field(default_factory=dict) currency_warning: bool = False class FinancialStatsResponse(BaseModel): """Hardware financial summary for a scope (mine / department / all). Mirrors the software ``CostSummaryResponse`` shape so finance reads the two cost axes the same way. ``total_*`` are single-currency rollups (None when the scope mixes currencies); the ``by_currency_*`` maps are always authoritative. """ as_of: str scope: str # mine | department | office | all asset_count: int = 0 undepreciable_count: int = 0 total_original_cost: Decimal | None = None total_accumulated_depreciation: Decimal | None = None total_net_book_value: Decimal | None = None total_repair_cost: Decimal | None = None by_currency_original: dict[str, Decimal] = Field(default_factory=dict) by_currency_nbv: dict[str, Decimal] = Field(default_factory=dict) currency_warning: bool = False by_category: list[FinancialBucket] = Field(default_factory=list) by_department: list[FinancialBucket] = Field(default_factory=list) by_office: list[FinancialBucket] = Field(default_factory=list) disposal_pnl: DisposalPnl = Field(default_factory=DisposalPnl) class BookValueTrendPoint(BaseModel): period: str # YYYY-MM original_cost_cny: Decimal accumulated_depreciation_cny: Decimal net_book_value_cny: Decimal asset_count: int undepreciable_count: int = 0 class BookValueTrendResponse(BaseModel): points: list[BookValueTrendPoint] = Field(default_factory=list) # Live current point (computed on the fly, not materialised). current: BookValueTrendPoint | None = None currency: str = "CNY" fx_available: bool = True class AssetFinancialProfile(BaseModel): """Per-asset financial timeline: acquisition → depreciation schedule → repair investment → disposal P&L → total cost of ownership.""" asset_id: UUID asset_tag: str asset_name: str | None = None category: str | None = None currency: str | None = None purchase_date: date | None = None purchase_cost: Decimal | None = None purchase_entity: str | None = None # Depreciation policy_method: str useful_life_months: int residual_rate: Decimal elapsed_months: int = 0 residual_value: Decimal | None = None accumulated_depreciation: Decimal | None = None net_book_value: Decimal | None = None fully_depreciated: bool = False undepreciable: bool = False schedule: list[dict] = Field(default_factory=list) # Repair investment (lifetime) repair_count: int = 0 repair_total_cost: Decimal = Decimal("0") # Disposal status: str disposal_type: str | None = None disposed_at: datetime | None = None disposal_proceeds: Decimal | None = None disposal_book_value: Decimal | None = None disposal_gain_loss: Decimal | None = None # Total cost of ownership = purchase + repairs − proceeds. total_cost_of_ownership: Decimal | None = None class ExternalAccountClassifyRequest(BaseModel): """Upsert a cross-vendor external-account annotation by email. ``model_fields_set`` distinguishes "field omitted" from "field set to null": omit ``owner_person_id`` to leave it unchanged, send it as ``null`` to clear. Classification/note follow the same rule. """ email: str classification: str | None = None note: str | None = None owner_person_id: UUID | None = None # --------------------------------------------------------------------------- # MDM managed-device ledger (asset_mdm_devices) # --------------------------------------------------------------------------- class MdmDeviceResponse(BaseModel): """One enrolled device, with the joins the page needs already flat. ``assigned_person_*`` and ``asset_tag`` are resolved by the endpoint rather than declared as relationships: the ledger's resolution columns carry no foreign keys (they are rewritten by a nightly batch, and TiDB locks the parent row on every FK-carrying insert), so a dangling id has to render as an absence instead of blowing up a join. """ id: UUID provider: str device_type: str external_id: str management_id: str | None = None serial_number: str | None = None device_name: str | None = None model: str | None = None os_version: str | None = None managed: bool = True # What the MDM itself records, kept separate from what Helix resolved # so "Jamf cannot attribute this device" stays a visible fact rather # than being papered over by the asset-ledger fallback. mdm_assigned_email: str | None = None mdm_assigned_name: str | None = None asset_id: UUID | None = None asset_tag: str | None = None asset_status: str | None = None # When the device LEFT company ownership, which since the backlog # reconciler exists is no longer the same moment as # ``cleanup_requested_at``. The cleanup card states it; this is the # page that card deep-links to, and the two have to tell one story — # otherwise an admin arriving from a card that says "sold 2026-01-14" # lands where that fact does not exist. asset_disposed_at: datetime | None = None assigned_person_id: UUID | None = None assigned_person_name: str | None = None assigned_person_email: str | None = None assigned_person_employment_status: str | None = None owner_source: str | None = None match_category: str enrollment_state: str cleanup_task_id: UUID | None = None cleanup_reason: str | None = None cleanup_requested_at: datetime | None = None cleanup_confirmed_at: datetime | None = None cleanup_confirmed_via: str | None = None cleanup_contradicted_at: datetime | None = None cleanup_last_reminded_at: datetime | None = None first_seen_at: datetime | None = None last_seen_at: datetime | None = None last_contact_at: datetime | None = None gone_at: datetime | None = None created_at: datetime updated_at: datetime model_config = ConfigDict(from_attributes=True) class MdmDeviceSummary(BaseModel): """Headline counters for the ledger page. ``pending_cleanup`` is the only one that is a work queue. The others describe the fleet and the MDM's own data quality — in particular ``leaver_residual`` is "the MDM still has a departed employee's name on this machine", which resolves itself when the device is re-imaged and is deliberately not a todo. """ total: int = 0 enrolled: int = 0 pending_cleanup: int = 0 gone: int = 0 matched: int = 0 leaver_residual: int = 0 external: int = 0 unowned: int = 0 linked_to_asset: int = 0 unlinked: int = 0 overdue_cleanup: int = 0 class MdmDeviceMetaResponse(BaseModel): """Vocabularies, counters and the caller's write capability. ``can_manage`` mirrors the server-side write gate so a read-only viewer gets a page without buttons, rather than buttons that 403. """ providers: list[str] device_types: list[str] enrollment_states: list[str] match_categories: list[str] owner_sources: list[str] can_manage: bool integration_configured: bool last_synced_at: datetime | None = None summary: MdmDeviceSummary class MdmDeviceCleanupConfirmRequest(BaseModel): model_config = ConfigDict(extra="forbid") note: str | None = Field(None, max_length=512)