"""Pydantic schemas for asset inventory (盘点).""" from __future__ import annotations from datetime import UTC, datetime, timedelta from typing import Any, Literal from uuid import UUID from pydantic import BaseModel, Field, field_validator, model_validator from app.core.storage_uploads import sanitize_object_key_list # Cycle status state machine. ``draft`` rows have not been started # yet (no items snapshotted, no notifications sent). ``active`` rows # have items + outstanding pending responses. ``closed`` is terminal. ALLOWED_CYCLE_STATUSES: tuple[str, ...] = ("draft", "active", "closed") # Item status. Mirrors the response state — ``pending`` = no response, # ``confirmed`` = recipient said the asset is fine, ``issue_reported`` # = anything else (admin queue). ALLOWED_ITEM_STATUSES: tuple[str, ...] = ( "pending", "confirmed", "issue_reported", ) # The bucket for idle stock counted by a responsible administrator. # Renamed from ``office_admin`` by migration 543: the recipient is the # administrator of the asset's managed LOCATION, which since migrations # 535-540 is a different person from the office's 当地 Admin. HOLDER_KIND_LOCATION_ADMIN = "location_admin" # Only ``HOLDER_KIND_LOCATION_ADMIN`` is ever written. The old token is # still ACCEPTED on read, and that is load-bearing rather than tidy: # the deploy applies migrations before it restarts containers, so an old # container can write ``office_admin`` against an already-migrated table. # Production had four active cycles when this landed, so those were live # responses, not hypothetical ones. Filter on the pair, never on one. LEGACY_HOLDER_KIND_LOCATION_ADMIN = "office_admin" HOLDER_KINDS_LOCATION_ADMIN: tuple[str, ...] = ( HOLDER_KIND_LOCATION_ADMIN, LEGACY_HOLDER_KIND_LOCATION_ADMIN, ) ALLOWED_HOLDER_KINDS: tuple[str, ...] = ( "assignee", HOLDER_KIND_LOCATION_ADMIN, LEGACY_HOLDER_KIND_LOCATION_ADMIN, "it_fallback", ) # Per-holder-kind allowed response types. The server enforces this map # in ``AssetInventoryService.respond_to_item`` so a recipient can't # submit an option that doesn't apply to their bucket (e.g. an # assignee can't pick ``in_storage``). # # Ownership transfer (``transferred``) is intentionally NOT in any # bucket: user-to-user asset moves are an asset-admin-only operation, # performed through the dedicated ``POST /assets/{id}/transfer`` / # ``change-assignee`` endpoints (require_asset_admin). During a count a # holder who no longer has an asset reports ``not_mine`` (with photo # proof); an admin then executes the real transfer. Historical items # that already recorded ``transferred`` still read back fine — only # creating a NEW transferred response is disallowed. RESPONSE_TYPES_BY_HOLDER_KIND: dict[str, tuple[str, ...]] = { "assignee": ( "in_use", "damaged", "lost", "not_mine", "other", ), HOLDER_KIND_LOCATION_ADMIN: ( "in_storage", "damaged", "lost", "taken_away", "moved_to", "other", ), # Same options under the pre-543 token, so a row that has not been # migrated (or was written by an old container mid-deploy) still # offers its holder the right choices instead of rejecting them. LEGACY_HOLDER_KIND_LOCATION_ADMIN: ( "in_storage", "damaged", "lost", "taken_away", "moved_to", "other", ), # IT fallback responses are by an admin acting on behalf of the # bucket; allow the union since IT might categorise the issue # as either kind. "it_fallback": ( "in_use", "in_storage", "damaged", "lost", "taken_away", "moved_to", "not_mine", "other", ), } # Response types that flip status='confirmed'. Anything else is an # issue. CONFIRMING_RESPONSES: tuple[str, ...] = ("in_use", "in_storage") # Statuses excluded from snapshot. ``disposed/retired/disposal_pending`` # are dead; ``*_pending`` are mid-flux. ``held_by_leaver`` is # attributed to a departed person — by definition no longer recoverable # via the in-app inventory response flow (the holder can't log in). Asset # admins reconcile these rows through the dedicated "追回" affordance on # the asset detail page instead. See DECISIONS.md. EXCLUDED_ASSET_STATUSES: tuple[str, ...] = ( "disposed", "disposal_pending", "claim_pending", "return_pending", "borrow_pending", "borrow_return_pending", "transferring", "held_by_leaver", ) class CycleScopeFilter(BaseModel): """Optional narrowing of what's snapshotted into a cycle. Empty / missing keys mean "no filter on that dimension". An entirely empty filter = company-wide. Validation is intentionally lax — a department name that doesn't match anyone just produces an empty snapshot, which is preferable to a 400 the admin doesn't know how to fix. """ departments: list[str] | None = None locations: list[str] | None = None categories: list[str] | None = None person_ids: list[UUID] | None = None # Holder-presence filter, independent of the other dimensions. # ``all`` → every eligible asset, regardless of assignee. # ``assigned`` → only assets with ``assigned_to IS NOT NULL`` — # the people-in-the-loop slice. The classify step # may still route some of these to office_admin / # it_fallback if the assignee is no longer active. # ``idle`` → only assets with ``assigned_to IS NULL`` — every # row routes to office_admin or it_fallback. Ideal # for warehouse / spare-laptop sweeps where end # users have nothing to confirm. # ``person_ids`` implies a non-null assignee, so combining it with # ``idle`` produces an intentionally empty snapshot (caught by the # auto_start path's ``EMPTY_SNAPSHOT`` guard). assignment_scope: Literal["all", "assigned", "idle"] = "all" def _validate_due_at(value: datetime | None) -> datetime | None: """Reject due dates in the past or absurdly far in the future. The DatePicker on the frontend doesn't disable past dates (it lets the admin pick anything), so a typo (2025-05-28 in 2026 when the admin meant the same year) used to land a cycle whose Feishu card already reads "overdue" the moment it goes out. Two-year ceiling matches the longest reasonable inventory horizon for IT assets (annual + one extra year for spillover); anything past that is almost certainly a wrong year. Naive datetime inputs are coerced to UTC before comparison. The frontend always submits ISO-8601 with timezone (``...Z`` or ``+08:00``); a naive value can only arrive from a script or direct service call, so treating it as UTC matches our DB convention (asyncmy strips tzinfo on MySQL DATETIME write — see [[feedback_mysql_datetime_naive_on_fresh_load]]). NOT mixing naive-local with aware-UTC for the comparison itself was the Round-2 finding: ``datetime.now()`` returns local time which is UTC in our prod EC2 but Beijing in many devs' laptops — the threshold would drift +8h between environments. """ if value is None: return None aware = value if value.tzinfo else value.replace(tzinfo=UTC) now = datetime.now(UTC) # Allow a small 1-hour slack for clock skew between admin browser # and server — same-day cycles are a legitimate "due today by # EOD" pattern. earliest = now - timedelta(hours=1) latest = now + timedelta(days=365 * 2) if aware < earliest: raise ValueError("due_at must not be in the past") if aware > latest: raise ValueError("due_at must be within 2 years from now") return value class CycleCreateRequest(BaseModel): name: str = Field(..., min_length=1, max_length=128) description: str | None = Field(None, max_length=2000) due_at: datetime | None = None scope_filter: CycleScopeFilter | None = None # If True, the snapshot is built and notifications dispatched in # the same request. If False, the cycle is created in ``draft`` and # the admin must call POST .../start later — useful for "preview # the recipient list before committing". auto_start: bool = True @field_validator("due_at") @classmethod def _check_due_at(cls, value: datetime | None) -> datetime | None: return _validate_due_at(value) class CycleUpdateRequest(BaseModel): """Allow lightweight metadata edits while a cycle is draft/active. Scope filter and items are NOT editable post-creation; the snapshot semantics depend on a frozen filter. To change scope, close this cycle and start a new one. """ name: str | None = Field(None, min_length=1, max_length=128) description: str | None = Field(None, max_length=2000) due_at: datetime | None = None @field_validator("due_at") @classmethod def _check_due_at(cls, value: datetime | None) -> datetime | None: return _validate_due_at(value) class CycleResponse(BaseModel): id: UUID name: str description: str | None status: str scope_filter: dict[str, Any] | None started_at: datetime | None due_at: datetime | None closed_at: datetime | None reminder_sent_at: datetime | None created_by: UUID | None created_by_name: str | None closed_by: UUID | None total_items: int pending_count: int confirmed_count: int issue_count: int created_at: datetime updated_at: datetime class CycleListResponse(BaseModel): data: list[CycleResponse] meta: dict[str, Any] class ItemResponseSchema(BaseModel): """One inventory item — what a recipient sees and what admin review pages render. The ``*_snapshot`` fields are the frozen asset shape at cycle creation; the ``current_*`` fields reflect today's truth (useful for admin to spot drift). """ id: UUID cycle_id: UUID asset_id: UUID holder_kind: str holder_person_id: UUID | None holder_name: str | None office_id: UUID | None office_city: str | None asset_tag_snapshot: str | None asset_name_snapshot: str | None asset_category_snapshot: str | None asset_location_snapshot: str | None asset_status_snapshot: str | None current_status: str | None current_assigned_to_name: str | None current_brand: str | None current_model: str | None current_serial_number: str | None status: str response_type: str | None response_notes: str | None transferred_to_id: UUID | None transferred_to_name: str | None new_location: str | None responded_at: datetime | None responded_by: UUID | None reminded_at: datetime | None issue_status: str | None issue_resolved_at: datetime | None issue_resolved_by: UUID | None issue_resolution_notes: str | None evidence_photos: list[str] | None = None created_at: datetime updated_at: datetime # Upper bound on evidence_photos: enough headroom for the typical # "we issued you a Mac Pro but you actually have a MacBook" case # where the holder photographs 2-3 angles, but small enough to keep # the JSON payload trivially-bounded. MAX_EVIDENCE_PHOTOS = 10 def sanitize_evidence_photo_keys( raw: list[str] | None, ) -> list[str] | None: """Normalise + bound-check a list of S3 object keys. Shared between the route-layer Pydantic validator and the service-layer defence-in-depth pass: internal callers (tests / scripted flows) bypass the request schema and the service should still refuse garbage payloads. Returns ``None`` when the input is ``None`` / empty; otherwise returns the stripped list. Raises ``ValueError`` on bad entries. Thin wrapper over the platform-shared :func:`app.core.storage_uploads.sanitize_object_key_list` — the same rules (length, traversal, duplicate) now guard every domain that stores browser-uploaded keys, so a fix lands once. """ return sanitize_object_key_list( raw, field="evidence_photos", max_items=MAX_EVIDENCE_PHOTOS, ) class ItemRespondRequest(BaseModel): response_type: str response_notes: str | None = Field(None, max_length=2000) new_location: str | None = Field(None, max_length=256) evidence_photos: list[str] | None = Field( default=None, max_length=MAX_EVIDENCE_PHOTOS, ) @field_validator("response_type") @classmethod def _validate_response_type(cls, v: str) -> str: # Cross-bucket validation lives in the service (it knows # holder_kind); here we just guard against unknown enums. all_known = { t for opts in RESPONSE_TYPES_BY_HOLDER_KIND.values() for t in opts } if v not in all_known: raise ValueError(f"unknown response_type: {v}") return v @field_validator("evidence_photos") @classmethod def _validate_photo_keys(cls, v: list[str] | None) -> list[str] | None: return sanitize_evidence_photo_keys(v) @model_validator(mode="after") def _require_target_for_branch(self) -> ItemRespondRequest: if self.response_type == "moved_to" and not self.new_location: raise ValueError("moved_to response requires new_location") # "记录有误,不属于我" requires photo proof of the actual hardware # SN — without it the IT desk has nothing to reconcile against. if self.response_type == "not_mine" and not self.evidence_photos: raise ValueError( "not_mine response requires at least one evidence photo", ) return self class ItemIssueResolveRequest(BaseModel): issue_resolution_notes: str | None = Field(None, max_length=2000) class CycleRemindRequest(BaseModel): """Options for a manual admin remind. ``force`` bypasses the working-hours gate + per-day dedup that the cron honours (the admin clicked "立即催办"). ``notify_manager`` / ``notify_hrbp`` additionally send ONE aggregated nudge digest per direct Lead / HRBP listing their still-unresponsive people; both default off so the existing behaviour (employee-only push) is unchanged unless the admin opts in. """ force: bool = False notify_manager: bool = False notify_hrbp: bool = False class CycleSnapshotPreview(BaseModel): """What the admin sees when previewing a draft cycle's recipient breakdown before committing. """ total_assets: int assignee_items: int location_admin_items: int it_fallback_items: int distinct_recipients: int sample_assignee_persons: list[dict[str, Any]] sample_location_admins: list[dict[str, Any]] sample_it_fallback_assets: list[dict[str, Any]]