feat(crm,ops): proceso comercial (solicitudes/RFQ, tarifas, cotizaciones) + Operaciones (embarques)
Diagrama 1 (CRM comercial) y Diagrama 2 (Operaciones) del spec de agente de carga: - crm.service_requests (RFQ) + crm.rate_requests (solicitud de tarifas a proveedores) - crm.quotes + crm.quote_items: conceptos costo/venta/margen, totales automáticos, estados borrador→enviada→aceptada/rechazada - schema ops: ops.shipments (booking, Cut Off, ETD/ETA, naviera/agente aduanal/destino) y ops.shipment_documents (MBL/HBL, MAWB/HAWB, CMR…) - liberar-a-operaciones: crea el embarque desde la cotización aceptada - migración b3c4d5e6f7a8, routers/permisos, seed del flujo completo - 52 tests pytest en verde Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
0
backend/api/v1/modules/ops/__init__.py
Normal file
0
backend/api/v1/modules/ops/__init__.py
Normal file
26
backend/api/v1/modules/ops/permissions.py
Normal file
26
backend/api/v1/modules/ops/permissions.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Registro de permisos del módulo Operaciones (ops)."""
|
||||
|
||||
from api.v1.modules.core.permissions.registry import registry
|
||||
|
||||
MODULE = "ops"
|
||||
|
||||
_ENTITIES = [
|
||||
("shipment", "embarques"),
|
||||
("document", "documentos de embarque"),
|
||||
]
|
||||
_ACTIONS = [("view", "Ver"), ("create", "Crear"), ("edit", "Editar"), ("delete", "Eliminar")]
|
||||
|
||||
|
||||
def register_permissions() -> None:
|
||||
registry.register(code=f"{MODULE}.access", description="Acceso a Operaciones", module=MODULE, action="access")
|
||||
for entity, label in _ENTITIES:
|
||||
for action, verb in _ACTIONS:
|
||||
registry.register(
|
||||
code=f"{MODULE}.{entity}.{action}",
|
||||
description=f"{verb} {label}",
|
||||
module=MODULE,
|
||||
action=action,
|
||||
)
|
||||
|
||||
|
||||
register_permissions()
|
||||
13
backend/api/v1/modules/ops/router.py
Normal file
13
backend/api/v1/modules/ops/router.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Router agregador del módulo Operaciones (Diagramas 2-4).
|
||||
|
||||
Se monta bajo el prefijo ``/ops`` en ``api/v1/router.py``.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import permissions # noqa: F401 (side-effect: registra permisos de ops)
|
||||
from .shipments.routes import router as shipments_router
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
router.include_router(shipments_router)
|
||||
0
backend/api/v1/modules/ops/shipments/__init__.py
Normal file
0
backend/api/v1/modules/ops/shipments/__init__.py
Normal file
102
backend/api/v1/modules/ops/shipments/dto.py
Normal file
102
backend/api/v1/modules/ops/shipments/dto.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ShipmentBase(BaseModel):
|
||||
reference: str | None = Field(None, max_length=40)
|
||||
quote_id: int | None = None
|
||||
service_request_id: int | None = None
|
||||
account_id: int | None = None
|
||||
operation_type: str | None = Field(None, max_length=20)
|
||||
transport_mode: str | None = Field(None, max_length=20)
|
||||
service_type: str | None = Field(None, max_length=20)
|
||||
incoterm: str | None = Field(None, max_length=10)
|
||||
origin: str | None = Field(None, max_length=160)
|
||||
destination: str | None = Field(None, max_length=160)
|
||||
status: str = Field("abierta", max_length=20)
|
||||
booking_number: str | None = Field(None, max_length=60)
|
||||
carrier_supplier_id: int | None = None
|
||||
customs_agent_id: int | None = None
|
||||
destination_agent_id: int | None = None
|
||||
cutoff_date: datetime | None = None
|
||||
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
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
|
||||
|
||||
class ShipmentCreate(ShipmentBase):
|
||||
pass
|
||||
|
||||
|
||||
class ShipmentUpdate(BaseModel):
|
||||
reference: str | None = Field(None, max_length=40)
|
||||
account_id: int | None = None
|
||||
operation_type: str | None = Field(None, max_length=20)
|
||||
transport_mode: str | None = Field(None, max_length=20)
|
||||
service_type: str | None = Field(None, max_length=20)
|
||||
incoterm: str | None = Field(None, max_length=10)
|
||||
origin: str | None = Field(None, max_length=160)
|
||||
destination: str | None = Field(None, max_length=160)
|
||||
status: str | None = Field(None, max_length=20)
|
||||
booking_number: str | None = Field(None, max_length=60)
|
||||
carrier_supplier_id: int | None = None
|
||||
customs_agent_id: int | None = None
|
||||
destination_agent_id: int | None = None
|
||||
cutoff_date: datetime | None = None
|
||||
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
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
|
||||
|
||||
class ShipmentResponse(ShipmentBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
created_by: str | None = None
|
||||
updated_by: str | None = None
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ShipmentDocumentBase(BaseModel):
|
||||
shipment_id: int
|
||||
doc_kind: str = Field("otro", max_length=10)
|
||||
doc_type: str = Field(..., max_length=30)
|
||||
number: str | None = Field(None, max_length=80)
|
||||
issue_date: date | None = None
|
||||
file_url: str | None = Field(None, max_length=1024)
|
||||
file_key: str | None = Field(None, max_length=512)
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ShipmentDocumentCreate(ShipmentDocumentBase):
|
||||
pass
|
||||
|
||||
|
||||
class ShipmentDocumentUpdate(BaseModel):
|
||||
doc_kind: str | None = Field(None, max_length=10)
|
||||
doc_type: str | None = Field(None, max_length=30)
|
||||
number: str | None = Field(None, max_length=80)
|
||||
issue_date: date | None = None
|
||||
file_url: str | None = Field(None, max_length=1024)
|
||||
file_key: str | None = Field(None, max_length=512)
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ShipmentDocumentResponse(ShipmentDocumentBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
73
backend/api/v1/modules/ops/shipments/models.py
Normal file
73
backend/api/v1/modules/ops/shipments/models.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class Shipment(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Operación / Embarque (Diagrama 2). Se crea al liberar una cotización aceptada."""
|
||||
|
||||
__tablename__ = "shipments"
|
||||
__table_args__ = {"schema": "ops"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio de embarque
|
||||
quote_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.quotes.id"), nullable=True, index=True
|
||||
)
|
||||
service_request_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.service_requests.id"), nullable=True
|
||||
)
|
||||
account_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
|
||||
)
|
||||
operation_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
transport_mode: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
service_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
incoterm: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
origin: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
destination: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
# abierta | booking | en_transito | arribado | entregada | cerrada | cancelada
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'abierta'"), index=True)
|
||||
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
|
||||
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
|
||||
cutoff_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # Cut Off
|
||||
etd: Mapped[date | None] = mapped_column(Date, nullable=True) # salida estimada
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
class ShipmentDocument(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Documento de transporte del embarque (Master/House: MBL, HBL, MAWB, HAWB, CMR, etc.)."""
|
||||
|
||||
__tablename__ = "shipment_documents"
|
||||
__table_args__ = {"schema": "ops"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
shipment_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("ops.shipments.id"), nullable=False, index=True
|
||||
)
|
||||
doc_kind: Mapped[str] = mapped_column(String(10), nullable=False, server_default=text("'otro'")) # master|house|otro
|
||||
# MBL | HBL | MAWB | HAWB | CMR | factura_comercial | packing_list | carta_encomienda | carta_garantia | otro
|
||||
doc_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
number: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
issue_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
file_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
132
backend/api/v1/modules/ops/shipments/routes.py
Normal file
132
backend/api/v1/modules/ops/shipments/routes.py
Normal file
@@ -0,0 +1,132 @@
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
|
||||
from . import service
|
||||
from .dto import (
|
||||
ShipmentCreate,
|
||||
ShipmentDocumentCreate,
|
||||
ShipmentDocumentResponse,
|
||||
ShipmentDocumentUpdate,
|
||||
ShipmentResponse,
|
||||
ShipmentUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _user_id(current_user: dict) -> str | None:
|
||||
return current_user.get("sub") or current_user.get("id")
|
||||
|
||||
|
||||
@router.get("/shipments", response_model=list[ShipmentResponse])
|
||||
def list_shipments(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
search: str | None = Query(None),
|
||||
shipment_status: str | None = Query(None, alias="status"),
|
||||
account_id: int | None = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.get_shipments(db, tenant_id, company_id, search, shipment_status, account_id)
|
||||
|
||||
|
||||
@router.get("/shipments/{shipment_id}", response_model=ShipmentResponse)
|
||||
def get_shipment(
|
||||
shipment_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
return service.get_shipment(db, shipment_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.post("/shipments", response_model=ShipmentResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_shipment(
|
||||
payload: ShipmentCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.create_shipment(db, payload, tenant_id, company_id, _user_id(current_user))
|
||||
|
||||
|
||||
@router.post("/shipments/from-quote", response_model=ShipmentResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_shipment_from_quote(
|
||||
quote_id: int = Query(..., description="Cotización aceptada a liberar"),
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.create_shipment_from_quote(db, quote_id, tenant_id, company_id, _user_id(current_user))
|
||||
|
||||
|
||||
@router.patch("/shipments/{shipment_id}", response_model=ShipmentResponse)
|
||||
def update_shipment(
|
||||
shipment_id: int,
|
||||
payload: ShipmentUpdate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.update_shipment(db, shipment_id, payload, tenant_id, company_id, _user_id(current_user))
|
||||
|
||||
|
||||
@router.delete("/shipments/{shipment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_shipment(
|
||||
shipment_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
service.delete_shipment(db, shipment_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
# ----- Documentos del embarque -----
|
||||
|
||||
@router.get("/shipments/{shipment_id}/documents", response_model=list[ShipmentDocumentResponse])
|
||||
def list_shipment_documents(
|
||||
shipment_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
service.get_shipment(db, shipment_id, current_user["tenant_id"], company_id)
|
||||
return service.get_shipment_documents(db, current_user["tenant_id"], company_id, shipment_id)
|
||||
|
||||
|
||||
@router.post("/shipment-documents", response_model=ShipmentDocumentResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_shipment_document(
|
||||
payload: ShipmentDocumentCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
return service.create_shipment_document(db, payload, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.patch("/shipment-documents/{doc_id}", response_model=ShipmentDocumentResponse)
|
||||
def update_shipment_document(
|
||||
doc_id: int,
|
||||
payload: ShipmentDocumentUpdate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
return service.update_shipment_document(db, doc_id, payload, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.delete("/shipment-documents/{doc_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_shipment_document(
|
||||
doc_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
service.delete_shipment_document(db, doc_id, current_user["tenant_id"], company_id)
|
||||
230
backend/api/v1/modules/ops/shipments/service.py
Normal file
230
backend/api/v1/modules/ops/shipments/service.py
Normal file
@@ -0,0 +1,230 @@
|
||||
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, ShipmentUpdate
|
||||
from .models import Shipment, ShipmentDocument
|
||||
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user