import uuid from datetime import UTC, datetime from datetime import date as date_type from decimal import Decimal from typing import Any from sqlalchemy import ( JSON, BigInteger, Boolean, Date, DateTime, ForeignKey, Index, Integer, Numeric, String, Text, UniqueConstraint, text, ) from sqlalchemy.dialects.mysql import MEDIUMTEXT from sqlalchemy.orm import Mapped, mapped_column, relationship, validates from sqlalchemy.sql import func from app.core.database import Base from app.core.sql_types import GUID as Uuid # Statuses that indicate the asset currently has a holder or is in # flight. Promoted from ``AssetService._IN_USE_STATUSES`` to module # scope (Round 4 audit fix-up 2026-05-04) so cross-domain consumers # (offboarding ``_rescan``, governance reports) can use the canonical # set instead of duplicating it. ``borrowed_by`` carries the borrower # while status='borrowed'; ``assigned_to`` carries the original owner # (or NULL for borrow-from-idle). ASSET_IN_USE_STATUSES: frozenset[str] = frozenset({ "assigned", "claimed", "borrowed", "claim_pending", "return_pending", "borrow_pending", "borrow_return_pending", "transferring", }) # Non-standard but real-world state: the holder has left the company and # the asset was not physically returned (employment dispute pending, # overseas employee carried the laptop, IT consciously chose to leave the # asset attributed to the ex-employee). Set by offboarding ``skip_blocker`` # on a hardware blocker OR by an admin via the asset detail page. The # asset is still company property; ``assigned_to`` is preserved so the # audit trail and "last holder" attribution are intact. Recovery flips # the asset back to ``idle`` via ``AssetService.recover_from_leaver``. # # Deliberately NOT in ``ASSET_IN_USE_STATUSES`` — the offboarding scanner # uses that set to decide whether the leaver still holds anything, and a # ``held_by_leaver`` row by construction belongs to an *already-departed* # person, so re-detecting it on a rehire would re-open the process for # the wrong reason. ASSET_STATUS_HELD_BY_LEAVER = "held_by_leaver" # Office-held mode (migration 183, 2026-05-19). Companion to # ``holder_kind='office'`` — the device physically lives at a city office # (meeting-room kit, shared dock, spare device on a shelf) with no Person # assignee. ``assigned_to`` MUST be NULL; ``holder_office_id`` points at # the responsible ``asset_office_addresses`` row, and ``OfficeAddress # .admin_person_id`` is who receives inventory pings + offboarding # handoffs. Pre-183 these assets were silently routed by the inventory # classifier via a free-text ``location`` match — that worked for idle # rows but broke the moment T+0 force_transfer reattributed a leaver's # laptop to whatever active person caught the toss, hiding it from the # office-routing path. Making the mode explicit also makes "recycle to # office" a first-class operation (rather than "unassign + hope"). # # Like ``held_by_leaver`` this status is deliberately NOT in # ``ASSET_IN_USE_STATUSES``: the offboarding scanner treats this row as # "nobody's personal device, leave it alone". The lifecycle ends with # either ``claim_asset`` (someone picks it up, → personal/claimed) or # ``dispose_asset``. ASSET_STATUS_HELD_BY_OFFICE = "held_by_office" # ── purchase_cost provenance (migration 604, 2026-09-05) ────────────── # An operator-recorded amount. Every row predating migration 604 is # backfilled to this, which is honest: the amounts that WERE present came # from intake or a bulk update. PURCHASE_COST_SOURCE_RECORDED = "recorded" # Median of same-model devices that have a price. An estimate, and the UI # must say so — it exists to make depreciation possible on the 2026-04 # legacy import, not to make the completeness metric look better. PURCHASE_COST_SOURCE_ESTIMATED_MODEL = "estimated_model" # Matched against the finance fixed-asset register by serial number. The # authoritative source; overwrites an estimate, never the reverse. PURCHASE_COST_SOURCE_FINANCE_REGISTER = "finance_register" PURCHASE_COST_SOURCES = ( PURCHASE_COST_SOURCE_RECORDED, PURCHASE_COST_SOURCE_ESTIMATED_MODEL, PURCHASE_COST_SOURCE_FINANCE_REGISTER, ) ASSET_HOLDER_KIND_PERSONAL = "personal" ASSET_HOLDER_KIND_OFFICE = "office" # --------------------------------------------------------------------------- # Canonical ``asset_items.status`` vocabulary — THE list (2026-08-07). # # Ordered as the lifecycle reads, and enforced at write time by # ``AssetItem.validate_status`` below. ``schemas.ALLOWED_STATUSES`` is # derived from this tuple so the request validator and the ORM can never # drift apart. # # Why the write-time guard exists: ``in_stock`` (a pre-Helix Excel-import # synonym for idle) sat in one prod row for months, frozen — every # holder-changing gate whitelists canonical statuses, so the asset could # not be dispatched to anyone and 「变更持有」 just refused. Nothing in the # code wrote that token; it arrived through a bulk import that bypassed # the request schema. Migration 454 retired it from the data, and this # validator is what makes the next such import fail loudly at the write # instead of silently freezing an asset. See PLATFORM.md. # # Retired from the vocabulary in the same pass (0 rows in prod, 0 writers, # never reachable): ``maintenance`` (repairs live on ``device_status`` — # ``under_repair`` / ``pending_repair``), ``retired`` (duplicate of # ``disposed``), ``faulty`` (name-collides with ``device_status='faulty'``, # which is the real one), ``testing`` / ``legacy`` (import-era leftovers). ASSET_STATUSES: tuple[str, ...] = ( # In stock, no holder. ``idle`` is canonical; ``available`` is its # legacy twin — still accepted on read, no longer written by any path # (``unassign_asset`` was the last writer, 2026-08-07). "idle", "available", # Held by a person. ``claimed`` = employee picked it up; # ``assigned`` = admin dispatched it. "claimed", "assigned", "borrowed", # Held, but not by a person. ASSET_STATUS_HELD_BY_OFFICE, ASSET_STATUS_HELD_BY_LEAVER, # In-flight two-phase states. No code writes these today; they are the # vocabulary the "don't touch, an operation is pending" guards speak # (``ASSET_IN_USE_STATUSES``, inventory snapshot exclusions), and the # names a future request→confirm flow would reuse. "claim_pending", "return_pending", "borrow_pending", "borrow_return_pending", "transferring", # Terminal. "disposal_pending", "disposed", ) ASSET_STATUS_SET: frozenset[str] = frozenset(ASSET_STATUSES) class AssetItem(Base): __tablename__ = "asset_items" __mapper_args__ = {"eager_defaults": True} __table_args__ = ( Index("ix_asset_items_assigned_to", "assigned_to"), Index("ix_asset_items_status", "status"), Index("ix_asset_items_deleted_at", "deleted_at"), Index("ix_asset_items_borrowed_by", "borrowed_by"), Index("ix_asset_items_holder", "holder_kind", "holder_office_id"), ) id: Mapped[uuid.UUID] = mapped_column( Uuid, primary_key=True, default=uuid.uuid4, ) asset_tag: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) asset_name: Mapped[str | None] = mapped_column(String(128)) category: Mapped[str] = mapped_column(String(32), nullable=False) # Free-text brand / model strings — kept as the canonical display # value for back-compat with reports / list endpoints. New writes # also populate ``brand_id`` / ``model_id`` (added in migration 071) # so admins can curate the lookup. brand: Mapped[str | None] = mapped_column(String(64)) model: Mapped[str | None] = mapped_column(String(128)) # FK lookup — populated alongside the text columns by intake / update. # Nullable so legacy rows + free-text-only writes still work. brand_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("asset_brands.id", ondelete="SET NULL"), ) model_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("asset_models.id", ondelete="SET NULL"), ) serial_number: Mapped[str | None] = mapped_column(String(128), unique=True) status: Mapped[str] = mapped_column(String(32), nullable=False, default="idle") device_status: Mapped[str] = mapped_column(String(16), nullable=False, default="normal") source_type: Mapped[str | None] = mapped_column(String(16)) order_number: Mapped[str | None] = mapped_column(String(128)) purchase_date: Mapped[datetime | None] = mapped_column(Date) purchase_cost: Mapped[Decimal | None] = mapped_column(Numeric(10, 2)) # Where ``purchase_cost`` came from (migration 604). One of # ``PURCHASE_COST_SOURCES``. Depreciation, BvA and cost attribution # all read the amount, so an ESTIMATE has to stay distinguishable # from a recorded invoice figure — a single amount column cannot say # which it is, and once the two are mixed the number stops being # defensible. Read surfaces label ``estimated_model`` explicitly. purchase_cost_source: Mapped[str] = mapped_column( String(24), nullable=False, default=PURCHASE_COST_SOURCE_RECORDED, server_default=PURCHASE_COST_SOURCE_RECORDED, ) currency: Mapped[str | None] = mapped_column(String(8)) invoice_number: Mapped[str | None] = mapped_column(String(128)) warranty_until: Mapped[datetime | None] = mapped_column(Date) usage_limit_months: Mapped[int | None] = mapped_column(Integer) supplier_name: Mapped[str | None] = mapped_column(String(128)) supplier_contact: Mapped[str | None] = mapped_column(String(128)) supplier_phone: Mapped[str | None] = mapped_column(String(64)) maintenance_note: Mapped[str | None] = mapped_column(Text) location: Mapped[str | None] = mapped_column(String(256)) admin_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), ) assigned_to: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), ) assigned_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # Office-held mode (migration 183). When ``holder_kind='office'`` the # asset has no Person assignee — it lives at the city office pointed # at by ``holder_office_id``. Service-layer invariant: ``office`` # ⇒ assigned_to IS NULL AND holder_office_id IS NOT NULL. The # inventory classifier routes these directly via # ``holder_office_id`` (no more location string fuzzy-match). holder_kind: Mapped[str] = mapped_column( String(16), nullable=False, default="personal", server_default="personal", ) holder_office_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("asset_office_addresses.id", ondelete="SET NULL"), ) # Borrow tracking (migration 115). When an asset is loaned to another # person, ``borrowed_by`` holds the borrower and ``assigned_to`` is # PRESERVED as the original owner — so offboarding scans, "my # inventory" listings, and audit trails stay accurate while the asset # is out on loan. Cleared on borrow_return. Pre-115 rows had # ``assigned_to`` overwritten by the borrower; the migration's # backfill recovers the original owner from AssetOperation history. borrowed_by: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id", ondelete="SET NULL"), ) borrow_started_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), ) # Reservation for an incoming hire (migration 279, 备机闭环). The # asset stays in its stock status (idle / available / held_by_office # / held_by_leaver) but is earmarked: assign / claim / transfer to # anyone OTHER than the reserved person is refused until the # reservation is released. Cleared automatically when the asset is # assigned to the reserved person. reserved_for_person_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id", ondelete="SET NULL"), ) reserved_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), ) disposal_type: Mapped[str | None] = mapped_column(String(32)) disposed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # Disposal financials (PR 角色对称/财务闭环). ``disposal_proceeds`` is the # cash recovered on sale / lease-return (0 for scrapped / lost / # donated). ``disposal_book_value`` is the net book value FROZEN at # the disposal date (computed from the depreciation policy at that # instant) so the disposal gain/loss (= proceeds − book value) is # reproducible even after the policy later changes. ``disposal_currency`` # mirrors the asset currency at disposal time. All three are also # snapshotted into the immutable disposal ``AssetOperation.detail``. disposal_proceeds: Mapped[Decimal | None] = mapped_column(Numeric(12, 2)) disposal_book_value: Mapped[Decimal | None] = mapped_column(Numeric(12, 2)) disposal_currency: Mapped[str | None] = mapped_column(String(8)) # S3 object keys for the disposal evidence the admin uploaded with the # 处置 action (回收单 / 二手买家付款截图 / 租赁归还回执 / 硬盘销毁照片). # Same contract as ``asset_inventory_items.evidence_photos``: the # browser PUTs straight to S3 via ``/api/v1/uploads/presign-put`` and # only the keys land here; resolve to short-lived presigned GET URLs at # render time via ``app.core.storage.presign_get``. Also copied into the # immutable disposal ``AssetOperation.detail`` snapshot (migration 591). # # ``none_as_null=True`` is load-bearing: SQLAlchemy's JSON type renders # a Python ``None`` as the JSON literal ``null`` — a real value — not # SQL NULL. "This disposal has no evidence" must be SQL NULL, the same # thing every pre-591 row already is, or a later # ``WHERE disposal_photos IS NOT NULL`` (how many disposals carry # proof?) counts rows that carry none. See the 2026-09-02 # ``cost_facts_raw_purge`` infinite loop for what that costs. disposal_photos: Mapped[list[str] | None] = mapped_column( JSON(none_as_null=True), ) # Soft-delete (migration 115). ``deleted_at IS NULL`` is the active # filter applied to every read. Replaces hard DELETE which used to # cascade-delete asset_history / asset_operations and break the # audit chain (CLAUDE.md rule 6). deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) image_urls: Mapped[Any | None] = mapped_column(JSON, default=list) custom_fields: Mapped[Any | None] = mapped_column(JSON, default=dict) epandian_id: Mapped[int | None] = mapped_column(BigInteger) # New columns for Excel import owner_company: Mapped[str | None] = mapped_column(String(128)) usage_company: Mapped[str | None] = mapped_column(String(128)) expected_return_date: Mapped[date_type | None] = mapped_column(Date) repair_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default=text("0")) repair_total_cost: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False, default=Decimal("0"), server_default=text("0")) print_status: Mapped[str | None] = mapped_column(String(16)) rfid: Mapped[str | None] = mapped_column(String(128)) purchase_entity: Mapped[str | None] = mapped_column(String(128)) notes: Mapped[str | None] = mapped_column(Text) # Asset inventory (盘点) — denormalised "last confirmed" state so # the asset list page can show a "最近盘点" column + filter without # joining to ``asset_inventory_items``. Written when an inventory # item flips to ``confirmed`` or ``issue_reported``. ``status`` # mirrors ``asset_inventory_items.status`` for the most recent # responded item ('confirmed' | 'issue_reported'); pending items # do NOT update these columns. last_inventory_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_inventory_cycle_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("asset_inventory_cycles.id", ondelete="SET NULL"), ) last_inventory_status: Mapped[str | None] = mapped_column(String(32)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), ) # Relationships history: Mapped[list["AssetHistory"]] = relationship( back_populates="asset", order_by="desc(AssetHistory.created_at)", ) operations: Mapped[list["AssetOperation"]] = relationship( back_populates="asset", order_by=lambda: ( AssetOperation.created_at.desc(), AssetOperation.operation_number.desc(), ), ) @validates("status") def validate_status(self, _key: str, value: str) -> str: """Refuse any status outside the canonical vocabulary. Deliberately at the ORM layer, not (only) in the request schema: the token that froze a laptop for months (``in_stock``) came in through a bulk import that never touched ``AssetCreateRequest``. Every write — service, script, migration-adjacent backfill — goes through here. Loading an existing row does NOT trigger this, so a not-yet-migrated environment still reads its legacy rows. """ if value not in ASSET_STATUS_SET: raise ValueError( f"Unknown asset status {value!r}. Allowed: " f"{', '.join(ASSET_STATUSES)}" ) return value class AssetLocation(Base): """Managed list of asset locations with codes for tag generation. Also the anchor of asset-domain administrative authority: the persons listed in ``asset_location_admins`` for a location are the ``asset_admin`` role holders scoped to it (migration 535). Before that the authority hung off ``asset_office_addresses``, which forced every "who covers this asset" question through a city/country inference ladder; ``asset_items.location`` is a managed, resolver-gated value, so anchoring here makes the lookup a direct one. """ __tablename__ = "asset_locations" __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column( Uuid, primary_key=True, default=uuid.uuid4, ) code: Mapped[str] = mapped_column(String(16), nullable=False, unique=True) name: Mapped[str] = mapped_column(String(128), nullable=False) sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default=text("0")) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=text("1")) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), ) class AssetLocationAdmin(Base): """Many-to-many: which persons administer which asset location. Single source of truth for ``asset_admin`` role membership — the role is fully auto-derived from this table (see ``app.domains.asset.location_admin_sync``) and manual grants are rejected with ``ROLE_AUTO_DERIVED``. It is also the region scope: an ``asset_admin`` may only mutate assets whose resolved location is one they are listed against. ``is_primary`` marks the one row per location that single-recipient flows address (inventory office-held confirmations, recycle-to-office notifications). Enforced by the service layer, not a DB constraint — MySQL has no partial unique index, and a location with no primary is a legitimate intermediate state while an admin list is being edited. Distinct from ``AssetOfficeLocalAdmin``: that one is the office's 当地 Admin (a local IT contact for person-anchored HR flows) and grants no asset authority at all. """ __tablename__ = "asset_location_admins" __mapper_args__ = {"eager_defaults": True} location_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("asset_locations.id", ondelete="CASCADE"), primary_key=True, ) person_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("identity_persons.id", ondelete="CASCADE"), primary_key=True, ) is_primary: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, server_default=text("0"), ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) class AssetCategoryConfig(Base): """Managed list of asset categories — the source of truth for the category segment of an asset tag (``IT-{entity}-{code}-{ts}``). ``asset_category`` is the stable asset-category enum value (``laptop`` / ``desktop`` / ...) that ``AssetItem.category`` stores; it links an asset to its config row. ``code`` is the literal tag segment (``NB`` / ``DT`` / ...) — admins edit it via the 分类管理 tab and it takes effect on the next intake. """ __tablename__ = "asset_category_configs" __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column( Uuid, primary_key=True, default=uuid.uuid4, ) code: Mapped[str] = mapped_column(String(16), nullable=False, unique=True) asset_category: Mapped[str | None] = mapped_column(String(32), unique=True) name: Mapped[str] = mapped_column(String(128), nullable=False) parent_code: Mapped[str | None] = mapped_column(String(16)) usage_limit_months: Mapped[int | None] = mapped_column(Integer) unit: Mapped[str | None] = mapped_column(String(32)) notes: Mapped[str | None] = mapped_column(Text) sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default=text("0")) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=text("1")) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), ) class AssetBrand(Base): """Managed list of asset brands (Apple, Lenovo, Dell, ...). Two-level lookup with :class:`AssetModel` so the intake form can show "select Apple → MacBook Pro 14 / Air 13 / ..." and admins can curate / merge / deactivate values via the asset settings page. ``auto_created=True`` rows were inserted by the intake AutoComplete (admin typed a value the lookup didn't have) — surfaces in the settings UI with a chip so the admin can decide whether to rename, merge, or deactivate. """ __tablename__ = "asset_brands" __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column( Uuid, primary_key=True, default=uuid.uuid4, ) name: Mapped[str] = mapped_column( String(64), nullable=False, unique=True, ) display_name: Mapped[str | None] = mapped_column(String(128)) notes: Mapped[str | None] = mapped_column(Text) sort_order: Mapped[int] = mapped_column( Integer, nullable=False, default=0, server_default="0", ) is_active: Mapped[bool] = mapped_column( Boolean, nullable=False, default=True, server_default="1", ) auto_created: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, server_default="0", ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), ) class AssetModel(Base): """Managed list of asset models, scoped under a brand. Composite uniqueness is on (brand_id, name) so the same model name can recur across brands — common for accessory / cable / generic SKUs. """ __tablename__ = "asset_models" __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column( Uuid, primary_key=True, default=uuid.uuid4, ) brand_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("asset_brands.id", ondelete="CASCADE"), nullable=False, ) name: Mapped[str] = mapped_column(String(128), nullable=False) notes: Mapped[str | None] = mapped_column(Text) sort_order: Mapped[int] = mapped_column( Integer, nullable=False, default=0, server_default="0", ) is_active: Mapped[bool] = mapped_column( Boolean, nullable=False, default=True, server_default="1", ) auto_created: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, server_default="0", ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), ) class AssetHistory(Base): """Legacy history table kept for backward compatibility.""" __tablename__ = "asset_history" __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column( Uuid, primary_key=True, default=uuid.uuid4, ) asset_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("asset_items.id"), nullable=False, ) action: Mapped[str] = mapped_column(String(32), nullable=False) from_user: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), ) to_user: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), ) operator_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), ) notes: Mapped[str | None] = mapped_column(Text) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), server_default=func.now(), ) # Relationships asset: Mapped["AssetItem"] = relationship(back_populates="history") class AssetOperation(Base): """Voucher/order-based operation tracking for asset lifecycle.""" __tablename__ = "asset_operations" __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column( Uuid, primary_key=True, default=uuid.uuid4, ) operation_number: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) asset_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("asset_items.id"), nullable=False, ) operation_type: Mapped[str] = mapped_column(String(32), nullable=False) status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending") from_user_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), ) to_user_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), ) operator_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("identity_persons.id"), nullable=False, ) notes: Mapped[str | None] = mapped_column(Text) detail: Mapped[Any | None] = mapped_column(JSON) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), server_default=func.now(), ) confirmed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) confirmed_by: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), ) # Relationships asset: Mapped["AssetItem"] = relationship(back_populates="operations") class AssetProcurementOrder(Base): """Procurement orders for asset purchasing/leasing.""" __tablename__ = "asset_procurement_orders" __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column( Uuid, primary_key=True, default=uuid.uuid4, ) order_number: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) order_type: Mapped[str] = mapped_column(String(16), nullable=False) status: Mapped[str] = mapped_column(String(16), nullable=False, default="draft") applicant_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("identity_persons.id"), nullable=False, ) supplier_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("asset_suppliers.id"), ) department: Mapped[str | None] = mapped_column(String(128)) supplier_name: Mapped[str | None] = mapped_column(String(128)) total_amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2)) items: Mapped[Any | None] = mapped_column(JSON) notes: Mapped[str | None] = mapped_column(Text) requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) approved_by: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), ) completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), ) class AssetSoftwareCatalog(Base): """Software asset catalog — tracks all SaaS/IT software subscriptions.""" __tablename__ = "asset_software_catalog" __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column( Uuid, primary_key=True, default=uuid.uuid4, ) name: Mapped[str] = mapped_column(String(128), nullable=False) asset_type: Mapped[str | None] = mapped_column(String(64)) owner: Mapped[str | None] = mapped_column(String(64)) owner_person_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), ) it_contact: Mapped[str | None] = mapped_column(String(64)) it_contact_person_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), ) status: Mapped[str] = mapped_column(String(32), nullable=False, default="active") remark: Mapped[str | None] = mapped_column(Text) # 「详细介绍」(migration 328) — long-form write-up that doesn't belong in # ``remark`` (备注, a one-liner): what the software is for, onboarding # notes, internal policy, links. MEDIUMTEXT (16MB) so admins can write # as much as they need; Pydantic caps the API surface at 20k chars. description: Mapped[str | None] = mapped_column(MEDIUMTEXT) # Free-text product version / edition (migration 228). Captured per # PCI DSS 「软件资产清单」 — e.g. "Google Workspace Enterprise Standard", # "Cloud Service", "2.222.4". Optional, no schema validation: this is # the contract / license tier as the admin would write it, not a # semver string. version: Mapped[str | None] = mapped_column(String(64)) department: Mapped[str | None] = mapped_column(String(128)) login_url: Mapped[str | None] = mapped_column(String(512)) expiry_date: Mapped[date_type | None] = mapped_column(Date) license_info: Mapped[str | None] = mapped_column(Text) attachment: Mapped[str | None] = mapped_column(String(256)) custom_fields: Mapped[dict | None] = mapped_column(JSON) # Provisioning extensions (migration 021) sso_provider: Mapped[str | None] = mapped_column(String(32)) offboarding_grace_days: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # Offboarding policy (migration 033) — decides what the platform does # with this software's seats when a holder leaves. # ``sso_cascade`` — seats die when Google/Feishu SSO is suspended; no # action. No connector required. (Default.) # ``auto_revoke`` — call the software's connector driver to revoke. # Requires a configured ``software_connectors`` row. offboarding_policy: Mapped[str] = mapped_column( String(32), nullable=False, default="sso_cascade", ) offboarding_config: Mapped[dict | None] = mapped_column(JSON) # Self-cancel policy (migration 361) — may a seat HOLDER drop their # own seat from the self-service portal / MCP, or is this software # IT-managed? # ``True`` (default) — the employee may schedule "cancel next # month" on their own seat. The normal self-service path. # ``False`` — managed software (Google Workspace, Feishu, and other # identity-tier tools): the employee must NOT be able to # self-cancel, because the account is provisioned/managed # by IT and tied to SSO/identity. Asset/global admins can # still cancel on the employee's behalf (admin-on-behalf); # the gate only blocks the self path. allow_self_cancel: Mapped[bool] = mapped_column( Boolean, nullable=False, default=True, server_default=text("1"), ) # Free-text category that replaces the legacy ``is_ai_tool`` flag. # Recognised values today: ``ai`` (chargeback dashboard's source of # truth), ``im``, ``mail``, ``cloud``, ``dev``, ``office``, # ``security``, ``other`` (fallback). Migration 082 backfills # category=ai for every row that was previously is_ai_tool=true and # then drops the boolean. New categories are admin-editable from # /admin/assets/software-catalog and can be invented freely — the # AI-spending dedup only filters on ``category='ai'`` so unknown # values are harmless. Default ``other`` keeps the column NOT NULL # without forcing an explicit value on every catalog row. category: Mapped[str] = mapped_column( String(32), nullable=False, default="other", server_default=text("'other'"), ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), ) class AssetSupplier(Base): """Supplier master data used by procurement and software payments.""" __tablename__ = "asset_suppliers" __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column( Uuid, primary_key=True, default=uuid.uuid4, ) name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True) contact_name: Mapped[str | None] = mapped_column(String(64)) contact_phone: Mapped[str | None] = mapped_column(String(32)) remark: Mapped[str | None] = mapped_column(Text) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=text("1")) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), ) class AssetBusinessPayment(Base): """Business payment tracking for software subscriptions.""" __tablename__ = "asset_business_payments" __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column( Uuid, primary_key=True, default=uuid.uuid4, ) software_catalog_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("asset_software_catalog.id"), ) procurement_order_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("asset_procurement_orders.id"), ) supplier_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("asset_suppliers.id"), ) software_name: Mapped[str] = mapped_column(String(128), nullable=False) software_type: Mapped[str | None] = mapped_column(String(64)) payment_frequency: Mapped[str | None] = mapped_column(String(64)) payment_method: Mapped[str | None] = mapped_column(String(64)) signing_entity: Mapped[str | None] = mapped_column(String(128)) po_number: Mapped[str | None] = mapped_column(String(64)) contract_expiry: Mapped[str | None] = mapped_column(String(128)) requester: Mapped[str | None] = mapped_column(String(64)) requester_person_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), ) amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2)) remark: Mapped[str | None] = mapped_column(Text) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), ) class AssetLegalEntity(Base): """Company legal entity information (tax, bank, etc).""" __tablename__ = "asset_legal_entities" __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column( Uuid, primary_key=True, default=uuid.uuid4, ) name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True) # Short tag-safe identifier (CN / US / SG / JP) embedded in the # asset-tag auto-generation rule: # ``IT-{purchase_entity.code}-{category code}-{YYYYMMDDHHMMSS}``. # Nullable so existing rows backfill as NULL — intake refuses to # auto-generate a tag against an entity with no code (422), which # nudges admins to fill it via the OfficeAddress 公司主体信息 tab. # NOT unique on purpose (migration 133 dropped the unique index): # multiple Chinese subsidiaries legitimately share ``CN`` as the # tag-prefix slug; tag uniqueness is enforced at the asset row # level by ``asset_items.asset_tag`` UNIQUE. code: Mapped[str | None] = mapped_column(String(16)) country_or_region: Mapped[str | None] = mapped_column(String(64)) tax_identifier: Mapped[str | None] = mapped_column(String(128)) registered_address: Mapped[str | None] = mapped_column(String(512)) phone: Mapped[str | None] = mapped_column(String(64)) bank_name: Mapped[str | None] = mapped_column(String(128)) bank_account: Mapped[str | None] = mapped_column(String(128)) bank_account_name: Mapped[str | None] = mapped_column(String(128)) bank_address: Mapped[str | None] = mapped_column(String(512)) swift_code: Mapped[str | None] = mapped_column(String(32)) currency: Mapped[str | None] = mapped_column(String(8)) account_type: Mapped[str | None] = mapped_column(String(64)) notes: Mapped[str | None] = mapped_column(Text) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=text("1")) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), ) class AssetOfficeAddress(Base): """Office addresses for each city. The 当地 Admin list lives entirely in the ``asset_office_local_admins`` join table, with one row flagged ``is_primary``. A denormalised ``admin_person_id`` column mirrored that primary until migration 540 — two places recording one fact, written by a single code path and read by three others. Single-contact callers use ``office_local_admins.get_office_primary_local_admin()``. As of migration 535 these people carry **no asset authority**: the ``asset_admin`` role and every regional write gate derive from ``asset_location_admins`` instead. An office's 当地 Admin is the local IT contact for the humans who sit there, nothing more. """ __tablename__ = "asset_office_addresses" __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column( Uuid, primary_key=True, default=uuid.uuid4, ) city: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) office_name: Mapped[str | None] = mapped_column(String(128)) country_or_region: Mapped[str | None] = mapped_column(String(64)) contact_name: Mapped[str | None] = mapped_column(String(64)) contact_phone: Mapped[str | None] = mapped_column(String(32)) address: Mapped[str | None] = mapped_column(String(512)) address_en: Mapped[str | None] = mapped_column(String(512)) legal_entity_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("asset_legal_entities.id"), ) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=text("1")) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), ) class AssetOfficeLocalAdmin(Base): """Many-to-many: which persons are the 当地 Admin of which office. An office's local IT contact. Read only by the person-anchored flows — the Day-1 welcome CC audience, the onboarding hardware recipient, and the offboarding handover suggestion — all of which key off where a *person* sits, not where an *asset* lives. This table granted the ``asset_admin`` role until migration 535 (when it was still named ``asset_office_admins``). It no longer does: asset authority derives from ``AssetLocationAdmin``. Nothing here scopes an asset write, and editing this list changes no role. Renamed at the same time so the two tables can't be mistaken for one another. Composite PK on (office_id, person_id). ``is_primary`` marks the row that single-contact callers address. """ __tablename__ = "asset_office_local_admins" __mapper_args__ = {"eager_defaults": True} office_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("asset_office_addresses.id", ondelete="CASCADE"), primary_key=True, ) person_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("identity_persons.id", ondelete="CASCADE"), primary_key=True, ) is_primary: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, server_default=text("0"), ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) class EmployeeRequest(Base): """Employee self-service requests — unified table for all request types. Originally ``asset_employee_requests`` (assets only); renamed in migration 030 to be the single home for self-service requests across domains (laptop replacement, computer borrow, asset return, offboarding buyback, software seat, api_key_request, and anything added later). Domain-specific fields live in ``detail`` (JSON) and, for the api_key case, in the linked ``api_key_assignments`` row via ``api_key_assignments.employee_request_id``. """ __tablename__ = "employee_requests" __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column( Uuid, primary_key=True, default=uuid.uuid4, ) request_number: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) request_type: Mapped[str] = mapped_column(String(32), nullable=False) status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending") requester_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("identity_persons.id"), nullable=False, ) asset_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("asset_items.id"), ) category: Mapped[str | None] = mapped_column(String(32)) target_user_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), ) reason: Mapped[str | None] = mapped_column(Text) detail: Mapped[Any | None] = mapped_column(JSON) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) processed_by: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), ) notes: Mapped[str | None] = mapped_column(Text) # Backward-compat alias — in-progress callers still reference the old name. # Delete once ApiKeyService dual-write lands and the name has propagated. AssetEmployeeRequest = EmployeeRequest class SoftwareLicensePlan(Base): """License plan (seat type) for a software catalog entry. Each software can have multiple plans, e.g. "Basic ¥100/mo", "Pro ¥200/yr". """ __tablename__ = "software_license_plans" __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) # ondelete defaults to RESTRICT — deleting a software_catalog row # while it still has plans is refused at both the application # layer (delete_software_catalog refuses with 409 when plans exist) # and at the DB layer. The previous shape used CASCADE, which silently # vaporised every plan when admin clicked "delete software" — and # because ``software_monthly_bills.plan_id`` is a bare UUID with no # FK (audit-trail isolation), historical bills were left dangling. # See migration 119 for the FK rename. software_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("asset_software_catalog.id"), nullable=False, index=True, ) name: Mapped[str] = mapped_column(String(128), nullable=False) price: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False) billing_cycle: Mapped[str] = mapped_column( String(16), nullable=False, ) # "monthly", "yearly", "one_time" description: Mapped[str | None] = mapped_column(Text) # Seat quota. NULL = unlimited (default). Integer = hard cap enforced # by the provisioning service on grant; requests exceeding the cap # are routed to an "over-quota approval" path instead of dispatched # to the driver (see provisioning service `enforce_plan_quota`). max_seats: Mapped[int | None] = mapped_column(Integer, nullable=True) # Policy when a grant would push the plan over ``max_seats``: # ``reject`` — default; raise QUOTA_EXCEEDED at request time # ``evict_lru`` — accept the request, approval proceeds; at grant # dispatch the worker evicts the least-recently- # logged-in seat holder (with a whitelist) and # then runs the grant. Used for Zoom. over_quota_policy: Mapped[str] = mapped_column( String(16), nullable=False, default="reject", server_default="reject", ) # ISO 4217 currency code for ``price``. Defaults to ``CNY`` to # preserve historical billing aggregation behaviour for plans # created before the column existed. Mixed-currency aggregation # is refused at the service layer (see provisioning.compute_billing # and asset.billing_service); admin must split a multi-currency # tenant into per-currency totals. currency: Mapped[str] = mapped_column( String(3), nullable=False, default="CNY", server_default="CNY", ) status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), ) software: Mapped["AssetSoftwareCatalog"] = relationship(lazy="joined") class SoftwareSeatAllocation(Base): """Records a seat allocated to a user for a specific license plan. Created after approval; tracks cost attribution per person/department. """ __tablename__ = "software_seat_allocations" __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) plan_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("software_license_plans.id"), nullable=False, index=True, ) user_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("identity_persons.id"), nullable=False, index=True, ) status: Mapped[str] = mapped_column( String(16), nullable=False, default="active", ) # "active", "revoked" reason: Mapped[str | None] = mapped_column(Text) approval_request_id: Mapped[uuid.UUID | None] = mapped_column(Uuid) allocated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # Self-service "cancel at end of next billable period" marker. # Set when an employee (or admin on their behalf) requests the seat # be revoked at a future month boundary instead of immediately — # they keep the seat for the rest of the period they've already # billed for, then the ``software_seat.auto_revoke_pending_cancels`` # cron job picks them up and runs the standard revoke path. NULL # when no cancellation is pending. The companion ``cancel_requested`` # fields capture who/when so the audit trail survives even after # the cron actually revokes (it logs ``actor_user_id`` as the # original requester, not the system actor). cancel_at_period_end: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), ) cancel_requested_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), ) cancel_requested_by: Mapped[uuid.UUID | None] = mapped_column(Uuid) cancel_reason: Mapped[str | None] = mapped_column(Text) # Drift cleanup grace tracking. Stamped by a seat-sync handler # the first time a seat's vendor-side membership is found # missing; cleared again the moment the membership reappears. # Handlers that revoke on drift compare ``now - drift_observed_at`` # against the per-vendor grace window. NULL means "currently in # roster (or never observed missing)". Used by feishu.seat_sync; # other vendors may opt in later. drift_observed_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), ) # ── Idle-seat reclaim (software_seat.idle_reclaim) ────────────── # Lifecycle: the daily idle job stamps ``idle_warned_at`` the first # time a seat crosses the idle threshold (start of the grace # buffer); after the buffer elapses with the seat still idle it is # reclaimed (status→revoked). ``idle_kept_until`` is the employee's # (or admin's) self-service "keep my seat" opt-out — while it's set # and in the future the job skips the seat entirely. Keeping clears # ``idle_warned_at`` so that when the keep window later expires the # employee gets a fresh warning + full buffer rather than an instant # reclaim. ``idle_manual_review_at`` records when the job punted a # seat to a human because removing the bound group alone would NOT # fully revoke the product (the holder has it via another group too) # — it dedups the admin notification so the same seat isn't re-paged # every tick. idle_warned_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), ) idle_kept_until: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), ) idle_manual_review_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), ) plan: Mapped["SoftwareLicensePlan"] = relationship(lazy="joined") class SoftwareExternalHolder(Base): """A vendor license holder that DEVIATES from "active Helix employee". Written by every ``*.seat_sync`` handler: after a sync lists the real license holders on the vendor side (Atlassian product_access, Zoom roster, Google tenant, Feishu roster, Slack members) and classifies each into one of 6 mutually-exclusive buckets, the rows persisted here are the four that DEVIATE from the in-use baseline (``employees_active`` — Helix-active person on the paid site with a healthy vendor account — never row-persisted; it's the "everything is fine" category). Together with ``software_seat_sync_stat`` they let the operator answer "WHO is occupying each billable seat" not just "how many". **Category discriminator (since migration 219)** — ``category`` is one of: * ``external`` — vendor account whose email doesn't match any Helix person (partners, contractors, service / bot accounts, ex-employees deleted from HR). The original 2026-05-28 use case; ``person_id`` is NULL. * ``leaver_residual`` — vendor account whose email matches a Helix person whose ``employment_status`` is ``left`` / ``leaver_pending``. The seat should've been revoked by offboarding but didn't (SCIM bounce, default-group reassertion, manual re-add). Highest operational severity — a leaver still has paid access. ``person_id`` points to the leaver. * ``vendor_inactive`` — vendor account_status is NOT ``active`` (Atlassian ``inactive``, Zoom ``deactivated``, Slack ``deleted``, etc.) yet the account still occupies a billable seat per the vendor's billing UI. Often a transitional state during offboarding (vendor side flipped, billing side hasn't released yet). ``person_id`` populated when matched. * ``off_paid_site`` — Atlassian-specific. Vendor account is ``active`` and holds the product, but only on a NON-paid site (sandbox / personal / external Atlassian instances). For marketplace add-ons (Tempo, ScriptRunner) these still get billed org-wide; for native products they don't. Surfaced so the operator can audit "who has a sandbox seat we're not tracking on the paid tenant". A 5th category ``no_email`` (vendor accounts with no email at all — service / API accounts, hand-created consultant accounts) is counted in ``SoftwareSeatSyncStat.holders_no_email`` and, since 2026-07-02, MAY additionally be row-persisted when the handler can supply a vendor-side ``holder_key`` (Feishu: ``feishu-user:``; ``email`` is NULL on those rows). Handlers without a stable vendor key remain count-only. Persisting them makes the account visible + annotatable in the external-accounts directory instead of being an anonymous counter. Lifecycle: upserted (``last_observed_at`` bumped, ``resolved=False``) every run the holder is still in this category. A holder that disappears from the vendor side OR changes category (e.g. external → leaver_residual when the email is finally HRIS'd in as someone who left) is marked ``resolved=True`` for the prior category by the same run that no longer observes them THERE; a new row appears under the new category. Resolved rows are kept for history. The unique key is (plan_id, holder_key). For per-USER vendors ``holder_key == email`` (one email is in at most one category per plan, category transitions upserted in place — historical behaviour). For per-DEVICE vendors (Jamf MDM) ``holder_key`` is the device-unique key (serial) so a single employee holding several devices yields several rows / seats instead of collapsing on a shared email. """ __tablename__ = "software_external_holder" __mapper_args__ = {"eager_defaults": True} __table_args__ = ( # One row per (plan, holder_key) — the upsert key. holder_key is # ``email`` for per-user vendors and the device key for per-device # vendors (Jamf). Category transitions upsert in place. Index( "uq_software_external_holder_plan_holder_key", "plan_id", "holder_key", unique=True, ), # "Show me this software's open external holders" — the drawer # + the company-wide reconciliation aggregation both filter on # (software_id, resolved). Index( "ix_software_external_holder_software_resolved", "software_id", "resolved", ), Index( "ix_software_external_holder_plan_resolved", "plan_id", "resolved", ), # Drawer per-category tab — pull a single category from a plan. Index( "ix_software_external_holder_plan_category", "plan_id", "category", ), # Reverse lookup: "which residual seats does this leaver still # hold?" — used by the offboarding view's per-person residual # badge. Index( "ix_software_external_holder_person_id", "person_id", ), ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) integration_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("integrations.id"), nullable=False, index=True, ) software_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("asset_software_catalog.id"), nullable=False, ) plan_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("software_license_plans.id"), nullable=False, ) # Vendor short key: 'jira' | 'zoom' | 'google' | 'feishu' | 'slack'. provider: Mapped[str] = mapped_column(String(32), nullable=False) # Product family / SKU on the vendor side (e.g. 'jira-software', # 'jira-service-desk', 'confluence', 'zoom-base'). NULL for vendors # with a single undifferentiated roster (Slack). product_key: Mapped[str | None] = mapped_column(String(64)) # Vendor site / workspace the holder appears on (Atlassian site, # Slack workspace, Feishu group chat id). NULL when not applicable. # For category='off_paid_site' this stores the NON-paid site host # (sandbox / personal) so the drawer shows "where" the seat lives. site: Mapped[str | None] = mapped_column(String(255)) # Dedup/uniqueness key (since holder_key migration). For per-user # vendors this equals ``email``; for per-device vendors (Jamf) it's # the device-unique key (serial). Always populated. Backfilled to # ``email`` for pre-migration rows. The ORM default falls back to the # row's (lowercased) ``email`` when an inserter doesn't set it — so # per-user callers/factories that only know ``email`` keep working # with the historical (plan, email) uniqueness semantics. holder_key: Mapped[str] = mapped_column( String(255), nullable=False, default=lambda ctx: ( (ctx.get_current_parameters().get("email") or "").strip().lower() or None ), ) # The holder's email — for display + person-linking. Stored # lowercased. NULLABLE now that ``holder_key`` is the join key (a # per-device holder row may carry a null email if the device has no # assigned user; ``category='no_email'`` rows are keyed on a vendor # id like ``feishu-user:``; per-user vendors otherwise set # it == holder_key). Holders with no dedupe key at all are NOT # row-persisted; counted in # ``SoftwareSeatSyncStat.holders_no_email`` instead. email: Mapped[str | None] = mapped_column(String(255), nullable=True) # Mutually-exclusive category — see class docstring. Default # ``'external'`` for back-compat with pre-217 rows; new categories # are written explicitly by the handler. category: Mapped[str] = mapped_column( String(32), nullable=False, default="external", server_default=text("'external'"), ) # Helix person FK — NULL for ``category='external'`` (definitionally # no match); populated for ``leaver_residual`` / ``vendor_inactive`` / # ``off_paid_site`` when the email resolved via the alias index. # ON DELETE SET NULL so deleting a person archives the residual # observation rather than the row vanishing silently. person_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id", ondelete="SET NULL"), nullable=True, ) display_name: Mapped[str | None] = mapped_column(String(255)) # Vendor-side stable id (Atlassian accountId, Zoom user id, Google # id, Feishu union_id, Slack user id) — powers the "open in vendor # admin console" deep-link where the vendor has a stable URL. vendor_account_id: Mapped[str | None] = mapped_column(String(128)) # Vendor account status ('active', 'suspended', 'inactive', ...) so # the drawer can flag e.g. a suspended account still holding a seat. account_status: Mapped[str | None] = mapped_column(String(32)) # Last activity date the vendor reports for this holder on this # product, if any. NULL = vendor never reported activity. last_active_on: Mapped[date_type | None] = mapped_column(Date) first_observed_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) last_observed_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) resolved: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, server_default=text("0"), ) resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), ) class SoftwareSeatSyncStat(Base): """Per-plan reconciliation snapshot from the latest ``*.seat_sync`` run. One row per license plan, upserted each sync. Holds the authoritative headline numbers — every billable seat the vendor counts is bucketed into exactly one of six mutually-exclusive categories, so the operator can answer "WHO is occupying each seat" not just "how many": * ``vendor_license_holders`` — **total accounts the vendor bills** for this product, org-wide (matches Atlassian's Admin UI count modulo timing slip). The six bucket columns sum to this. * ``holders_matched`` — vendor account is ``active``, email maps to a Helix person whose ``employment_status='active'``, and the holder is on the plan's paid site. "Employees properly using their seat" — the desired baseline; not row-persisted in ``SoftwareExternalHolder``. * ``holders_external`` — vendor account is ``active``, email matches no Helix person (partners, contractors, service / bot accounts). Persisted with ``category='external'``. * ``holders_no_email`` — vendor account with no email at all (service / API accounts). Count-only — not row-persisted because there's no dedupe key. * ``holders_leaver_residual`` (since migration 219) — vendor account is ``active`` and the email matches a Helix person whose ``employment_status='left'`` / ``leaver_pending``. Offboarding leak — the seat should have been revoked by the offboarding flow but wasn't. Highest operational severity. Persisted with ``category='leaver_residual'``. * ``holders_vendor_inactive`` (since migration 219) — vendor ``account_status`` is NOT ``active`` (Atlassian ``inactive``, Zoom ``deactivated``, Slack ``deleted``) yet the account still occupies a billable seat per the vendor's billing UI. Transitional during offboarding. Persisted with ``category='vendor_inactive'``. * ``holders_off_paid_site`` (since migration 219) — Atlassian- specific. Vendor account is ``active`` and holds the product, but only on a NON-paid site (sandbox / personal / external Atlassian instance). Marketplace addons (Tempo, ScriptRunner) still bill these org-wide. Persisted with ``category='off_paid_site'``. * ``synced_at`` / ``run_id`` — provenance so the UI can say "as of