El CRM genera documentos que hoy viven sueltos en su MinIO: sin nada que los
agrupe y sin catalogo validado en backend. EFC es el sistema de expedientes de
la casa y debe ser la fuente unica, pero el CRM no tiene ni un dato de pedimento
-verificado: ningun campo de aduana, patente, numero ni anio en crm/ ni en ops/-
y en EFC el expediente ES el pedimento. Esta fase pone del lado del CRM lo que
falta para poder hablar de un expediente antes de que exista data aduanera.
crm.expedientes cuelga de la solicitud de servicio, que es el hilo comercial que
el repo ya tiene de la RFQ a la factura. Nace con folio EXP{YYYY}-{MM}-{NNN} en
la misma transaccion del alta: un flush y no un commit, porque si el alta fallara
despues quedaria un folio quemado colgando de una solicitud inexistente.
El folio se reserva con un unico INSERT ... ON CONFLICT DO UPDATE ... RETURNING
sobre crm.expediente_folio_counters. Un SELECT max(sequence)+1 es justo la
carrera que hay que evitar: dos altas simultaneas leerian el mismo maximo y
entregarian el mismo folio a dos expedientes distintos. El asignador no
commitea, igual que el de folios de Anexo22, para que un rollback posterior
pueda soltar el folio; deja hueco, que es preferible a un duplicado. Los dos
UNIQUE de la tabla son la red por si el contador se corrompe: revienta ruidoso
en vez de mezclar dos hilos documentales.
efc_storage_token se fija al nacer y es inmutable: es la carpeta de MinIO en
EFC. Lleva el company_id dentro porque el puente con EFC es tenant -> organizacion
1:1 pero un tenant tiene N companies, asi que sin el, dos companies generando
EXP2026-08-001 chocarian en el unique_together de EFC y a una le devolverian el
provisional de la otra.
EfcDocumentRefMixin va en las dos tablas de documentos -crm.documents y
ops.shipment_documents- porque las dos alimentan el mismo expediente; el handle
autoritativo es efc_document_ref, que el CRM construye, y efc_document_id es
solo cache de la resolucion. El ref es texto y no entero porque las dos tablas
tienen secuencias independientes: crm.documents.id = 5 y
ops.shipment_documents.id = 5 coexisten.
La migracion se GENERA y se deja SIN aplicar.
Verificacion: pytest tests/ EXIT=0, 90 passed 1 skipped (baseline 70 passed).
Los tests nuevos se vieron en rojo a proposito antes de darlos por buenos:
quitando el enganche del expediente reventaron nombrando el expediente ausente,
quitando el UNIQUE del folio el test dijo DID NOT RAISE IntegrityError, y
dejando el contador sin incrementar los tres consecutivos salieron 001/001/001.
Ticket: T2026-08-046 (fase 5 de 7)
39 lines
2.0 KiB
Python
39 lines
2.0 KiB
Python
from sqlalchemy import ForeignKey, Integer, String
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from api.v1.common.base_models import EfcDocumentRefMixin, TenantScopedMixin, TimestampMixin
|
|
from core.database import Base
|
|
|
|
|
|
class Document(Base, TenantScopedMixin, TimestampMixin, EfcDocumentRefMixin):
|
|
"""Documento de un cliente (``account_id``) o proveedor (``supplier_id``).
|
|
|
|
Guarda los metadatos y una referencia al archivo (``file_key`` en MinIO/S3 o
|
|
``file_url`` externa). La subida binaria se hace vía la capa de storage.
|
|
|
|
Con ``EfcDocumentRefMixin`` la fila además refleja el estado del documento en el expediente
|
|
electrónico de EFC. Un documento del CRM puede vivir en tres modos y la descarga se ramifica por
|
|
ellos: ``efc_document_id`` (está en EFC → proxy), ``file_key`` (solo local → URL firmada) o
|
|
``file_url`` (externa).
|
|
"""
|
|
|
|
__tablename__ = "documents"
|
|
__table_args__ = {"schema": "crm"}
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
|
account_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
|
|
)
|
|
supplier_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("crm.suppliers.id"), nullable=True, index=True
|
|
)
|
|
# constancia_fiscal | acta_constitutiva | identificacion | comprobante_domicilio |
|
|
# contrato | presentacion | certificacion | licencia | convenio | tarifario | otro
|
|
doc_type: Mapped[str] = mapped_column(String(60), nullable=False)
|
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
file_key: Mapped[str | None] = mapped_column(String(512), nullable=True) # objeto en MinIO/S3
|
|
file_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) # o URL externa
|
|
content_type: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
|
size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
uploaded_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|