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)
117 lines
7.0 KiB
Python
117 lines
7.0 KiB
Python
from datetime import date, datetime
|
|
|
|
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from api.v1.common.base_models import EfcDocumentRefMixin, TenantScopedMixin, TimestampMixin
|
|
from core.database import Base
|
|
|
|
|
|
class Shipment(Base, TenantScopedMixin, TimestampMixin):
|
|
"""Operación / Embarque (Diagrama 2). Se crea al liberar una cotización aceptada."""
|
|
|
|
__tablename__ = "shipments"
|
|
__table_args__ = {"schema": "ops"}
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
|
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio de embarque
|
|
quote_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("crm.quotes.id"), nullable=True, index=True
|
|
)
|
|
service_request_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("crm.service_requests.id"), nullable=True
|
|
)
|
|
account_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
|
|
)
|
|
operation_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
|
transport_mode: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
|
service_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
|
incoterm: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
|
origin: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
|
destination: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
|
# abierta | booking | en_transito | arribado | entregada | cerrada | cancelada
|
|
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'abierta'"), index=True)
|
|
booking_number: Mapped[str | None] = mapped_column(String(60), nullable=True)
|
|
carrier_supplier_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("crm.suppliers.id"), nullable=True
|
|
) # naviera / aerolínea / transportista principal
|
|
ground_carrier_supplier_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("crm.suppliers.id"), nullable=True
|
|
) # transporte terrestre / recolección (R-E-07)
|
|
customs_agent_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("crm.suppliers.id"), nullable=True
|
|
) # agente aduanal
|
|
destination_agent_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("crm.suppliers.id"), nullable=True
|
|
) # agente corresponsal en destino
|
|
cutoff_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # Cut Off
|
|
pickup_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # cita/ventana de recolección (R-E-07)
|
|
etd: Mapped[date | None] = mapped_column(Date, nullable=True) # salida estimada
|
|
previous_etd: Mapped[date | None] = mapped_column(Date, nullable=True) # salida previa tras reprogramación (R-E-06)
|
|
eta: Mapped[date | None] = mapped_column(Date, nullable=True) # llegada estimada
|
|
vessel_flight: Mapped[str | None] = mapped_column(String(120), nullable=True) # buque / vuelo
|
|
container_number: Mapped[str | None] = mapped_column(String(60), nullable=True)
|
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
# ----- Cierre operativo (R-E-22 / disparador de facturación R-F-01) -----
|
|
actual_cost_total: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True) # costos finales reales
|
|
cost_currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
|
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # cierre operativo
|
|
closed_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
|
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
|
|
class ShipmentEvent(Base, TenantScopedMixin, TimestampMixin):
|
|
"""Hito / bitácora del embarque (Diagramas 2 y 3). Timeline de la operación."""
|
|
|
|
__tablename__ = "shipment_events"
|
|
__table_args__ = {"schema": "ops"}
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
|
shipment_id: Mapped[int] = mapped_column(
|
|
Integer, ForeignKey("ops.shipments.id"), nullable=False, index=True
|
|
)
|
|
event_type: Mapped[str | None] = mapped_column(String(60), nullable=True) # clave del hito
|
|
title: Mapped[str] = mapped_column(String(160), nullable=False)
|
|
# hito | decision — un 'decision' es un punto de decisión del diagrama (rombo)
|
|
kind: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'hito'"))
|
|
# pendiente | completado | omitido | rechazado | en_correccion
|
|
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'pendiente'"))
|
|
# Resultado de un punto de decisión: autorizado | rechazado (NULL mientras está pendiente)
|
|
outcome: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
|
# Ciclo de corrección: el hito de re-trámite apunta a la decisión rechazada que lo originó
|
|
parent_event_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("ops.shipment_events.id"), nullable=True
|
|
)
|
|
attempt: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("1")) # número de intento
|
|
position: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
|
|
planned_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
actual_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
|
|
class ShipmentDocument(Base, TenantScopedMixin, TimestampMixin, EfcDocumentRefMixin):
|
|
"""Documento de transporte del embarque (Master/House: MBL, HBL, MAWB, HAWB, CMR, etc.).
|
|
|
|
Comparte con ``crm.documents`` las columnas del espejo en EFC vía ``EfcDocumentRefMixin``: las
|
|
dos tablas alimentan el mismo expediente electrónico y la UI las pinta con el mismo badge.
|
|
"""
|
|
|
|
__tablename__ = "shipment_documents"
|
|
__table_args__ = {"schema": "ops"}
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
|
shipment_id: Mapped[int] = mapped_column(
|
|
Integer, ForeignKey("ops.shipments.id"), nullable=False, index=True
|
|
)
|
|
doc_kind: Mapped[str] = mapped_column(String(10), nullable=False, server_default=text("'otro'")) # master|house|otro
|
|
# MBL | HBL | MAWB | HAWB | CMR | factura_comercial | packing_list | carta_encomienda | carta_garantia | otro
|
|
doc_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
|
number: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
|
issue_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
|
file_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
|
file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|