Files
CRM_AGENTES_CARGA/backend/tests/test_quotes.py
Ernesto Herrera 915bdd19fe feat(crm): ampliar solicitud de servicio y encadenar el ciclo comercial
Solicitud de servicio:
- Campos del documento maestro de cotización (ruta estructurada por país,
  mercancía, dimensiones/bultos, FCL/LCL, servicios adicionales, notas).
- Origen/Destino seleccionables por catálogo de país (seed ya poblado).
- Validación de contacto asociado (422 si no existe).

Ciclo Oportunidad -> Solicitud -> Cotización -> Operación:
- Dirección impo/expo se captura en la Oportunidad y se hereda al ciclo.
- Conversión Oportunidad->Solicitud idempotente con back-link.
- Endpoint Solicitud->Cotización; "Ambas" genera 2 cotizaciones (FCL/LCL).
- Liberación a Operaciones confirma IMPO/EXPO (prefijado) y siembra los hitos.
- Fecha de la cotización (issue_date) por defecto hoy, editable y en el PDF.

Folios auto-generados {LETRA}{AAAA}-{MM}-{NNN}-{DIR} para Oportunidad (O),
Solicitud (S), Cotización (C) y Operación (OP); consecutivo mensual por
compañía y entidad (crm.folio_counters + helper next_folio con bloqueo de fila).

Catálogos: 9 nuevos (tipo_operacion, medio_transporte, tipo_servicio, prioridad,
tipo_mercancia, unidad_medida, tipo_embalaje, servicio_adicional, tipo_documento).

Migración b1c2d3e4f5a6 reversible (upgrade->downgrade->upgrade verificado en PG).
25 pruebas unitarias nuevas (folios, catálogos, solicitudes, cotizaciones,
embarques); suite completa en verde (101 pruebas).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 17:51:40 -06:00

118 lines
4.6 KiB
Python

from datetime import date
from decimal import Decimal
import pytest
from fastapi import HTTPException
from api.v1.modules.crm.quotes import service
from api.v1.modules.crm.quotes.dto import QuoteCreate, QuoteItemCreate, QuoteItemUpdate
from api.v1.modules.crm.service_requests import service as sr_service
from api.v1.modules.crm.service_requests.dto import RateRequestCreate, ServiceRequestCreate
T, C = 1, 1
def test_quote_totals_recompute_on_items(db):
q = service.create_quote(db, QuoteCreate(reference="COT-001", currency="USD"), T, C)
service.create_quote_item(
db, QuoteItemCreate(quote_id=q.id, concept="flete_internacional", quantity=2, unit_cost=100, unit_sale=150), T, C
)
service.create_quote_item(
db, QuoteItemCreate(quote_id=q.id, concept="despacho_aduanal", quantity=1, unit_cost=50, unit_sale=90), T, C
)
q = service.get_quote(db, q.id, T, C)
assert float(q.total_cost) == 250.0 # 2*100 + 1*50
assert float(q.total_sale) == 390.0 # 2*150 + 1*90
def test_quote_totals_update_and_delete_item(db):
q = service.create_quote(db, QuoteCreate(reference="COT-002"), T, C)
item = service.create_quote_item(
db, QuoteItemCreate(quote_id=q.id, concept="otros", quantity=1, unit_cost=100, unit_sale=200), T, C
)
service.update_quote_item(db, item.id, QuoteItemUpdate(unit_sale=Decimal("300")), T, C)
q = service.get_quote(db, q.id, T, C)
assert float(q.total_sale) == 300.0
service.delete_quote_item(db, item.id, T, C)
q = service.get_quote(db, q.id, T, C)
assert float(q.total_sale) == 0.0
def test_accept_quote_updates_service_request(db):
sr = sr_service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C)
q = service.create_quote(db, QuoteCreate(reference="COT-003", service_request_id=sr.id), T, C)
service.send_quote(db, q.id, T, C)
accepted = service.accept_quote(db, q.id, T, C)
assert accepted.status == "aceptada"
assert accepted.accepted_at is not None
# la solicitud asociada queda aceptada
sr = sr_service.get_service_request(db, sr.id, T, C)
assert sr.status == "aceptada"
# ----- Solicitud → Cotización -----
def _sr_with_rates(db, load_type="FCL"):
sr = sr_service.create_service_request(
db, ServiceRequestCreate(operation_type="importacion", load_type=load_type, currency="USD"), T, C
)
sr_service.create_rate_request(
db, RateRequestCreate(service_request_id=sr.id, concept="flete_internacional",
rate_amount=1200, currency="USD"), T, C
)
sr_service.create_rate_request(
db, RateRequestCreate(service_request_id=sr.id, concept="despacho_aduanal",
rate_amount=300, currency="USD"), T, C
)
return sr
def test_quote_from_service_request_seeds_items(db):
sr = _sr_with_rates(db)
quotes = service.create_quotes_from_service_request(db, sr.id, T, C, user_id="dev")
assert len(quotes) == 1
q = quotes[0]
assert q.service_request_id == sr.id
assert q.reference.startswith("C") and q.reference.endswith("-I")
items = service.get_quote_items(db, q.id, T, C)
assert len(items) == 2
assert float(q.total_sale) == 1500.0 # 1200 + 300
def test_quote_from_service_request_without_rates(db):
sr = sr_service.create_service_request(
db, ServiceRequestCreate(operation_type="exportacion", load_type="FCL"), T, C
)
quotes = service.create_quotes_from_service_request(db, sr.id, T, C)
assert len(quotes) == 1
assert service.get_quote_items(db, quotes[0].id, T, C) == []
def test_quote_from_service_request_not_found(db):
with pytest.raises(HTTPException) as exc:
service.create_quotes_from_service_request(db, 999, T, C)
assert exc.value.status_code == 404
def test_quote_from_service_request_ambas_genera_dos(db):
sr = _sr_with_rates(db, load_type="AMBAS")
quotes = service.create_quotes_from_service_request(db, sr.id, T, C)
assert len(quotes) == 2
variants = {q.load_type for q in quotes}
assert variants == {"FCL", "LCL"}
# cada variante siembra sus propios conceptos y toma su propio folio
assert quotes[0].reference != quotes[1].reference
for q in quotes:
assert len(service.get_quote_items(db, q.id, T, C)) == 2
def test_quote_from_service_request_sets_issue_date_today(db):
sr = _sr_with_rates(db)
quotes = service.create_quotes_from_service_request(db, sr.id, T, C)
assert quotes[0].issue_date == date.today()
def test_create_quote_sets_issue_date_today(db):
q = service.create_quote(db, QuoteCreate(reference="COT-DATE"), T, C)
assert q.issue_date == date.today()