"""PRD-defined core PostgreSQL models for the risk prediction platform.""" from datetime import date, datetime from decimal import Decimal from typing import Any from sqlalchemy import ( BigInteger, Boolean, Date, DateTime, ForeignKey, ForeignKeyConstraint, Index, Integer, Numeric, String, Text, UniqueConstraint, func, ) from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database.base import Base class AnomalyCase(Base): """Approved historical anomaly case used by the experience engine.""" __tablename__ = "anomaly_cases" __table_args__ = ( Index("ix_anomaly_cases_date", "date"), Index("ix_anomaly_cases_product_category", "product_category"), Index("ix_anomaly_cases_supplier", "supplier"), Index("ix_anomaly_cases_factory", "factory"), Index("ix_anomaly_cases_material_type", "material_type"), Index("ix_anomaly_cases_defect_level", "defect_level"), ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) date: Mapped[date | None] = mapped_column(Date) product_code: Mapped[str | None] = mapped_column(String(100)) product_name: Mapped[str | None] = mapped_column(String(500)) brand: Mapped[str | None] = mapped_column(String(100)) specification: Mapped[str | None] = mapped_column(String(100)) product_category: Mapped[str | None] = mapped_column(String(100)) stage_new: Mapped[str | None] = mapped_column(String(100)) stage_old: Mapped[str | None] = mapped_column(String(100)) anomaly_type: Mapped[str | None] = mapped_column(String(200)) material_type: Mapped[str | None] = mapped_column(String(200)) supplier: Mapped[str | None] = mapped_column(String(300)) factory: Mapped[str | None] = mapped_column(String(300)) person: Mapped[str | None] = mapped_column(String(200)) description: Mapped[str | None] = mapped_column(Text) root_cause: Mapped[str | None] = mapped_column(Text) solution: Mapped[str | None] = mapped_column(Text) result: Mapped[str | None] = mapped_column(Text) responsible: Mapped[str | None] = mapped_column(String(300)) defect_level: Mapped[str | None] = mapped_column(String(10)) is_resolved: Mapped[str | None] = mapped_column(Text) source_file: Mapped[str | None] = mapped_column(String(300)) source_type: Mapped[str | None] = mapped_column(String(30)) remark: Mapped[str | None] = mapped_column(Text) import_fingerprint: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) standard_rule_id: Mapped[int | None] = mapped_column( BigInteger, ForeignKey("standard_rules.id", ondelete="SET NULL"), ) # pgvector is not installed on the target PostgreSQL server. Store vectors as JSONB until it is available. embedding: Mapped[list[float] | None] = mapped_column(JSONB) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) standard_rule: Mapped["StandardRule | None"] = relationship(back_populates="anomaly_cases") review_records: Mapped[list["ReviewRecord"]] = relationship(back_populates="case") supplier_links: Mapped[list["AnomalyCaseSupplier"]] = relationship( back_populates="case", cascade="all, delete-orphan" ) class SupplierMaster(Base): """Canonical supplier entity independent from raw workbook text.""" __tablename__ = "supplier_master" __table_args__ = ( Index("ix_supplier_master_city", "city"), Index("ix_supplier_master_normalized_name", "normalized_name", unique=True), ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) canonical_name: Mapped[str] = mapped_column(String(200), nullable=False) short_name: Mapped[str | None] = mapped_column(String(100)) normalized_name: Mapped[str] = mapped_column(String(200), nullable=False) city: Mapped[str | None] = mapped_column(String(50)) province: Mapped[str | None] = mapped_column(String(50)) supplier_type: Mapped[str | None] = mapped_column(String(50)) aliases: Mapped[list[str]] = mapped_column(JSONB, default=list, server_default="[]", nullable=False) status: Mapped[str] = mapped_column(String(20), default="启用", server_default="启用", nullable=False) source: Mapped[str | None] = mapped_column(String(100)) review_status: Mapped[str] = mapped_column(String(20), default="待确认", server_default="待确认", nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False ) anomaly_links: Mapped[list["AnomalyCaseSupplier"]] = relationship( back_populates="supplier", cascade="all, delete-orphan" ) class AnomalyCaseSupplier(Base): """Many-to-many mapping from an anomaly case to canonical suppliers.""" __tablename__ = "anomaly_case_suppliers" __table_args__ = ( UniqueConstraint("case_id", "supplier_id", "supplier_role", name="uq_case_supplier_role"), Index("ix_case_suppliers_supplier", "supplier_id"), Index("ix_case_suppliers_case", "case_id"), ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) case_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("anomaly_cases.id", ondelete="CASCADE"), nullable=False) supplier_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("supplier_master.id", ondelete="CASCADE"), nullable=False ) supplier_role: Mapped[str | None] = mapped_column(String(50)) material_type: Mapped[str | None] = mapped_column(String(200)) source_text: Mapped[str | None] = mapped_column(Text) match_method: Mapped[str] = mapped_column(String(30), default="manual", server_default="manual", nullable=False) match_confidence: Mapped[Decimal | None] = mapped_column(Numeric(5, 4)) review_status: Mapped[str] = mapped_column(String(20), default="待确认", server_default="待确认", nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) case: Mapped[AnomalyCase] = relationship(back_populates="supplier_links") supplier: Mapped[SupplierMaster] = relationship(back_populates="anomaly_links") class SupplierProfile(Base): """Aggregated supplier risk profile.""" __tablename__ = "supplier_profiles" id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) supplier_name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) total_anomalies: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False) frequency_coef: Mapped[Decimal] = mapped_column(Numeric(4, 2), default=Decimal("1.00"), server_default="1.00") tier: Mapped[str | None] = mapped_column(String(1)) category_breakdown: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, server_default="{}", nullable=False) product_cat_breakdown: Mapped[dict[str, Any]] = mapped_column( JSONB, default=dict, server_default="{}", nullable=False, ) stage_breakdown: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, server_default="{}", nullable=False) monthly_trend: Mapped[list[dict[str, Any]]] = mapped_column( JSONB, default=list, server_default="[]", nullable=False, ) risk_score: Mapped[int | None] = mapped_column(Integer) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False, ) class PurchaseOrderLine(Base): """A source row from the procurement order ledger.""" __tablename__ = "purchase_order_lines" __table_args__ = ( Index("ix_purchase_order_lines_supplier_date", "supplier", "purchase_date"), Index("ix_purchase_order_lines_document", "document_number"), Index("uq_purchase_order_lines_source", "import_fingerprint", unique=True), ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) document_number: Mapped[str] = mapped_column(String(64), nullable=False) purchase_date: Mapped[date | None] = mapped_column(Date) supplier: Mapped[str] = mapped_column(String(300), nullable=False) document_status: Mapped[str | None] = mapped_column(String(30)) close_status: Mapped[str | None] = mapped_column(String(30)) material_code: Mapped[str | None] = mapped_column(String(200)) material_name: Mapped[str | None] = mapped_column(Text) purchase_unit: Mapped[str | None] = mapped_column(String(30)) purchase_quantity: Mapped[Decimal | None] = mapped_column(Numeric(18, 4)) delivery_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) tax_inclusive_unit_price: Mapped[Decimal | None] = mapped_column(Numeric(18, 6)) is_gift: Mapped[str | None] = mapped_column(String(10)) business_close: Mapped[str | None] = mapped_column(String(30)) remark: Mapped[str | None] = mapped_column(Text) source_file: Mapped[str] = mapped_column(String(300), nullable=False) import_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) class RiskAssessment(Base): """A user-triggered risk assessment and its structured result.""" __tablename__ = "risk_assessments" __table_args__ = ( Index("ix_risk_assessments_user_id", "user_id"), Index("ix_risk_assessments_created_at", "created_at"), ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) user_id: Mapped[str | None] = mapped_column(String(50)) product_name: Mapped[str | None] = mapped_column(String(200)) params: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, server_default="{}", nullable=False) risk_score: Mapped[int | None] = mapped_column(Integer) risk_level: Mapped[str | None] = mapped_column(String(10)) dimension_scores: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, server_default="{}", nullable=False) similar_cases: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, server_default="{}", nullable=False) ai_suggestions: Mapped[str | None] = mapped_column(Text) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) class Conversation(Base): """A persistent assistant conversation owned by one authenticated user.""" __tablename__ = "conversations" __table_args__ = (Index("ix_conversations_user_updated", "user_id", "updated_at"),) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) user_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("user_accounts.id", ondelete="CASCADE"), nullable=False, ) title: Mapped[str] = mapped_column(String(120), nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False, ) messages: Mapped[list["ConversationMessage"]] = relationship( back_populates="conversation", cascade="all, delete-orphan", order_by="ConversationMessage.created_at", ) class ConversationMessage(Base): """One user or assistant message, including optional structured results.""" __tablename__ = "conversation_messages" __table_args__ = (Index("ix_conversation_messages_conversation_created", "conversation_id", "created_at"),) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) conversation_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("conversations.id", ondelete="CASCADE"), nullable=False, ) role: Mapped[str] = mapped_column(String(20), nullable=False) message_type: Mapped[str] = mapped_column(String(20), default="text", server_default="text", nullable=False) content: Mapped[str] = mapped_column(Text, nullable=False) structured_data: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, server_default="{}", nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) conversation: Mapped[Conversation] = relationship(back_populates="messages") class IntegrationNonce(Base): """Durable replay protection shared by all API workers.""" __tablename__ = "integration_nonces" __table_args__ = ( UniqueConstraint("client_id", "nonce", name="uq_integration_nonce"), Index("ix_integration_nonce_expires", "expires_at"), Index("ix_integration_nonce_client_created", "client_id", "created_at"), ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) client_id: Mapped[str] = mapped_column(String(64), nullable=False) nonce: Mapped[str] = mapped_column(String(64), nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) class IntegrationAssessment(Base): """Immutable successful reports and recoverable requests from external systems.""" __tablename__ = "integration_assessments" __table_args__ = ( UniqueConstraint("client_id", "request_id", name="uq_integration_assessment_request"), Index("ix_integration_assessment_user", "user_id"), ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) client_id: Mapped[str] = mapped_column(String(64), nullable=False) request_id: Mapped[str] = mapped_column(String(128), nullable=False) # Logical reference, like OperationAudit: migration does not require # REFERENCES on the pre-existing user table, and reports survive account removal. user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) request_hash: Mapped[str] = mapped_column(String(64), nullable=False) request_data: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) as_of_date: Mapped[date] = mapped_column(Date, nullable=False) status: Mapped[str] = mapped_column(String(20), nullable=False) owner_token: Mapped[str] = mapped_column(String(32), nullable=False) lease_until: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) result_data: Mapped[dict[str, Any] | None] = mapped_column(JSONB) report_markdown: Mapped[str | None] = mapped_column(Text) error_message: Mapped[str | None] = mapped_column(String(200)) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) class IntegrationConversation(Base): """One operator-owned conversation attached to an immutable report.""" __tablename__ = "integration_conversations" __table_args__ = ( ForeignKeyConstraint( ["client_id", "report_request_id"], ["integration_assessments.client_id", "integration_assessments.request_id"], ondelete="CASCADE", name="fk_integration_conversation_report", ), UniqueConstraint("client_id", "report_request_id", name="uq_integration_conversation_report"), UniqueConstraint("conversation_id", name="uq_integration_conversation_thread"), Index("ix_integration_conversation_user", "user_id"), ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) client_id: Mapped[str] = mapped_column(String(64), nullable=False) report_request_id: Mapped[str] = mapped_column(String(128), nullable=False) conversation_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("conversations.id", ondelete="CASCADE"), nullable=False, ) user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) class IntegrationConversationRequest(Base): """Idempotent external message request and recoverable generation lease.""" __tablename__ = "integration_conversation_requests" __table_args__ = ( UniqueConstraint( "client_id", "conversation_id", "request_id", name="uq_integration_conversation_request" ), Index("ix_integration_conversation_request_owner", "client_id", "conversation_id"), ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) client_id: Mapped[str] = mapped_column(String(64), nullable=False) conversation_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("conversations.id", ondelete="CASCADE"), nullable=False, ) request_id: Mapped[str] = mapped_column(String(128), nullable=False) user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) content_hash: Mapped[str] = mapped_column(String(64), nullable=False) status: Mapped[str] = mapped_column(String(20), nullable=False) owner_token: Mapped[str] = mapped_column(String(32), nullable=False) lease_until: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) user_message_id: Mapped[int | None] = mapped_column(BigInteger) assistant_message_id: Mapped[int | None] = mapped_column(BigInteger) error_message: Mapped[str | None] = mapped_column(String(200)) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) class RiskItem(Base): """Risk item submitted through form, voice, OCR, or import before approval.""" __tablename__ = "risk_items" __table_args__ = ( Index("ix_risk_items_status", "status"), Index("ix_risk_items_submitted_by", "submitted_by"), Index("ix_risk_items_submitted_at", "submitted_at"), Index("ix_risk_items_occurred_on", "occurred_on"), Index("ix_risk_items_material_type", "material_type"), ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) source_type: Mapped[str] = mapped_column(String(20), nullable=False) source_file_id: Mapped[str | None] = mapped_column(String(50)) occurred_on: Mapped[date | None] = mapped_column(Date) product_name: Mapped[str | None] = mapped_column(String(200)) product_category: Mapped[str | None] = mapped_column(String(50)) material_type: Mapped[str | None] = mapped_column(String(200)) supplier: Mapped[str | None] = mapped_column(String(100)) factory: Mapped[str | None] = mapped_column(String(100)) stage_new: Mapped[str | None] = mapped_column(String(50)) description: Mapped[str | None] = mapped_column(Text) defect_level: Mapped[str | None] = mapped_column(String(5)) solution: Mapped[str | None] = mapped_column(Text) photos: Mapped[list[str]] = mapped_column(JSONB, default=list, server_default="[]", nullable=False) recognition_confidence: Mapped[Decimal | None] = mapped_column(Numeric(5, 4)) ocr_payload: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, server_default="{}", nullable=False) submitted_by: Mapped[str | None] = mapped_column(String(50)) submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) status: Mapped[str] = mapped_column(String(20), default="待预审", server_default="待预审", nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) review_records: Mapped[list["ReviewRecord"]] = relationship( back_populates="risk_item", cascade="all, delete-orphan", ) class ReviewRecord(Base): """Immutable review trail for a submitted risk item.""" __tablename__ = "review_records" __table_args__ = ( Index("ix_review_records_risk_item_id", "risk_item_id"), Index("ix_review_records_reviewer", "reviewer"), ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) risk_item_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("risk_items.id", ondelete="CASCADE"), nullable=False, ) pre_review_result: Mapped[str | None] = mapped_column(String(20)) pre_review_note: Mapped[str | None] = mapped_column(Text) reviewer: Mapped[str | None] = mapped_column(String(50)) review_action: Mapped[str | None] = mapped_column(String(20)) review_note: Mapped[str | None] = mapped_column(Text) reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) case_id: Mapped[int | None] = mapped_column( BigInteger, ForeignKey("anomaly_cases.id", ondelete="SET NULL"), ) risk_item: Mapped[RiskItem] = relationship(back_populates="review_records") case: Mapped[AnomalyCase | None] = relationship(back_populates="review_records") class StandardRule(Base): """Four-level judgment standard used by the rule engine.""" __tablename__ = "standard_rules" __table_args__ = ( Index("ix_standard_rules_categories", "category_lv1", "category_lv2", "category_lv3"), Index("ix_standard_rules_status", "status"), ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) category_lv1: Mapped[str] = mapped_column(String(20), nullable=False) category_lv2: Mapped[str | None] = mapped_column(String(60)) category_lv3: Mapped[str | None] = mapped_column(String(60)) risk_point: Mapped[str] = mapped_column(Text, nullable=False) judge_standard: Mapped[str] = mapped_column(Text, nullable=False) regulation: Mapped[str | None] = mapped_column(String(300)) attachment: Mapped[str | None] = mapped_column(String(500)) status: Mapped[str] = mapped_column(String(10), default="启用", server_default="启用", nullable=False) import_fingerprint: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False, ) anomaly_cases: Mapped[list[AnomalyCase]] = relationship(back_populates="standard_rule") class UserAccount(Base): """Enterprise user account and RBAC role assignment.""" __tablename__ = "user_accounts" id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) emp_no: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) email: Mapped[str | None] = mapped_column(String(128), unique=True) name: Mapped[str] = mapped_column(String(50), nullable=False) dept: Mapped[str | None] = mapped_column(String(50)) position: Mapped[str | None] = mapped_column(String(50)) role: Mapped[str] = mapped_column(String(20), nullable=False) password_hash: Mapped[str | None] = mapped_column(String(255)) status: Mapped[str] = mapped_column(String(10), default="启用", server_default="启用", nullable=False) login_failed_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False) locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) dingtalk_id: Mapped[str | None] = mapped_column(String(50), unique=True) dingtalk_union_id: Mapped[str | None] = mapped_column(String(64)) dingtalk_avatar: Mapped[str | None] = mapped_column(String(512)) dingtalk_job_number: Mapped[str | None] = mapped_column(String(64)) dingtalk_title: Mapped[str | None] = mapped_column(String(128)) dingtalk_dept_id: Mapped[int | None] = mapped_column(BigInteger) dingtalk_is_dept_manager: Mapped[bool] = mapped_column( Boolean, default=False, server_default="false", nullable=False, ) dingtalk_role_codes: Mapped[str | None] = mapped_column(String(512)) last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) extra_perms: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, default=list, server_default="[]", nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False, ) class RolePermission(Base): """Function and data-level permissions assigned to a role.""" __tablename__ = "role_permissions" __table_args__ = ( Index("uq_role_permissions_role_func", "role", "func_key", unique=True), Index("ix_role_permissions_role", "role"), ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) role: Mapped[str] = mapped_column(String(20), nullable=False) func_key: Mapped[str] = mapped_column(String(50), nullable=False) data_level: Mapped[str] = mapped_column(String(10), default="L1", server_default="L1", nullable=False) can_view: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false", nullable=False) can_operate: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false", nullable=False) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False, ) class RiskModelConfig(Base): """Administrator-managed seven-dimension weights and frequency parameters.""" __tablename__ = "risk_model_configs" id: Mapped[int] = mapped_column(BigInteger, primary_key=True, default=1) weights: Mapped[dict[str, float]] = mapped_column(JSONB, nullable=False) frequency_coefficient: Mapped[Decimal] = mapped_column(Numeric(5, 4), nullable=False) frequency_cap: Mapped[Decimal] = mapped_column(Numeric(4, 2), nullable=False) updated_by: Mapped[str | None] = mapped_column(String(20)) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False ) class OperationAudit(Base): """Append-only audit record for authentication, permission, and review actions.""" __tablename__ = "operation_audits" __table_args__ = ( Index("ix_operation_audits_actor_created", "actor_user_id", "created_at"), Index("ix_operation_audits_action_created", "action", "created_at"), ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) actor_user_id: Mapped[int | None] = mapped_column(BigInteger) actor_emp_no: Mapped[str | None] = mapped_column(String(20)) action: Mapped[str] = mapped_column(String(64), nullable=False) resource_type: Mapped[str | None] = mapped_column(String(64)) resource_id: Mapped[str | None] = mapped_column(String(128)) result: Mapped[str] = mapped_column(String(20), default="success", server_default="success", nullable=False) audit_metadata: Mapped[dict[str, Any]] = mapped_column( "metadata", JSONB, default=dict, server_default="{}", nullable=False, ) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)