from __future__ import annotations import uuid from datetime import datetime from decimal import Decimal from typing import Any from sqlalchemy import ( CheckConstraint, DateTime, ForeignKeyConstraint, Index, Integer, Numeric, String, Text, UniqueConstraint, func, ) from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.types import JSON from app.db.base_class import Base def _new_id() -> str: return str(uuid.uuid4()) class FinancialConnectorConfig(Base): """租户绑定的连接器契约;只保存服务端密钥引用,不保存密钥。""" __tablename__ = "financial_connector_configs" __table_args__ = ( UniqueConstraint("tenant_id", "id", name="uq_financial_connector_configs_tenant_id"), UniqueConstraint( "tenant_id", "provider", "key_version", name="uq_financial_connector_configs_tenant_provider_key", ), CheckConstraint( "environment IN ('test', 'mock', 'staging', 'production')", name="ck_financial_connector_configs_environment", ), CheckConstraint( "status IN ('active', 'disabled', 'rotating')", name="ck_financial_connector_configs_status", ), CheckConstraint( "clock_skew_seconds BETWEEN 30 AND 900", name="ck_financial_connector_configs_clock_skew", ), CheckConstraint( "version >= 1", name="ck_financial_connector_configs_version", ), CheckConstraint( "length(trim(provider)) > 0 AND length(trim(key_version)) > 0 " "AND length(trim(secret_ref)) > 0", name="ck_financial_connector_configs_keys", ), Index( "ix_financial_connector_configs_tenant_status", "tenant_id", "status", "provider", ), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id) tenant_id: Mapped[str] = mapped_column(String(64), nullable=False) provider: Mapped[str] = mapped_column(String(80), nullable=False) environment: Mapped[str] = mapped_column(String(16), nullable=False) key_version: Mapped[str] = mapped_column(String(40), nullable=False) secret_ref: Mapped[str] = mapped_column(String(180), nullable=False) allowed_event_types_json: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) clock_skew_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=300) status: Mapped[str] = mapped_column(String(16), nullable=False, default="disabled") version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) last_success_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_error_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_error_code: Mapped[str | None] = mapped_column(String(80)) created_by: Mapped[str] = mapped_column(String(120), nullable=False) 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 FinancialConnectorConfigEvent(Base): """连接器配置生命周期审计事实;不得保存密钥引用或密钥明文。""" __tablename__ = "financial_connector_config_events" __table_args__ = ( UniqueConstraint( "tenant_id", "id", name="uq_financial_connector_config_events_tenant_id", ), UniqueConstraint( "tenant_id", "request_id", "action", name="uq_financial_connector_config_events_tenant_request_action", ), ForeignKeyConstraint( ["tenant_id", "config_id"], ["financial_connector_configs.tenant_id", "financial_connector_configs.id"], name="fk_financial_connector_config_events_tenant_config", ondelete="RESTRICT", ), CheckConstraint( "action IN ('created', 'activated', 'disabled', " "'rotation_started', 'rotation_replacement_created')", name="ck_financial_connector_config_events_action", ), CheckConstraint( "expected_version IS NULL OR expected_version >= 1", name="ck_financial_connector_config_events_expected_version", ), CheckConstraint( "length(trim(actor_id)) > 0 AND length(trim(request_id)) > 0 " "AND length(trim(reason)) > 0", name="ck_financial_connector_config_events_required_text", ), Index( "ix_financial_connector_config_events_tenant_config_time", "tenant_id", "config_id", "occurred_at", ), Index( "ix_financial_connector_config_events_tenant_request", "tenant_id", "request_id", ), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id) tenant_id: Mapped[str] = mapped_column(String(64), nullable=False) config_id: Mapped[str] = mapped_column(String(36), nullable=False) action: Mapped[str] = mapped_column(String(40), nullable=False) actor_id: Mapped[str] = mapped_column(String(120), nullable=False) request_id: Mapped[str] = mapped_column(String(120), nullable=False) reason: Mapped[str] = mapped_column(Text(), nullable=False) expected_version: Mapped[int | None] = mapped_column(Integer) before_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict) after_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict) occurred_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) class FinancialConnectorOperationalEvent(Base): """连接器重放、认证失败和载荷冲突的最小化追加式运营事实。""" __tablename__ = "financial_connector_operational_events" __table_args__ = ( UniqueConstraint( "tenant_id", "id", name="uq_financial_connector_operational_events_tenant_id", ), UniqueConstraint( "tenant_id", "idempotency_key", name="uq_financial_connector_operational_events_tenant_request", ), ForeignKeyConstraint( ["tenant_id", "config_id"], ["financial_connector_configs.tenant_id", "financial_connector_configs.id"], name="fk_financial_connector_operational_events_tenant_config", ondelete="RESTRICT", ), CheckConstraint( "event_type IN ('replay', 'auth_failure', 'payload_conflict')", name="ck_financial_connector_operational_events_type", ), CheckConstraint( "environment IN ('test', 'mock', 'staging', 'production')", name="ck_financial_connector_operational_events_environment", ), CheckConstraint( "length(trim(provider)) > 0 AND length(trim(reason_code)) > 0", name="ck_financial_connector_operational_events_required_text", ), CheckConstraint( "length(request_fingerprint) = 76 " "AND request_fingerprint LIKE 'hmac-sha256:%' " "AND length(external_event_fingerprint) = 76 " "AND external_event_fingerprint LIKE 'hmac-sha256:%' " "AND length(idempotency_key) = 71 " "AND idempotency_key LIKE 'sha256:%'", name="ck_financial_connector_operational_events_fingerprints", ), Index( "ix_financial_connector_operational_events_tenant_config_time", "tenant_id", "config_id", "occurred_at", ), Index( "ix_financial_connector_operational_events_tenant_type_time", "tenant_id", "event_type", "occurred_at", ), Index( "ix_financial_connector_operational_events_tenant_provider_time", "tenant_id", "provider", "occurred_at", ), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id) tenant_id: Mapped[str] = mapped_column(String(64), nullable=False) config_id: Mapped[str] = mapped_column(String(36), nullable=False) provider: Mapped[str] = mapped_column(String(80), nullable=False) environment: Mapped[str] = mapped_column(String(16), nullable=False) event_type: Mapped[str] = mapped_column(String(32), nullable=False) reason_code: Mapped[str] = mapped_column(String(80), nullable=False) request_fingerprint: Mapped[str] = mapped_column(String(76), nullable=False) external_event_fingerprint: Mapped[str] = mapped_column(String(76), nullable=False) idempotency_key: Mapped[str] = mapped_column(String(71), nullable=False) occurred_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) class FinancialConnectorEvent(Base): """经签名验证的最小化外部事实。表由 PostgreSQL 触发器强制只追加。""" __tablename__ = "financial_connector_events" __table_args__ = ( UniqueConstraint("tenant_id", "id", name="uq_financial_connector_events_tenant_id"), UniqueConstraint( "tenant_id", "provider", "external_event_id", name="uq_financial_connector_events_external_id", ), ForeignKeyConstraint( ["tenant_id", "config_id"], ["financial_connector_configs.tenant_id", "financial_connector_configs.id"], name="fk_financial_connector_events_tenant_config", ondelete="RESTRICT", ), ForeignKeyConstraint( ["tenant_id", "origin_event_id"], ["financial_connector_events.tenant_id", "financial_connector_events.id"], name="fk_financial_connector_events_tenant_origin", ondelete="RESTRICT", ), ForeignKeyConstraint( ["tenant_id", "expense_case_id"], ["expense_cases.tenant_id", "expense_cases.id"], name="fk_financial_connector_events_tenant_expense_case", ondelete="RESTRICT", ), CheckConstraint( "direction = 'inbound'", name="ck_financial_connector_events_direction", ), CheckConstraint( "event_type IN ('payment_settled', 'payment_failed', 'erp_posted', " "'erp_posting_failed', 'payment_refunded', 'payment_reversed')", name="ck_financial_connector_events_type", ), CheckConstraint( "environment IN ('test', 'mock', 'staging', 'production')", name="ck_financial_connector_events_environment", ), CheckConstraint( "verification_level IN ('simulated', 'staging_verified', 'production_verified')", name="ck_financial_connector_events_verification", ), CheckConstraint( "processing_status IN ('processed', 'exception', 'pending')", name="ck_financial_connector_events_processing_status", ), CheckConstraint( "length(trim(external_event_id)) > 0 " "AND length(trim(request_fingerprint)) >= 16 " "AND length(trim(content_hash)) >= 16", name="ck_financial_connector_events_fingerprints", ), CheckConstraint( "(event_type IN ('payment_refunded', 'payment_reversed', " "'erp_posted', 'erp_posting_failed') " "AND (origin_event_id IS NOT NULL OR processing_status = 'exception')) " "OR (event_type IN ('payment_settled', 'payment_failed') " "AND origin_event_id IS NULL)", name="ck_financial_connector_events_origin", ), Index( "ix_financial_connector_events_tenant_received", "tenant_id", "received_at", ), Index( "ix_financial_connector_events_tenant_claim", "tenant_id", "claim_id", "occurred_at", ), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id) tenant_id: Mapped[str] = mapped_column(String(64), nullable=False) config_id: Mapped[str] = mapped_column(String(36), nullable=False) provider: Mapped[str] = mapped_column(String(80), nullable=False) environment: Mapped[str] = mapped_column(String(16), nullable=False) direction: Mapped[str] = mapped_column(String(12), nullable=False, default="inbound") external_event_id: Mapped[str] = mapped_column(String(160), nullable=False) event_type: Mapped[str] = mapped_column(String(40), nullable=False) occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) received_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) key_version: Mapped[str] = mapped_column(String(40), nullable=False) verification_level: Mapped[str] = mapped_column(String(32), nullable=False) request_fingerprint: Mapped[str] = mapped_column(String(80), nullable=False) content_hash: Mapped[str] = mapped_column(String(80), nullable=False) processing_status: Mapped[str] = mapped_column(String(20), nullable=False) error_code: Mapped[str | None] = mapped_column(String(80)) # expense_claims 由 legacy bootstrap 创建,迁移表只保存经过租户 Case 校验的软引用。 claim_id: Mapped[str | None] = mapped_column(String(36)) expense_case_id: Mapped[str | None] = mapped_column(String(36)) origin_event_id: Mapped[str | None] = mapped_column(String(36)) correlation_id: Mapped[str] = mapped_column(String(64), nullable=False) external_reference_tail: Mapped[str | None] = mapped_column(String(8)) normalized_payload_json: Mapped[dict[str, Any]] = mapped_column( JSON, nullable=False, default=dict ) response_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict) class PaymentReconciliationCase(Base): """对账当前投影;所有历史变化由 PaymentReconciliationEvent 保存。""" __tablename__ = "payment_reconciliation_cases" __table_args__ = ( UniqueConstraint("tenant_id", "id", name="uq_payment_reconciliation_cases_tenant_id"), UniqueConstraint( "tenant_id", "provider", "claim_id", name="uq_payment_reconciliation_cases_tenant_provider_claim", ), ForeignKeyConstraint( ["tenant_id", "last_connector_event_id"], ["financial_connector_events.tenant_id", "financial_connector_events.id"], name="fk_payment_reconciliation_cases_tenant_last_event", ondelete="RESTRICT", ), ForeignKeyConstraint( ["tenant_id", "expense_case_id"], ["expense_cases.tenant_id", "expense_cases.id"], name="fk_payment_reconciliation_cases_tenant_expense_case", ondelete="RESTRICT", ), CheckConstraint( "status IN ('pending', 'matched', 'exception', 'confirmed', " "'rejected', 'reopened', 'closed')", name="ck_payment_reconciliation_cases_status", ), CheckConstraint( "erp_status IN ('pending_posting', 'posted', 'posting_failed')", name="ck_payment_reconciliation_cases_erp_status", ), CheckConstraint( "expected_amount >= 0 AND actual_amount >= 0", name="ck_payment_reconciliation_cases_amounts", ), CheckConstraint( "length(trim(expected_currency)) = 3 AND length(trim(actual_currency)) = 3", name="ck_payment_reconciliation_cases_currencies", ), Index( "ix_payment_reconciliation_cases_tenant_status", "tenant_id", "status", "updated_at", ), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id) tenant_id: Mapped[str] = mapped_column(String(64), nullable=False) provider: Mapped[str] = mapped_column(String(80), nullable=False) # 租户边界由 expense_case_id 复合外键与服务查询共同保证。 claim_id: Mapped[str] = mapped_column(String(36), nullable=False) expense_case_id: Mapped[str | None] = mapped_column(String(36)) expected_amount: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False) actual_amount: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False) amount_difference: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False) expected_currency: Mapped[str] = mapped_column(String(3), nullable=False) actual_currency: Mapped[str] = mapped_column(String(3), nullable=False) expected_reference: Mapped[str] = mapped_column(String(160), nullable=False) external_reference_tail: Mapped[str | None] = mapped_column(String(8)) status: Mapped[str] = mapped_column(String(20), nullable=False) exception_code: Mapped[str | None] = mapped_column(String(80)) erp_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending_posting") erp_document_tail: Mapped[str | None] = mapped_column(String(8)) erp_document_hash: Mapped[str | None] = mapped_column(String(80)) assigned_to: Mapped[str | None] = mapped_column(String(120)) last_connector_event_id: Mapped[str] = mapped_column(String(36), nullable=False) version: Mapped[int] = mapped_column(Integer, nullable=False, default=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 PaymentReconciliationEvent(Base): """对账动作审计事实;数据库级禁止更新和删除。""" __tablename__ = "payment_reconciliation_events" __table_args__ = ( UniqueConstraint("tenant_id", "id", name="uq_payment_reconciliation_events_tenant_id"), UniqueConstraint( "tenant_id", "connector_event_id", "action", name="uq_payment_reconciliation_events_connector_action", ), ForeignKeyConstraint( ["tenant_id", "reconciliation_case_id"], ["payment_reconciliation_cases.tenant_id", "payment_reconciliation_cases.id"], name="fk_payment_reconciliation_events_tenant_case", ondelete="RESTRICT", ), ForeignKeyConstraint( ["tenant_id", "connector_event_id"], ["financial_connector_events.tenant_id", "financial_connector_events.id"], name="fk_payment_reconciliation_events_tenant_connector_event", ondelete="RESTRICT", ), CheckConstraint( "action IN ('auto_matched', 'exception_created', 'erp_posted', " "'erp_posting_failed', 'reopened', 'confirmed', 'rejected', 'closed')", name="ck_payment_reconciliation_events_action", ), CheckConstraint( "length(trim(request_fingerprint)) >= 16", name="ck_payment_reconciliation_events_fingerprint", ), Index( "ix_payment_reconciliation_events_tenant_case_time", "tenant_id", "reconciliation_case_id", "occurred_at", ), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id) tenant_id: Mapped[str] = mapped_column(String(64), nullable=False) reconciliation_case_id: Mapped[str] = mapped_column(String(36), nullable=False) connector_event_id: Mapped[str] = mapped_column(String(36), nullable=False) action: Mapped[str] = mapped_column(String(32), nullable=False) actor_type: Mapped[str] = mapped_column(String(20), nullable=False) actor_id: Mapped[str] = mapped_column(String(120), nullable=False) request_fingerprint: Mapped[str] = mapped_column(String(80), nullable=False) before_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict) after_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict) response_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict) reason: Mapped[str | None] = mapped_column(Text()) correlation_id: Mapped[str] = mapped_column(String(64), nullable=False) occurred_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() )