"""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, Index, Integer, Numeric, String, Text, 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") 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 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 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)