Files
CRM_AGENTES_CARGA/backend/api/v1/modules/ops/shipments/service.py
Aduanasoft 0b12ad5354 feat(fin,ops): Facturación y Cobranza (Diag. 4), bitácora de embarque (Diag. 3) y subida a MinIO
- schema fin: fin.invoices + fin.invoice_items + fin.payments; totales con IVA,
  estados borrador→emitida→enviada→pagada, cobranza (pagos) y saldo automático
- generar-factura-desde-embarque (toma conceptos de venta de la cotización)
- ops.shipment_events: bitácora/hitos del embarque con secuencia por defecto
  según operación (importación/exportación) — cubre Diagrama 3
- subida de documentos a MinIO: POST /crm/uploads (multipart) + URL firmada
- migración c4d5e6f7a8b9, routers/permisos (fin), seed del flujo hasta factura
- 58 tests pytest en verde

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 07:18:11 -06:00

347 lines
12 KiB
Python

from datetime import datetime, timezone
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from api.v1.modules.crm.accounts.models import Account
from api.v1.modules.crm.quotes.models import Quote
from api.v1.modules.crm.service_requests.models import ServiceRequest
from api.v1.modules.crm.suppliers.models import Supplier
from .dto import (
ShipmentCreate,
ShipmentDocumentCreate,
ShipmentDocumentUpdate,
ShipmentEventCreate,
ShipmentEventUpdate,
ShipmentUpdate,
)
from .models import Shipment, ShipmentDocument, ShipmentEvent
# Hitos por defecto según el tipo de operación (Diagramas 2 y 3)
_DEFAULT_MILESTONES = {
"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"),
],
"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"),
],
}
def _exists(db: Session, model, _id: int | None, tenant_id: int, company_id: int) -> bool:
if _id is None:
return True
return (
db.query(model.id)
.filter(
model.id == _id,
model.tenant_id == tenant_id,
model.company_id == company_id,
model.deleted_at.is_(None),
)
.first()
is not None
)
def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None:
checks = [
("account_id", Account, "El cliente asociado no existe"),
("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"),
("customs_agent_id", Supplier, "El agente aduanal no existe"),
("destination_agent_id", Supplier, "El agente en destino no existe"),
]
for field, model, msg in checks:
if field in data and not _exists(db, model, data.get(field), tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg)
def get_shipments(
db: Session,
tenant_id: int,
company_id: int,
search: str | None = None,
shipment_status: str | None = None,
account_id: int | None = None,
) -> list[Shipment]:
query = db.query(Shipment).filter(
Shipment.tenant_id == tenant_id,
Shipment.company_id == company_id,
Shipment.deleted_at.is_(None),
)
if shipment_status:
query = query.filter(Shipment.status == shipment_status)
if account_id is not None:
query = query.filter(Shipment.account_id == account_id)
if search:
pattern = f"%{search}%"
query = query.filter(
Shipment.reference.ilike(pattern)
| Shipment.booking_number.ilike(pattern)
| Shipment.origin.ilike(pattern)
| Shipment.destination.ilike(pattern)
)
return query.order_by(Shipment.created_at.desc()).all()
def get_shipment(db: Session, shipment_id: int, tenant_id: int, company_id: int) -> Shipment:
obj = (
db.query(Shipment)
.filter(
Shipment.id == shipment_id,
Shipment.tenant_id == tenant_id,
Shipment.company_id == company_id,
Shipment.deleted_at.is_(None),
)
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Embarque no encontrado")
return obj
def create_shipment(
db: Session, payload: ShipmentCreate, tenant_id: int, company_id: int, user_id: str | None = None
) -> Shipment:
data = payload.model_dump()
_validate_refs(db, data, tenant_id, company_id)
obj = Shipment(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def update_shipment(
db: Session, shipment_id: int, payload: ShipmentUpdate, tenant_id: int, company_id: int, user_id: str | None = None
) -> Shipment:
obj = get_shipment(db, shipment_id, tenant_id, company_id)
data = payload.model_dump(exclude_unset=True)
_validate_refs(db, data, tenant_id, company_id)
for field, value in data.items():
setattr(obj, field, value)
obj.updated_by = user_id
db.commit()
db.refresh(obj)
return obj
def delete_shipment(db: Session, shipment_id: int, tenant_id: int, company_id: int) -> None:
obj = get_shipment(db, shipment_id, tenant_id, company_id)
obj.deleted_at = datetime.now(timezone.utc)
db.commit()
def create_shipment_from_quote(
db: Session, quote_id: int, tenant_id: int, company_id: int, user_id: str | None = None
) -> Shipment:
"""Liberar a Operaciones: crea el embarque a partir de una cotización aceptada."""
quote = (
db.query(Quote)
.filter(
Quote.id == quote_id,
Quote.tenant_id == tenant_id,
Quote.company_id == company_id,
Quote.deleted_at.is_(None),
)
.first()
)
if not quote:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cotización no encontrada")
if quote.status != "aceptada":
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="La cotización debe estar aceptada para liberarse a Operaciones",
)
sr = None
if quote.service_request_id:
sr = db.query(ServiceRequest).filter(ServiceRequest.id == quote.service_request_id).first()
shipment = Shipment(
reference=quote.reference,
quote_id=quote.id,
service_request_id=quote.service_request_id,
account_id=quote.account_id,
operation_type=sr.operation_type if sr else None,
transport_mode=sr.transport_mode if sr else None,
service_type=sr.service_type if sr else None,
incoterm=sr.incoterm if sr else None,
origin=sr.origin if sr else None,
destination=sr.destination if sr else None,
destination_agent_id=sr.destination_agent_id if sr else None,
status="abierta",
owner_user_id=quote.owner_user_id,
tenant_id=tenant_id,
company_id=company_id,
created_by=user_id,
updated_by=user_id,
)
db.add(shipment)
if sr:
sr.status = "liberada"
db.commit()
db.refresh(shipment)
return shipment
# ----- Documentos del embarque -----
def get_shipment_documents(
db: Session, tenant_id: int, company_id: int, shipment_id: int | None = None
) -> list[ShipmentDocument]:
query = db.query(ShipmentDocument).filter(
ShipmentDocument.tenant_id == tenant_id,
ShipmentDocument.company_id == company_id,
ShipmentDocument.deleted_at.is_(None),
)
if shipment_id is not None:
query = query.filter(ShipmentDocument.shipment_id == shipment_id)
return query.order_by(ShipmentDocument.id.asc()).all()
def _get_document(db: Session, doc_id: int, tenant_id: int, company_id: int) -> ShipmentDocument:
obj = (
db.query(ShipmentDocument)
.filter(
ShipmentDocument.id == doc_id,
ShipmentDocument.tenant_id == tenant_id,
ShipmentDocument.company_id == company_id,
ShipmentDocument.deleted_at.is_(None),
)
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Documento no encontrado")
return obj
def create_shipment_document(
db: Session, payload: ShipmentDocumentCreate, tenant_id: int, company_id: int
) -> ShipmentDocument:
get_shipment(db, payload.shipment_id, tenant_id, company_id) # valida scope
obj = ShipmentDocument(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def update_shipment_document(
db: Session, doc_id: int, payload: ShipmentDocumentUpdate, tenant_id: int, company_id: int
) -> ShipmentDocument:
obj = _get_document(db, doc_id, tenant_id, company_id)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
def delete_shipment_document(db: Session, doc_id: int, tenant_id: int, company_id: int) -> None:
obj = _get_document(db, doc_id, tenant_id, company_id)
obj.deleted_at = datetime.now(timezone.utc)
db.commit()
# ----- Bitácora / hitos del embarque -----
def get_shipment_events(db: Session, tenant_id: int, company_id: int, shipment_id: int | None = None) -> list[ShipmentEvent]:
query = db.query(ShipmentEvent).filter(
ShipmentEvent.tenant_id == tenant_id,
ShipmentEvent.company_id == company_id,
ShipmentEvent.deleted_at.is_(None),
)
if shipment_id is not None:
query = query.filter(ShipmentEvent.shipment_id == shipment_id)
return query.order_by(ShipmentEvent.position.asc(), ShipmentEvent.id.asc()).all()
def _get_event(db: Session, event_id: int, tenant_id: int, company_id: int) -> ShipmentEvent:
obj = (
db.query(ShipmentEvent)
.filter(
ShipmentEvent.id == event_id,
ShipmentEvent.tenant_id == tenant_id,
ShipmentEvent.company_id == company_id,
ShipmentEvent.deleted_at.is_(None),
)
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hito no encontrado")
return obj
def create_shipment_event(db: Session, payload: ShipmentEventCreate, tenant_id: int, company_id: int) -> ShipmentEvent:
get_shipment(db, payload.shipment_id, tenant_id, company_id)
obj = ShipmentEvent(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def update_shipment_event(db: Session, event_id: int, payload: ShipmentEventUpdate, tenant_id: int, company_id: int) -> ShipmentEvent:
obj = _get_event(db, event_id, tenant_id, company_id)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
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)
obj.status = "completado"
obj.actual_date = datetime.now(timezone.utc)
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 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)
existing = get_shipment_events(db, tenant_id, company_id, shipment_id)
if existing:
return existing
milestones = _DEFAULT_MILESTONES.get(shipment.operation_type or "", [])
if not milestones:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Define el tipo de operación (importación/exportación) para generar los hitos",
)
created = []
for position, (event_type, title) 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,
)
db.add(ev)
created.append(ev)
db.commit()
for ev in created:
db.refresh(ev)
return created