feat(ops,fin,crm): reglas de negocio del PDF (decisiones, cierre, facturación, continuidad, RBAC)

Cierra los huecos de la auditoría contra "SOFTWARE PARA AGENTES DE CARGA":

- ops (Diag. 2/3): bitácora con puntos de decisión (kind=decision) y ciclo de
  corrección (parent_event_id/attempt) para ¿Cut Off? y ¿despacho autorizado?
  (R-E-05/13, R-I-06). Reprogramación de salida (previous_etd, R-E-06). Hitos
  operativos completos export/import. Cierre operativo con costos finales
  (close_shipment, R-E-22).
- fin (Diag. 4): facturación con gate por cierre operativo y sin duplicar
  (R-F-01), costos de operación arrastrados (ops_cost_total, R-F-02), envío con
  PDF generado y guardado en MinIO (send_invoice + pdf.py sin dependencias,
  R-F-05) y revisión del cliente (en_revision_cliente + aprobación, R-F-06).
- crm (Diag. 1): opportunity_id enlaza embudo→RFQ (R-C-02), contacto como etapa
  (first_contact_at, R-C-04), re-cotización (clone_quote + reopen, R-C-12).
- transversal: catálogo de Incoterms y participantes/actores incl. autoridad
  aduanera (R-T-01/10), enforcement de permisos por carril (RBAC) con roles
  sembrados y dependencias dev-safe (R-T-07).
- Migración d5e6f7a8b9c0 con downgrade. Seed extendido. 70 tests (12 nuevos).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aduanasoft
2026-07-15 08:46:53 -06:00
parent 116d2e7f5a
commit e79705e6e3
26 changed files with 1407 additions and 68 deletions

View File

@@ -1,6 +1,6 @@
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text, text
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 TenantScopedMixin, TimestampMixin
@@ -35,19 +35,29 @@ class Shipment(Base, TenantScopedMixin, TimestampMixin):
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
) # 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 en destino
) # 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)
@@ -65,8 +75,17 @@ class ShipmentEvent(Base, TenantScopedMixin, TimestampMixin):
)
event_type: Mapped[str | None] = mapped_column(String(60), nullable=True) # clave del hito
title: Mapped[str] = mapped_column(String(160), nullable=False)
# pendiente | completado | omitido
# 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)