from datetime import datetime from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, text from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.sql import func class BaseTimestampMixin: """Mixin for basic timestamp fields (no soft delete)""" created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, server_default=func.now() ) updated_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, server_default=func.now(), onupdate=func.now() ) class TimestampMixin(BaseTimestampMixin): """Mixin for common timestamp fields including soft delete""" deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) class TenantScopedMixin: """Mixin para entidades multi-tenant. company_id no tiene FK declarada aquí — agrégala en cada modelo apuntando a la tabla de compañías de tu proyecto. """ tenant_id: Mapped[int] = mapped_column(Integer, ForeignKey("core.tenants.id"), nullable=False, index=True) company_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) class EfcDocumentRefMixin: """Columnas del espejo de un documento en EFC. Se aplica a ``crm.documents`` y a ``ops.shipment_documents``. ``efc_document_ref`` es el handle AUTORITATIVO —el CRM lo construye y EFC lo guarda—; ``efc_document_id`` es solo un CACHE de la resolución, recuperable por el endpoint de lista si se pierde. Es el mismo principio que aplica el gateway de Anexo22: el sistema de origen conserva el registro de SU dato, y con eso pide el archivo de vuelta, en lugar de guardar identificadores ajenos en columnas propias. ``efc_sync_state`` es el estado del ESPEJO (lo que pinta la UI: badge, botón reintentar). La cola de trabajo vive aparte, en ``crm.efc_file_outbox``. No son redundantes: el outbox es indexable por su propio ciclo de vida y sobrevive a un borrado cuya fila ya no está. Es un mixin y no diez columnas copiadas en dos modelos porque las dos tablas tienen que describir el mismo espejo: si divergen, la UI pinta un badge distinto según de dónde venga el documento y nadie entiende por qué. """ expediente_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) # {TABLA}-{company_id}-{row_id}, p. ej. SHPDOC-1-4471. Texto y no un entero porque el CRM tiene # dos tablas de documentos con secuencias independientes: crm.documents.id = 5 y # ops.shipment_documents.id = 5 coexisten, así que un entero solo sería ambiguo entre ellas. efc_document_ref: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) efc_document_id: Mapped[str | None] = mapped_column(String(36), nullable=True) # PENDING | SYNCED | FAILED efc_sync_state: Mapped[str | None] = mapped_column( String(20), nullable=True, server_default=text("'PENDING'") ) efc_synced_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) efc_error_code: Mapped[str | None] = mapped_column(String(60), nullable=True) efc_error_detail: Mapped[str | None] = mapped_column(Text, nullable=True) efc_attempts: Mapped[int | None] = mapped_column(Integer, nullable=True, server_default=text("0")) content_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)