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,4 +1,6 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
@@ -17,14 +19,19 @@ class ShipmentBase(BaseModel):
|
||||
status: str = Field("abierta", max_length=20)
|
||||
booking_number: str | None = Field(None, max_length=60)
|
||||
carrier_supplier_id: int | None = None
|
||||
ground_carrier_supplier_id: int | None = None
|
||||
customs_agent_id: int | None = None
|
||||
destination_agent_id: int | None = None
|
||||
cutoff_date: datetime | None = None
|
||||
pickup_at: datetime | None = None
|
||||
etd: date | None = None
|
||||
previous_etd: date | None = None
|
||||
eta: date | None = None
|
||||
vessel_flight: str | None = Field(None, max_length=120)
|
||||
container_number: str | None = Field(None, max_length=60)
|
||||
notes: str | None = None
|
||||
actual_cost_total: Decimal | None = None
|
||||
cost_currency: str | None = Field(None, max_length=3)
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
|
||||
|
||||
@@ -44,9 +51,11 @@ class ShipmentUpdate(BaseModel):
|
||||
status: str | None = Field(None, max_length=20)
|
||||
booking_number: str | None = Field(None, max_length=60)
|
||||
carrier_supplier_id: int | None = None
|
||||
ground_carrier_supplier_id: int | None = None
|
||||
customs_agent_id: int | None = None
|
||||
destination_agent_id: int | None = None
|
||||
cutoff_date: datetime | None = None
|
||||
pickup_at: datetime | None = None
|
||||
etd: date | None = None
|
||||
eta: date | None = None
|
||||
vessel_flight: str | None = Field(None, max_length=120)
|
||||
@@ -55,10 +64,26 @@ class ShipmentUpdate(BaseModel):
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
|
||||
|
||||
class ShipmentRescheduleInput(BaseModel):
|
||||
"""Reprogramación de salida cuando no se alcanza el Cut Off (R-E-06)."""
|
||||
etd: date | None = None
|
||||
cutoff_date: datetime | None = None
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class ShipmentCloseInput(BaseModel):
|
||||
"""Cierre operativo del embarque con costos finales (R-E-22)."""
|
||||
actual_cost_total: Decimal = Field(..., ge=0)
|
||||
cost_currency: str = Field("MXN", max_length=3)
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ShipmentResponse(ShipmentBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
closed_at: datetime | None = None
|
||||
closed_by: str | None = None
|
||||
created_by: str | None = None
|
||||
updated_by: str | None = None
|
||||
tenant_id: int
|
||||
@@ -71,7 +96,11 @@ class ShipmentEventBase(BaseModel):
|
||||
shipment_id: int
|
||||
event_type: str | None = Field(None, max_length=60)
|
||||
title: str = Field(..., min_length=1, max_length=160)
|
||||
kind: str = Field("hito", max_length=20) # hito | decision
|
||||
status: str = Field("pendiente", max_length=20)
|
||||
outcome: str | None = Field(None, max_length=20) # autorizado | rechazado
|
||||
parent_event_id: int | None = None
|
||||
attempt: int = Field(1, ge=1)
|
||||
position: int = Field(0, ge=0)
|
||||
planned_date: datetime | None = None
|
||||
actual_date: datetime | None = None
|
||||
@@ -85,6 +114,7 @@ class ShipmentEventCreate(ShipmentEventBase):
|
||||
class ShipmentEventUpdate(BaseModel):
|
||||
event_type: str | None = Field(None, max_length=60)
|
||||
title: str | None = Field(None, min_length=1, max_length=160)
|
||||
kind: str | None = Field(None, max_length=20)
|
||||
status: str | None = Field(None, max_length=20)
|
||||
position: int | None = Field(None, ge=0)
|
||||
planned_date: datetime | None = None
|
||||
@@ -92,6 +122,12 @@ class ShipmentEventUpdate(BaseModel):
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ShipmentEventDecisionInput(BaseModel):
|
||||
"""Resultado de un punto de decisión del flujo (R-E-13, R-E-05, R-I-06)."""
|
||||
outcome: Literal["autorizado", "rechazado"]
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ShipmentEventResponse(ShipmentEventBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -6,13 +6,16 @@ from core.security import get_current_user
|
||||
|
||||
from . import service
|
||||
from .dto import (
|
||||
ShipmentCloseInput,
|
||||
ShipmentCreate,
|
||||
ShipmentDocumentCreate,
|
||||
ShipmentDocumentResponse,
|
||||
ShipmentDocumentUpdate,
|
||||
ShipmentEventCreate,
|
||||
ShipmentEventDecisionInput,
|
||||
ShipmentEventResponse,
|
||||
ShipmentEventUpdate,
|
||||
ShipmentRescheduleInput,
|
||||
ShipmentResponse,
|
||||
ShipmentUpdate,
|
||||
)
|
||||
@@ -69,6 +72,32 @@ def create_shipment_from_quote(
|
||||
return service.create_shipment_from_quote(db, quote_id, tenant_id, company_id, _user_id(current_user))
|
||||
|
||||
|
||||
@router.post("/shipments/{shipment_id}/reschedule", response_model=ShipmentResponse)
|
||||
def reschedule_shipment(
|
||||
shipment_id: int,
|
||||
payload: ShipmentRescheduleInput,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Reprograma la salida cuando no se alcanza el Cut Off (R-E-06)."""
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.reschedule_departure(db, shipment_id, payload, tenant_id, company_id, _user_id(current_user))
|
||||
|
||||
|
||||
@router.post("/shipments/{shipment_id}/close", response_model=ShipmentResponse)
|
||||
def close_shipment(
|
||||
shipment_id: int,
|
||||
payload: ShipmentCloseInput,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Cierre operativo del embarque con costos finales (R-E-22, dispara facturación R-F-01)."""
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.close_shipment(db, shipment_id, payload, tenant_id, company_id, _user_id(current_user))
|
||||
|
||||
|
||||
@router.patch("/shipments/{shipment_id}", response_model=ShipmentResponse)
|
||||
def update_shipment(
|
||||
shipment_id: int,
|
||||
@@ -189,6 +218,18 @@ def complete_shipment_event(
|
||||
return service.complete_shipment_event(db, event_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.patch("/shipment-events/{event_id}/decision", response_model=ShipmentEventResponse)
|
||||
def decide_shipment_event(
|
||||
event_id: int,
|
||||
payload: ShipmentEventDecisionInput,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Resuelve un punto de decisión: autorizado o rechazado (abre corrección). R-E-13/R-I-06."""
|
||||
return service.decide_shipment_event(db, event_id, payload, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.delete("/shipment-events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_shipment_event(
|
||||
event_id: int,
|
||||
|
||||
@@ -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