Catálogos y selects:
- Incoterm como catálogo (nuevo catálogo global 'incoterm') en solicitud y modal
de conversión de oportunidad.
- Moneda como catálogo en Cotizaciones (nuevo/detalle) y Facturación.
- "Tipo de transporte" desde catálogo medio_transporte (antes lista fija).
- Cotizador: origen/destino como selects alineados a las rutas de los tarifarios
activos (endpoint /rate-locations), para que el costeo siempre encuentre ruta.
Bugs de la sesión:
- Direcciones no guardaban: DTO country String(2)→String(3) (ISO alfa-3); se
amplía accounts.country y se normaliza 'MX'→'MEX' (migración).
- Contacto de proveedor mal filtrado: contacts.ts ahora envía supplier_id.
- Selects ilegibles en modo oscuro: regla global select option en app.css.
- Formas de pago SAT a 2 dígitos (01/04/08) en catálogo y valores guardados.
- RelatedManager: editar direcciones/contactos/documentos (antes solo eliminar).
Oportunidades:
- Se quitan etapas Prospecto/Contactado del embudo semilla.
- Fechas separadas won_date/lost_date + motivo de pérdida, con modal al mover a
Ganada/Perdida (migración).
Facturación:
- Folio automático F{AAAA}-{MM}-{NNN} (next_folio entidad F, sin dirección).
- Moneda como catálogo.
UI:
- Giro "otro" habilita campo para especificar (accounts.industry_other, migración).
- Lista de contactos muestra a quién pertenece (cliente/prospecto/proveedor).
- Proveedores: países/puertos/aeropuertos/aduanas por catálogo (select + chips).
Migraciones reversibles (c2d3e4f5a6b7 ya existía; d3e4f5a6b7c8, e4f5a6b7c8d9).
Suite backend en verde (109). svelte-check sin errores nuevos.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
114 lines
4.7 KiB
Python
114 lines
4.7 KiB
Python
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from api.v1.modules.crm.opportunities import service
|
|
from api.v1.modules.crm.opportunities.dto import OpportunityCreate, OpportunityUpdate
|
|
from api.v1.modules.crm.pipelines import service as pipelines_service
|
|
from api.v1.modules.crm.pipelines.dto import PipelineCreate, StageCreate
|
|
|
|
T, C = 1, 1
|
|
|
|
|
|
def _pipeline_with_stages(db):
|
|
pipeline = pipelines_service.create_pipeline(db, PipelineCreate(name="Ventas", is_default=True), T, C)
|
|
s_open = pipelines_service.create_stage(
|
|
db, StageCreate(pipeline_id=pipeline.id, name="Prospecto", position=0, probability=10), T, C
|
|
)
|
|
s_won = pipelines_service.create_stage(
|
|
db, StageCreate(pipeline_id=pipeline.id, name="Ganada", position=1, probability=100, is_won=True), T, C
|
|
)
|
|
s_lost = pipelines_service.create_stage(
|
|
db, StageCreate(pipeline_id=pipeline.id, name="Perdida", position=2, is_lost=True), T, C
|
|
)
|
|
return pipeline, s_open, s_won, s_lost
|
|
|
|
|
|
def test_create_opportunity(db):
|
|
pipeline, s_open, _, _ = _pipeline_with_stages(db)
|
|
opp = service.create_opportunity(
|
|
db,
|
|
OpportunityCreate(name="Licencia Aduanasoft", pipeline_id=pipeline.id, stage_id=s_open.id, amount=15000),
|
|
T, C,
|
|
)
|
|
assert opp.status == "open"
|
|
assert opp.currency == "MXN"
|
|
|
|
|
|
def test_move_to_won_closes_and_sets_probability(db):
|
|
pipeline, s_open, s_won, _ = _pipeline_with_stages(db)
|
|
opp = service.create_opportunity(db, OpportunityCreate(name="Deal", pipeline_id=pipeline.id, stage_id=s_open.id), T, C)
|
|
moved = service.move_opportunity(db, opp.id, s_won.id, T, C)
|
|
assert moved.status == "won"
|
|
assert moved.probability == 100
|
|
assert moved.closed_at is not None
|
|
assert moved.won_date is not None # fecha de ganada
|
|
assert moved.lost_date is None
|
|
assert moved.stage_id == s_won.id
|
|
|
|
|
|
def test_move_to_lost(db):
|
|
pipeline, s_open, _, s_lost = _pipeline_with_stages(db)
|
|
opp = service.create_opportunity(db, OpportunityCreate(name="Deal", pipeline_id=pipeline.id, stage_id=s_open.id), T, C)
|
|
moved = service.move_opportunity(db, opp.id, s_lost.id, T, C)
|
|
assert moved.status == "lost"
|
|
assert moved.probability == 0
|
|
assert moved.closed_at is not None
|
|
assert moved.lost_date is not None # fecha de perdida
|
|
assert moved.won_date is None
|
|
|
|
|
|
def test_move_back_to_open_reopens(db):
|
|
pipeline, s_open, s_won, _ = _pipeline_with_stages(db)
|
|
opp = service.create_opportunity(db, OpportunityCreate(name="Deal", pipeline_id=pipeline.id, stage_id=s_open.id), T, C)
|
|
service.move_opportunity(db, opp.id, s_won.id, T, C)
|
|
reopened = service.move_opportunity(db, opp.id, s_open.id, T, C)
|
|
assert reopened.status == "open"
|
|
assert reopened.probability == 10
|
|
assert reopened.closed_at is None
|
|
|
|
|
|
def test_create_rejects_unknown_account(db):
|
|
with pytest.raises(HTTPException) as exc:
|
|
service.create_opportunity(db, OpportunityCreate(name="X", account_id=999), T, C)
|
|
assert exc.value.status_code == 422
|
|
|
|
|
|
def test_only_one_default_pipeline(db):
|
|
pipelines_service.create_pipeline(db, PipelineCreate(name="P1", is_default=True), T, C)
|
|
pipelines_service.create_pipeline(db, PipelineCreate(name="P2", is_default=True), T, C)
|
|
pipelines = pipelines_service.get_pipelines(db, T, C)
|
|
defaults = [p for p in pipelines if p.is_default]
|
|
assert len(defaults) == 1 and defaults[0].name == "P2"
|
|
|
|
|
|
def test_create_in_won_stage_derives_state(db):
|
|
pipeline, _s_open, s_won, _ = _pipeline_with_stages(db)
|
|
opp = service.create_opportunity(
|
|
db,
|
|
OpportunityCreate(name="Directo a ganada", pipeline_id=pipeline.id, stage_id=s_won.id, amount=500),
|
|
T, C,
|
|
)
|
|
assert opp.status == "won"
|
|
assert opp.probability == 100
|
|
assert opp.closed_at is not None
|
|
|
|
|
|
def test_update_to_lost_stage_derives_state(db):
|
|
pipeline, s_open, _s_won, s_lost = _pipeline_with_stages(db)
|
|
opp = service.create_opportunity(db, OpportunityCreate(name="Z", pipeline_id=pipeline.id, stage_id=s_open.id), T, C)
|
|
updated = service.update_opportunity(db, opp.id, OpportunityUpdate(stage_id=s_lost.id), T, C)
|
|
assert updated.status == "lost"
|
|
assert updated.probability == 0
|
|
assert updated.closed_at is not None
|
|
|
|
|
|
def test_create_rejects_stage_from_other_pipeline(db):
|
|
pipeline, _s_open, _s_won, _s_lost = _pipeline_with_stages(db)
|
|
other = pipelines_service.create_pipeline(db, PipelineCreate(name="Otro"), T, C)
|
|
other_stage = pipelines_service.create_stage(db, StageCreate(pipeline_id=other.id, name="X"), T, C)
|
|
with pytest.raises(HTTPException) as exc:
|
|
service.create_opportunity(
|
|
db, OpportunityCreate(name="Y", pipeline_id=pipeline.id, stage_id=other_stage.id), T, C
|
|
)
|
|
assert exc.value.status_code == 422
|