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:
231
backend/api/v1/modules/crm/quotes/service.py
Normal file
231
backend/api/v1/modules/crm/quotes/service.py
Normal file
@@ -0,0 +1,231 @@
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..accounts.models import Account
|
||||
from ..service_requests.models import ServiceRequest
|
||||
from ..suppliers.models import Supplier
|
||||
from .dto import QuoteCreate, QuoteItemCreate, QuoteItemUpdate, QuoteUpdate
|
||||
from .models import Quote, QuoteItem
|
||||
|
||||
|
||||
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:
|
||||
if not _exists(db, Account, data.get("account_id"), tenant_id, company_id):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El cliente asociado no existe")
|
||||
if not _exists(db, ServiceRequest, data.get("service_request_id"), tenant_id, company_id):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="La solicitud asociada no existe")
|
||||
|
||||
|
||||
def _recompute_totals(db: Session, quote: Quote) -> None:
|
||||
"""Recalcula total_cost/total_sale a partir de los conceptos vigentes."""
|
||||
cost, sale = (
|
||||
db.query(
|
||||
func.coalesce(func.sum(QuoteItem.quantity * QuoteItem.unit_cost), 0),
|
||||
func.coalesce(func.sum(QuoteItem.quantity * QuoteItem.unit_sale), 0),
|
||||
)
|
||||
.filter(QuoteItem.quote_id == quote.id, QuoteItem.deleted_at.is_(None))
|
||||
.one()
|
||||
)
|
||||
quote.total_cost = Decimal(cost or 0)
|
||||
quote.total_sale = Decimal(sale or 0)
|
||||
|
||||
|
||||
# ----- Quotes -----
|
||||
|
||||
def get_quotes(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
search: str | None = None,
|
||||
quote_status: str | None = None,
|
||||
account_id: int | None = None,
|
||||
) -> list[Quote]:
|
||||
query = db.query(Quote).filter(
|
||||
Quote.tenant_id == tenant_id,
|
||||
Quote.company_id == company_id,
|
||||
Quote.deleted_at.is_(None),
|
||||
)
|
||||
if quote_status:
|
||||
query = query.filter(Quote.status == quote_status)
|
||||
if account_id is not None:
|
||||
query = query.filter(Quote.account_id == account_id)
|
||||
if search:
|
||||
query = query.filter(Quote.reference.ilike(f"%{search}%"))
|
||||
return query.order_by(Quote.created_at.desc()).all()
|
||||
|
||||
|
||||
def get_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote:
|
||||
obj = (
|
||||
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 obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cotización no encontrada")
|
||||
return obj
|
||||
|
||||
|
||||
def create_quote(
|
||||
db: Session, payload: QuoteCreate, tenant_id: int, company_id: int, user_id: str | None = None
|
||||
) -> Quote:
|
||||
data = payload.model_dump()
|
||||
_validate_refs(db, data, tenant_id, company_id)
|
||||
obj = Quote(**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_quote(
|
||||
db: Session, quote_id: int, payload: QuoteUpdate, tenant_id: int, company_id: int, user_id: str | None = None
|
||||
) -> Quote:
|
||||
obj = get_quote(db, quote_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_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> None:
|
||||
obj = get_quote(db, quote_id, tenant_id, company_id)
|
||||
obj.deleted_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _set_service_request_status(db: Session, quote: Quote, new_status: str) -> None:
|
||||
if quote.service_request_id:
|
||||
sr = db.query(ServiceRequest).filter(ServiceRequest.id == quote.service_request_id).first()
|
||||
if sr:
|
||||
sr.status = new_status
|
||||
|
||||
|
||||
def send_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote:
|
||||
quote = get_quote(db, quote_id, tenant_id, company_id)
|
||||
quote.status = "enviada"
|
||||
quote.sent_at = datetime.now(timezone.utc)
|
||||
_set_service_request_status(db, quote, "cotizada")
|
||||
db.commit()
|
||||
db.refresh(quote)
|
||||
return quote
|
||||
|
||||
|
||||
def accept_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote:
|
||||
quote = get_quote(db, quote_id, tenant_id, company_id)
|
||||
quote.status = "aceptada"
|
||||
quote.accepted_at = datetime.now(timezone.utc)
|
||||
_set_service_request_status(db, quote, "aceptada")
|
||||
db.commit()
|
||||
db.refresh(quote)
|
||||
return quote
|
||||
|
||||
|
||||
def reject_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote:
|
||||
quote = get_quote(db, quote_id, tenant_id, company_id)
|
||||
quote.status = "rechazada"
|
||||
quote.rejected_at = datetime.now(timezone.utc)
|
||||
_set_service_request_status(db, quote, "rechazada")
|
||||
db.commit()
|
||||
db.refresh(quote)
|
||||
return quote
|
||||
|
||||
|
||||
# ----- Quote items -----
|
||||
|
||||
def get_quote_items(db: Session, quote_id: int, tenant_id: int, company_id: int) -> list[QuoteItem]:
|
||||
get_quote(db, quote_id, tenant_id, company_id) # valida scope
|
||||
return (
|
||||
db.query(QuoteItem)
|
||||
.filter(
|
||||
QuoteItem.quote_id == quote_id,
|
||||
QuoteItem.tenant_id == tenant_id,
|
||||
QuoteItem.company_id == company_id,
|
||||
QuoteItem.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(QuoteItem.id.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _get_item(db: Session, item_id: int, tenant_id: int, company_id: int) -> QuoteItem:
|
||||
item = (
|
||||
db.query(QuoteItem)
|
||||
.filter(
|
||||
QuoteItem.id == item_id,
|
||||
QuoteItem.tenant_id == tenant_id,
|
||||
QuoteItem.company_id == company_id,
|
||||
QuoteItem.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Concepto no encontrado")
|
||||
return item
|
||||
|
||||
|
||||
def create_quote_item(db: Session, payload: QuoteItemCreate, tenant_id: int, company_id: int) -> QuoteItem:
|
||||
quote = get_quote(db, payload.quote_id, tenant_id, company_id)
|
||||
if not _exists(db, Supplier, payload.supplier_id, tenant_id, company_id):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El proveedor no existe")
|
||||
item = QuoteItem(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
|
||||
db.add(item)
|
||||
db.flush()
|
||||
_recompute_totals(db, quote)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
def update_quote_item(
|
||||
db: Session, item_id: int, payload: QuoteItemUpdate, tenant_id: int, company_id: int
|
||||
) -> QuoteItem:
|
||||
item = _get_item(db, item_id, tenant_id, company_id)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if "supplier_id" in data and not _exists(db, Supplier, data["supplier_id"], tenant_id, company_id):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El proveedor no existe")
|
||||
for field, value in data.items():
|
||||
setattr(item, field, value)
|
||||
db.flush()
|
||||
quote = get_quote(db, item.quote_id, tenant_id, company_id)
|
||||
_recompute_totals(db, quote)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
def delete_quote_item(db: Session, item_id: int, tenant_id: int, company_id: int) -> None:
|
||||
item = _get_item(db, item_id, tenant_id, company_id)
|
||||
quote_id = item.quote_id
|
||||
item.deleted_at = datetime.now(timezone.utc)
|
||||
db.flush()
|
||||
quote = get_quote(db, quote_id, tenant_id, company_id)
|
||||
_recompute_totals(db, quote)
|
||||
db.commit()
|
||||
Reference in New Issue
Block a user