- Tabla crm.cases (expediente) con folio EXP2026-08-001 (next_folio entidad EXP,
sin dirección). Nace al crear la Oportunidad y se hereda vía case_id a
solicitud → cotización → operación → factura. advance_stage solo avanza.
- case_id (FK a crm.cases) en crm.opportunities/service_requests/quotes,
ops.shipments y fin.invoices; propagación en sus create_*. Migración
d4e5f6a7b8c9 reversible.
- Endpoints GET /v1/crm/cases, /cases/{id}, /cases/by-ref/{ref} con timeline
(historia completa para UI y otros sistemas).
- Frontend: casesAPI, ruta /dashboard/crm/expedientes (lista + timeline vertical),
chip "📁 Expediente" en solicitud/cotización, "Expedientes" en el sidebar.
- Consecutivo de folios sin tope (soporta >10,000,000/mes).
- 4 pruebas de expediente (minteo, propagación, timeline, no-retroceso). Suite en verde (113).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
421 lines
16 KiB
Python
421 lines
16 KiB
Python
from datetime import date, 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 ..cases import service as cases_service
|
|
from ..catalogs.models import CatalogItem
|
|
from ..common.folios import next_folio
|
|
from ..common.pricing import air_chargeable_kg
|
|
from ..service_requests.models import RateRequest, 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}%"))
|
|
quotes = query.order_by(Quote.created_at.desc()).all()
|
|
# Enriquecer con el folio de la solicitud referenciada (para verlo en la lista)
|
|
sr_ids = {q.service_request_id for q in quotes if q.service_request_id}
|
|
if sr_ids:
|
|
refs = dict(
|
|
db.query(ServiceRequest.id, ServiceRequest.reference)
|
|
.filter(ServiceRequest.id.in_(sr_ids))
|
|
.all()
|
|
)
|
|
for q in quotes:
|
|
q.service_request_reference = refs.get(q.service_request_id)
|
|
return quotes
|
|
|
|
|
|
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 _sr_direction(db: Session, service_request_id: int | None) -> str | None:
|
|
"""Dirección impo/expo heredada de la solicitud asociada (para el folio)."""
|
|
if not service_request_id:
|
|
return None
|
|
sr = db.query(ServiceRequest).filter(ServiceRequest.id == service_request_id).first()
|
|
return sr.operation_type if sr else None
|
|
|
|
|
|
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)
|
|
# Fecha de la cotización: por defecto hoy si no se capturó
|
|
if obj.issue_date is None:
|
|
obj.issue_date = date.today()
|
|
# Folio C... auto-generado (mensual), con la dirección heredada de la solicitud
|
|
if not obj.reference:
|
|
obj.reference = next_folio(db, tenant_id, company_id, "C", _sr_direction(db, obj.service_request_id))
|
|
# Expediente heredado de la solicitud
|
|
if obj.service_request_id and not obj.case_id:
|
|
sr = db.query(ServiceRequest).filter(ServiceRequest.id == obj.service_request_id).first()
|
|
if sr:
|
|
obj.case_id = sr.case_id
|
|
cases_service.advance_stage(db, obj.case_id, "cotizacion")
|
|
db.add(obj)
|
|
db.commit()
|
|
db.refresh(obj)
|
|
return obj
|
|
|
|
|
|
def create_quotes_from_service_request(
|
|
db: Session, service_request_id: int, tenant_id: int, company_id: int, user_id: str | None = None
|
|
) -> list[Quote]:
|
|
"""Genera cotización(es) a partir de una solicitud de servicio.
|
|
|
|
Si la solicitud es "Ambas" (FCL y LCL), genera **dos** cotizaciones (una por
|
|
variante) para comparar. Cada cotización toma su propio folio C... y hereda la
|
|
dirección impo/expo de la solicitud. Los conceptos se siembran desde las
|
|
solicitudes de tarifa (RateRequest) capturadas en la solicitud.
|
|
"""
|
|
sr = (
|
|
db.query(ServiceRequest)
|
|
.filter(
|
|
ServiceRequest.id == service_request_id,
|
|
ServiceRequest.tenant_id == tenant_id,
|
|
ServiceRequest.company_id == company_id,
|
|
ServiceRequest.deleted_at.is_(None),
|
|
)
|
|
.first()
|
|
)
|
|
if not sr:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Solicitud no encontrada")
|
|
|
|
variants = ["FCL", "LCL"] if (sr.load_type or "").upper() == "AMBAS" else [sr.load_type or None]
|
|
rate_requests = (
|
|
db.query(RateRequest)
|
|
.filter(
|
|
RateRequest.service_request_id == sr.id,
|
|
RateRequest.tenant_id == tenant_id,
|
|
RateRequest.company_id == company_id,
|
|
RateRequest.deleted_at.is_(None),
|
|
)
|
|
.all()
|
|
)
|
|
# Etiquetas legibles de los servicios adicionales (global + tenant) para los conceptos
|
|
service_labels = {
|
|
code: label
|
|
for code, label in db.query(CatalogItem.code, CatalogItem.label).filter(
|
|
CatalogItem.catalog == "servicio_adicional"
|
|
)
|
|
}
|
|
service_costs = sr.additional_service_costs or {}
|
|
|
|
created: list[Quote] = []
|
|
for variant in variants:
|
|
quote = Quote(
|
|
account_id=sr.account_id,
|
|
service_request_id=sr.id,
|
|
currency=sr.currency or "USD",
|
|
load_type=variant,
|
|
status="borrador",
|
|
issue_date=date.today(),
|
|
notes=sr.client_notes or sr.notes,
|
|
owner_user_id=sr.owner_user_id,
|
|
reference=next_folio(db, tenant_id, company_id, "C", sr.operation_type),
|
|
case_id=sr.case_id,
|
|
tenant_id=tenant_id,
|
|
company_id=company_id,
|
|
created_by=user_id,
|
|
updated_by=user_id,
|
|
)
|
|
db.add(quote)
|
|
db.flush()
|
|
for rr in rate_requests:
|
|
amount = rr.rate_amount if rr.rate_amount is not None else Decimal(0)
|
|
db.add(QuoteItem(
|
|
quote_id=quote.id, concept=rr.concept, description=rr.description,
|
|
supplier_id=rr.supplier_id, quantity=Decimal(1),
|
|
unit_cost=amount, unit_sale=amount, currency=rr.currency,
|
|
tenant_id=tenant_id, company_id=company_id,
|
|
))
|
|
# Servicios adicionales marcados en la solicitud → conceptos con su costo estimado
|
|
for code in (sr.additional_services or []):
|
|
amount = Decimal(str(service_costs.get(code) or 0))
|
|
db.add(QuoteItem(
|
|
quote_id=quote.id, concept=code[:60],
|
|
description=service_labels.get(code, "Servicio adicional"),
|
|
quantity=Decimal(1), unit_cost=amount, unit_sale=amount,
|
|
currency=sr.currency, tenant_id=tenant_id, company_id=company_id,
|
|
))
|
|
# Carga aérea: concepto de flete con el peso a cobrar (P/Vol) como cantidad,
|
|
# para que el ejecutivo capture la tarifa por kg.
|
|
if (variant or "").upper() == "AEREO":
|
|
chargeable = air_chargeable_kg(
|
|
sr.weight, sr.length_cm, sr.width_cm, sr.height_cm,
|
|
sr.pallets_count or sr.pieces_count or 1,
|
|
)
|
|
db.add(QuoteItem(
|
|
quote_id=quote.id, concept="flete_internacional",
|
|
description=f"Flete aéreo — peso a cobrar {chargeable.quantize(Decimal('0.01'))} kg (P/Vol)",
|
|
quantity=chargeable, unit_cost=Decimal(0), unit_sale=Decimal(0),
|
|
currency=sr.currency, tenant_id=tenant_id, company_id=company_id,
|
|
))
|
|
db.flush()
|
|
_recompute_totals(db, quote)
|
|
created.append(quote)
|
|
|
|
cases_service.advance_stage(db, sr.case_id, "cotizacion")
|
|
db.commit()
|
|
for quote in created:
|
|
db.refresh(quote)
|
|
return created
|
|
|
|
|
|
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
|
|
|
|
|
|
def clone_quote(
|
|
db: Session, quote_id: int, tenant_id: int, company_id: int, user_id: str | None = None
|
|
) -> Quote:
|
|
"""Clona una cotización (y sus conceptos) como borrador para re-cotizar (R-C-12).
|
|
|
|
Si la cotización origen fue rechazada, reabre su solicitud a 'en_analisis' para
|
|
cerrar el ciclo de reintento del Diagrama 1.
|
|
"""
|
|
src = get_quote(db, quote_id, tenant_id, company_id)
|
|
new_quote = Quote(
|
|
reference=(f"{src.reference}-R" if src.reference else None),
|
|
service_request_id=src.service_request_id,
|
|
account_id=src.account_id,
|
|
currency=src.currency,
|
|
status="borrador",
|
|
valid_until=src.valid_until,
|
|
notes=src.notes,
|
|
terms=src.terms,
|
|
owner_user_id=src.owner_user_id,
|
|
tenant_id=tenant_id,
|
|
company_id=company_id,
|
|
created_by=user_id,
|
|
updated_by=user_id,
|
|
)
|
|
db.add(new_quote)
|
|
db.flush()
|
|
src_items = (
|
|
db.query(QuoteItem)
|
|
.filter(QuoteItem.quote_id == src.id, QuoteItem.deleted_at.is_(None))
|
|
.all()
|
|
)
|
|
for it in src_items:
|
|
db.add(QuoteItem(
|
|
quote_id=new_quote.id, concept=it.concept, description=it.description,
|
|
supplier_id=it.supplier_id, quantity=it.quantity, unit_cost=it.unit_cost,
|
|
unit_sale=it.unit_sale, currency=it.currency,
|
|
tenant_id=tenant_id, company_id=company_id,
|
|
))
|
|
db.flush()
|
|
_recompute_totals(db, new_quote)
|
|
# Reabre la solicitud origen para el ciclo de re-cotización
|
|
if src.service_request_id:
|
|
sr = db.query(ServiceRequest).filter(ServiceRequest.id == src.service_request_id).first()
|
|
if sr and sr.status in ("rechazada", "cotizada"):
|
|
sr.status = "en_analisis"
|
|
db.commit()
|
|
db.refresh(new_quote)
|
|
return new_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()
|