- 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>
347 lines
12 KiB
Python
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
|