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:
@@ -1,6 +1,7 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.crm.accounts.models import Account
|
||||
@@ -9,33 +10,57 @@ from api.v1.modules.crm.service_requests.models import ServiceRequest
|
||||
from api.v1.modules.crm.suppliers.models import Supplier
|
||||
|
||||
from .dto import (
|
||||
ShipmentCloseInput,
|
||||
ShipmentCreate,
|
||||
ShipmentDocumentCreate,
|
||||
ShipmentDocumentUpdate,
|
||||
ShipmentEventCreate,
|
||||
ShipmentEventDecisionInput,
|
||||
ShipmentEventUpdate,
|
||||
ShipmentRescheduleInput,
|
||||
ShipmentUpdate,
|
||||
)
|
||||
from .models import Shipment, ShipmentDocument, ShipmentEvent
|
||||
|
||||
# Hitos por defecto según el tipo de operación (Diagramas 2 y 3)
|
||||
# Hitos por defecto según el tipo de operación (Diagramas 2 y 3).
|
||||
# Tupla: (event_type, título, kind). kind="decision" son puntos de decisión (rombos)
|
||||
# que se resuelven con autorizado/rechazado y disparan el ciclo de corrección.
|
||||
_DEFAULT_MILESTONES = {
|
||||
# Diagrama 2 — Proceso operativo de exportación
|
||||
"exportacion": [
|
||||
("recoleccion", "Recolección de mercancía"),
|
||||
("despacho_exportacion", "Despacho de exportación"),
|
||||
("embarque", "Embarque"),
|
||||
("zarpe", "Zarpe / Salida del transporte"),
|
||||
("arribo", "Arribo a destino"),
|
||||
("entrega", "Entrega al consignatario"),
|
||||
("coordinacion_fecha_cliente", "Coordinar fecha de operación con el cliente", "hito"),
|
||||
("revision_salidas", "Revisar disponibilidad de salidas del transporte", "hito"),
|
||||
("validacion_cutoff", "Validar Cut Off del transportista", "hito"),
|
||||
("decision_cutoff", "¿Se alcanza el Cut Off?", "decision"),
|
||||
("programacion_transporte_terrestre", "Programar transporte terrestre y recolección", "hito"),
|
||||
("recoleccion", "Recolección de mercancía", "hito"),
|
||||
("traslado_puerto", "Trasladar la mercancía al puerto / aeropuerto", "hito"),
|
||||
("entrega_terminal", "Entregar la mercancía en la terminal", "hito"),
|
||||
("entrega_docs_agente", "Entregar documentación al agente aduanal", "hito"),
|
||||
("despacho_exportacion", "Despacho de exportación", "hito"),
|
||||
("decision_despacho_exportacion", "¿Despacho de exportación autorizado?", "decision"),
|
||||
("emision_docs_internacionales", "Emitir documentación internacional (MBL/HBL, MAWB/HAWB, CMR)", "hito"),
|
||||
("embarque", "Embarque", "hito"),
|
||||
("zarpe", "Zarpe / Salida del transporte", "hito"),
|
||||
("coordinacion_corresponsal", "Coordinar con el agente corresponsal en destino", "hito"),
|
||||
("arribo", "Arribo a destino", "hito"),
|
||||
("despacho_destino", "Despacho de importación en destino (corresponsal)", "hito"),
|
||||
("entrega", "Entrega al consignatario", "hito"),
|
||||
("cierre_operativo", "Cierre operativo (registrar costos finales)", "hito"),
|
||||
],
|
||||
# Diagrama 3 — Proceso de importación
|
||||
"importacion": [
|
||||
("aviso_llegada", "Aviso de llegada"),
|
||||
("recepcion_docs", "Recepción de documentos (MBL/MAWB)"),
|
||||
("despacho_importacion", "Despacho de importación"),
|
||||
("liberacion", "Liberación de mercancía"),
|
||||
("retiro", "Retiro en puerto / aeropuerto"),
|
||||
("traslado", "Traslado a bodega del importador"),
|
||||
("entrega", "Entrega final al cliente"),
|
||||
("aviso_llegada", "Aviso de llegada", "hito"),
|
||||
("recepcion_docs", "Recepción de documentos (MBL/MAWB)", "hito"),
|
||||
("coordinacion_agente_aduanal", "Coordinar con el agente aduanal el despacho", "hito"),
|
||||
("entrega_docs_agente", "Entregar documentos y requisitos al agente aduanal", "hito"),
|
||||
("despacho_importacion", "Despacho de importación", "hito"),
|
||||
("decision_despacho_importacion", "¿Despacho de importación autorizado?", "decision"),
|
||||
("liberacion", "Liberación de mercancía", "hito"),
|
||||
("retiro", "Retiro en puerto / aeropuerto", "hito"),
|
||||
("traslado", "Traslado a bodega del importador", "hito"),
|
||||
("entrega", "Entrega final al cliente", "hito"),
|
||||
("cierre_operativo", "Cierre operativo (registrar costos finales)", "hito"),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -62,6 +87,7 @@ def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) ->
|
||||
("quote_id", Quote, "La cotización asociada no existe"),
|
||||
("service_request_id", ServiceRequest, "La solicitud asociada no existe"),
|
||||
("carrier_supplier_id", Supplier, "El transportista/naviera no existe"),
|
||||
("ground_carrier_supplier_id", Supplier, "El transportista terrestre no existe"),
|
||||
("customs_agent_id", Supplier, "El agente aduanal no existe"),
|
||||
("destination_agent_id", Supplier, "El agente en destino no existe"),
|
||||
]
|
||||
@@ -307,6 +333,12 @@ def update_shipment_event(db: Session, event_id: int, payload: ShipmentEventUpda
|
||||
|
||||
def complete_shipment_event(db: Session, event_id: int, tenant_id: int, company_id: int) -> ShipmentEvent:
|
||||
obj = _get_event(db, event_id, tenant_id, company_id)
|
||||
# Un punto de decisión no se "completa" a mano: se resuelve con decide_shipment_event
|
||||
if obj.kind == "decision":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Este hito es un punto de decisión: resuélvelo como autorizado o rechazado",
|
||||
)
|
||||
obj.status = "completado"
|
||||
obj.actual_date = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
@@ -314,12 +346,158 @@ def complete_shipment_event(db: Session, event_id: int, tenant_id: int, company_
|
||||
return obj
|
||||
|
||||
|
||||
def decide_shipment_event(
|
||||
db: Session, event_id: int, payload: ShipmentEventDecisionInput, tenant_id: int, company_id: int
|
||||
) -> ShipmentEvent:
|
||||
"""Resuelve un punto de decisión del flujo (Cut Off / despacho autorizado).
|
||||
|
||||
- autorizado → la decisión queda completada y el flujo continúa.
|
||||
- rechazado → la decisión queda 'rechazada' y se genera automáticamente un hito
|
||||
de corrección (rehacer trámite) que apunta a esta decisión, implementando el
|
||||
ciclo de corrección de los diagramas 2 (R-E-14) y 3 (R-I-07).
|
||||
"""
|
||||
obj = _get_event(db, event_id, tenant_id, company_id)
|
||||
if obj.kind != "decision":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Solo los puntos de decisión aceptan un resultado (autorizado/rechazado)",
|
||||
)
|
||||
obj.outcome = payload.outcome
|
||||
obj.actual_date = datetime.now(timezone.utc)
|
||||
if payload.notes:
|
||||
obj.notes = payload.notes
|
||||
|
||||
if payload.outcome == "autorizado":
|
||||
obj.status = "completado"
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
# Rechazado: se abre el ciclo de corrección
|
||||
obj.status = "rechazado"
|
||||
correction = ShipmentEvent(
|
||||
shipment_id=obj.shipment_id,
|
||||
event_type=f"{obj.event_type or 'tramite'}_correccion",
|
||||
title=f"Corrección: rehacer trámite — {obj.title}",
|
||||
kind="hito",
|
||||
status="en_correccion",
|
||||
parent_event_id=obj.id,
|
||||
attempt=(obj.attempt or 1) + 1,
|
||||
# Se inserta justo después de la decisión rechazada para conservar el orden del flujo
|
||||
position=obj.position,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
)
|
||||
# Empuja una posición los hitos posteriores para dejar hueco a la corrección
|
||||
db.query(ShipmentEvent).filter(
|
||||
ShipmentEvent.shipment_id == obj.shipment_id,
|
||||
ShipmentEvent.tenant_id == tenant_id,
|
||||
ShipmentEvent.company_id == company_id,
|
||||
ShipmentEvent.deleted_at.is_(None),
|
||||
ShipmentEvent.position > obj.position,
|
||||
).update({ShipmentEvent.position: ShipmentEvent.position + 1})
|
||||
correction.position = obj.position + 1
|
||||
db.add(correction)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def delete_shipment_event(db: Session, event_id: int, tenant_id: int, company_id: int) -> None:
|
||||
obj = _get_event(db, event_id, tenant_id, company_id)
|
||||
obj.deleted_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
|
||||
def reschedule_departure(
|
||||
db: Session, shipment_id: int, payload: ShipmentRescheduleInput, tenant_id: int, company_id: int,
|
||||
user_id: str | None = None,
|
||||
) -> Shipment:
|
||||
"""Reprograma la salida cuando no se alcanza el Cut Off (R-E-06).
|
||||
|
||||
Conserva la salida anterior en ``previous_etd`` y deja constancia en la bitácora.
|
||||
"""
|
||||
shipment = get_shipment(db, shipment_id, tenant_id, company_id)
|
||||
if payload.etd is not None:
|
||||
shipment.previous_etd = shipment.etd
|
||||
shipment.etd = payload.etd
|
||||
if payload.cutoff_date is not None:
|
||||
shipment.cutoff_date = payload.cutoff_date
|
||||
shipment.updated_by = user_id
|
||||
|
||||
last_pos = (
|
||||
db.query(func.max(ShipmentEvent.position))
|
||||
.filter(
|
||||
ShipmentEvent.shipment_id == shipment_id,
|
||||
ShipmentEvent.tenant_id == tenant_id,
|
||||
ShipmentEvent.company_id == company_id,
|
||||
ShipmentEvent.deleted_at.is_(None),
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
detail = payload.reason or "Reprogramación de salida por Cut Off no alcanzado"
|
||||
db.add(
|
||||
ShipmentEvent(
|
||||
shipment_id=shipment_id,
|
||||
event_type="reprogramacion",
|
||||
title="Reprogramación de salida (nuevo Cut Off / ETD)",
|
||||
kind="hito",
|
||||
status="completado",
|
||||
actual_date=datetime.now(timezone.utc),
|
||||
position=(last_pos or 0) + 1,
|
||||
notes=detail,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(shipment)
|
||||
return shipment
|
||||
|
||||
|
||||
def close_shipment(
|
||||
db: Session, shipment_id: int, payload: ShipmentCloseInput, tenant_id: int, company_id: int,
|
||||
user_id: str | None = None,
|
||||
) -> Shipment:
|
||||
"""Cierre operativo del embarque con costos finales (R-E-22).
|
||||
|
||||
Marca el embarque como 'cerrada' y registra los costos reales; el cierre es el
|
||||
disparador válido de la facturación (R-F-01). No permite cerrar si quedan puntos
|
||||
de decisión sin resolver.
|
||||
"""
|
||||
shipment = get_shipment(db, shipment_id, tenant_id, company_id)
|
||||
if shipment.status == "cancelada":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="El embarque está cancelado"
|
||||
)
|
||||
pending_decision = (
|
||||
db.query(ShipmentEvent.id)
|
||||
.filter(
|
||||
ShipmentEvent.shipment_id == shipment_id,
|
||||
ShipmentEvent.tenant_id == tenant_id,
|
||||
ShipmentEvent.company_id == company_id,
|
||||
ShipmentEvent.deleted_at.is_(None),
|
||||
ShipmentEvent.kind == "decision",
|
||||
ShipmentEvent.status.in_(["pendiente", "rechazado", "en_correccion"]),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if pending_decision:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="No se puede cerrar: hay puntos de decisión pendientes o en corrección",
|
||||
)
|
||||
shipment.actual_cost_total = payload.actual_cost_total
|
||||
shipment.cost_currency = payload.cost_currency
|
||||
shipment.status = "cerrada"
|
||||
shipment.closed_at = datetime.now(timezone.utc)
|
||||
shipment.closed_by = user_id
|
||||
shipment.updated_by = user_id
|
||||
db.commit()
|
||||
db.refresh(shipment)
|
||||
return shipment
|
||||
|
||||
|
||||
def seed_default_milestones(db: Session, shipment_id: int, tenant_id: int, company_id: int) -> list[ShipmentEvent]:
|
||||
"""Crea los hitos por defecto del embarque según su tipo de operación (import/export)."""
|
||||
shipment = get_shipment(db, shipment_id, tenant_id, company_id)
|
||||
@@ -333,10 +511,10 @@ def seed_default_milestones(db: Session, shipment_id: int, tenant_id: int, compa
|
||||
detail="Define el tipo de operación (importación/exportación) para generar los hitos",
|
||||
)
|
||||
created = []
|
||||
for position, (event_type, title) in enumerate(milestones):
|
||||
for position, (event_type, title, kind) in enumerate(milestones):
|
||||
ev = ShipmentEvent(
|
||||
shipment_id=shipment_id, event_type=event_type, title=title, status="pendiente",
|
||||
position=position, tenant_id=tenant_id, company_id=company_id,
|
||||
shipment_id=shipment_id, event_type=event_type, title=title, kind=kind,
|
||||
status="pendiente", position=position, tenant_id=tenant_id, company_id=company_id,
|
||||
)
|
||||
db.add(ev)
|
||||
created.append(ev)
|
||||
|
||||
Reference in New Issue
Block a user