367 lines
17 KiB
Python

"""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)