diff --git a/backend/alembic/versions/c2d3e4f5a6b7_service_request_additional_service_costs.py b/backend/alembic/versions/c2d3e4f5a6b7_service_request_additional_service_costs.py new file mode 100644 index 0000000..724438c --- /dev/null +++ b/backend/alembic/versions/c2d3e4f5a6b7_service_request_additional_service_costs.py @@ -0,0 +1,33 @@ +"""Costo estimado por servicio adicional en la solicitud de servicio + +Revision ID: c2d3e4f5a6b7 +Revises: b1c2d3e4f5a6 +Create Date: 2026-08-04 00:00:00.000000 + +Agrega crm.service_requests.additional_service_costs (JSON: {codigo_servicio: costo}) +para capturar el costo estimado de cada servicio adicional marcado; ese costo se +usa como punto de partida al sembrar los conceptos de la cotización. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "c2d3e4f5a6b7" +down_revision: Union[str, None] = "b1c2d3e4f5a6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = "crm" + + +def upgrade() -> None: + op.add_column( + "service_requests", + sa.Column("additional_service_costs", sa.JSON(), nullable=True), + schema=SCHEMA, + ) + + +def downgrade() -> None: + op.drop_column("service_requests", "additional_service_costs", schema=SCHEMA) diff --git a/backend/api/v1/modules/crm/quotes/service.py b/backend/api/v1/modules/crm/quotes/service.py index 365308d..7c39673 100644 --- a/backend/api/v1/modules/crm/quotes/service.py +++ b/backend/api/v1/modules/crm/quotes/service.py @@ -6,6 +6,7 @@ from sqlalchemy import func from sqlalchemy.orm import Session from ..accounts.models import Account +from ..catalogs.models import CatalogItem from ..common.folios import next_folio from ..service_requests.models import RateRequest, ServiceRequest from ..suppliers.models import Supplier @@ -150,6 +151,14 @@ def create_quotes_from_service_request( ) .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: @@ -178,6 +187,15 @@ def create_quotes_from_service_request( 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, + )) db.flush() _recompute_totals(db, quote) created.append(quote) diff --git a/backend/api/v1/modules/crm/service_requests/dto.py b/backend/api/v1/modules/crm/service_requests/dto.py index 7ccb583..a45ddde 100644 --- a/backend/api/v1/modules/crm/service_requests/dto.py +++ b/backend/api/v1/modules/crm/service_requests/dto.py @@ -61,6 +61,7 @@ class ServiceRequestBase(BaseModel): volume_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) # Servicios adicionales (códigos del catálogo servicio_adicional) y pago additional_services: list[str] | None = None + additional_service_costs: dict[str, float] | None = None # {codigo: costo estimado} payment_method: str | None = Field(None, max_length=20) destination_agent_id: int | None = None requirements: str | None = None @@ -146,6 +147,7 @@ class ServiceRequestUpdate(BaseModel): weight_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) volume_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) additional_services: list[str] | None = None + additional_service_costs: dict[str, float] | None = None payment_method: str | None = Field(None, max_length=20) destination_agent_id: int | None = None requirements: str | None = None diff --git a/backend/api/v1/modules/crm/service_requests/models.py b/backend/api/v1/modules/crm/service_requests/models.py index e141967..5fbd64e 100644 --- a/backend/api/v1/modules/crm/service_requests/models.py +++ b/backend/api/v1/modules/crm/service_requests/models.py @@ -101,6 +101,8 @@ class ServiceRequest(Base, TenantScopedMixin, TimestampMixin): volume_per_pallet: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True) # Servicios adicionales (lista de códigos del catálogo servicio_adicional) y pago additional_services: Mapped[list | None] = mapped_column(JSON, nullable=True) + # Costo estimado por servicio adicional marcado: {codigo: costo} + additional_service_costs: Mapped[dict | None] = mapped_column(JSON, nullable=True) payment_method: Mapped[str | None] = mapped_column(String(20), nullable=True) # Notas client_notes: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/tests/test_quotes.py b/backend/tests/test_quotes.py index aa265ec..b2ccd9f 100644 --- a/backend/tests/test_quotes.py +++ b/backend/tests/test_quotes.py @@ -106,6 +106,25 @@ def test_quote_from_service_request_ambas_genera_dos(db): assert len(service.get_quote_items(db, q.id, T, C)) == 2 +def test_quote_from_service_request_seeds_additional_services(db): + sr = sr_service.create_service_request( + db, + ServiceRequestCreate( + operation_type="importacion", load_type="FCL", currency="USD", + additional_services=["seguro", "despacho_aduanal"], + additional_service_costs={"seguro": 500, "despacho_aduanal": 300}, + ), + T, C, + ) + quotes = service.create_quotes_from_service_request(db, sr.id, T, C) + items = service.get_quote_items(db, quotes[0].id, T, C) + costs = {i.concept: float(i.unit_cost) for i in items} + assert costs.get("seguro") == 500.0 + assert costs.get("despacho_aduanal") == 300.0 + # el costo estimado de la solicitud es el punto de partida (costo=venta) + assert float(quotes[0].total_sale) == 800.0 + + 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) diff --git a/frontend/src/lib/api/crm/commercial.ts b/frontend/src/lib/api/crm/commercial.ts index 184c5fb..3afe080 100644 --- a/frontend/src/lib/api/crm/commercial.ts +++ b/frontend/src/lib/api/crm/commercial.ts @@ -59,6 +59,7 @@ export interface ServiceRequest { weight_per_pallet: number | null; volume_per_pallet: number | null; additional_services: string[] | null; + additional_service_costs: Record | null; payment_method: string | null; destination_agent_id: number | null; requirements: string | null; diff --git a/frontend/src/lib/components/crm/ServiceRequestFields.svelte b/frontend/src/lib/components/crm/ServiceRequestFields.svelte index 6f86a54..7f8d7b2 100644 --- a/frontend/src/lib/components/crm/ServiceRequestFields.svelte +++ b/frontend/src/lib/components/crm/ServiceRequestFields.svelte @@ -34,6 +34,18 @@ return [c.first_name, c.last_name].filter(Boolean).join(' '); } + // Costo estimado por servicio adicional (se traspasa a la cotización) + function serviceCost(code: string): number | undefined { + return form.additional_service_costs?.[code]; + } + function setServiceCost(code: string, value: string) { + const map = { ...(form.additional_service_costs ?? {}) }; + const n = value === '' ? NaN : Number(value); + if (Number.isNaN(n)) delete map[code]; + else map[code] = n; + form.additional_service_costs = map; + } + onMount(() => { void crmCatalogs.preload([ 'pais', 'moneda', 'prioridad', 'tipo_mercancia', 'unidad_medida', @@ -41,12 +53,13 @@ 'puerto', 'aeropuerto' ]); if (!form.additional_services) form.additional_services = []; + if (!form.additional_service_costs) form.additional_service_costs = {}; }); {#if tab === 'datos'}
- + @@ -139,13 +152,23 @@
{/if} {:else if tab === 'servicios'} -

Servicios adicionales

-
+

Servicios adicionales

+

Marca los servicios requeridos e indica su costo estimado (opcional). Al cotizar, cada servicio marcado se agrega como concepto de la cotización con ese costo de partida.

+
{#each crmCatalogs.options('servicio_adicional') as s (s.value)} - + {@const checked = (form.additional_services ?? []).includes(s.value)} +
+ + {#if checked} +
+ setServiceCost(s.value, e.currentTarget.value)} /> + {form.currency ?? ''} +
+ {/if} +
{/each}
- + {:else if tab === 'notas'}
diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 8d91390..b267a95 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -42,17 +42,18 @@ export function getNavMain(): NavMainItem[] { title: 'CRM', url: '/dashboard/crm', icon: Briefcase, + // Orden por flujo comercial: captación → embudo → solicitud → cotización → apoyo items: [ { title: 'Panel', url: '/dashboard/crm' }, { title: 'Clientes / Prospectos', url: '/dashboard/crm/cuentas' }, - { title: 'Proveedores', url: '/dashboard/crm/proveedores' }, { title: 'Contactos', url: '/dashboard/crm/contactos' }, + { title: 'Prospectos (embudo)', url: '/dashboard/crm/prospectos' }, + { title: 'Oportunidades', url: '/dashboard/crm/oportunidades' }, { title: 'Solicitudes', url: '/dashboard/crm/solicitudes' }, { title: 'Cotizaciones', url: '/dashboard/crm/cotizaciones' }, { title: 'Tarifarios', url: '/dashboard/crm/tarifarios' }, { title: 'Cotizador', url: '/dashboard/crm/cotizador' }, - { title: 'Prospectos (embudo)', url: '/dashboard/crm/prospectos' }, - { title: 'Oportunidades', url: '/dashboard/crm/oportunidades' }, + { title: 'Proveedores', url: '/dashboard/crm/proveedores' }, { title: 'Actividades', url: '/dashboard/crm/actividades' }, { title: 'Catálogos', url: '/dashboard/crm/catalogos' }, ], diff --git a/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte b/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte index 06a2211..c8735db 100644 --- a/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte +++ b/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte @@ -276,6 +276,9 @@ ondragstart={(e) => onDragStart(e, opp.id)} >

{opp.name}

+ {#if opp.reference} +

{opp.reference}

+ {/if} {#if accountName(opp.account_id)}

{accountName(opp.account_id)}

{/if} diff --git a/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte b/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte index 0e26e7e..49befff 100644 --- a/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte @@ -54,7 +54,7 @@ contactsAPI.list(cid), rateRequestsAPI.list(cid, id) ]); - form = { ...sr, additional_services: sr.additional_services ?? [] }; + form = { ...sr, additional_services: sr.additional_services ?? [], additional_service_costs: sr.additional_service_costs ?? {} }; } catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo cargar la solicitud'); } finally { @@ -67,7 +67,7 @@ saving = true; try { sr = await serviceRequestsAPI.update(sr.id, form, companyId); - form = { ...sr, additional_services: sr.additional_services ?? [] }; + form = { ...sr, additional_services: sr.additional_services ?? [], additional_service_costs: sr.additional_service_costs ?? {} }; toast.success('Cambios guardados'); } catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo guardar'); @@ -82,7 +82,7 @@ busy = true; try { sr = await serviceRequestsAPI.registerContact(sr.id, companyId, notes); - form = { ...sr, additional_services: sr.additional_services ?? [] }; + form = { ...sr, additional_services: sr.additional_services ?? [], additional_service_costs: sr.additional_service_costs ?? {} }; toast.success('Contacto registrado'); } catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo registrar el contacto'); @@ -96,7 +96,7 @@ busy = true; try { sr = await serviceRequestsAPI.requote(sr.id, companyId); - form = { ...sr, additional_services: sr.additional_services ?? [] }; + form = { ...sr, additional_services: sr.additional_services ?? [], additional_service_costs: sr.additional_service_costs ?? {} }; toast.success('Solicitud reabierta para re-cotizar'); } catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo reabrir'); diff --git a/frontend/src/routes/dashboard/crm/solicitudes/nuevo/+page.svelte b/frontend/src/routes/dashboard/crm/solicitudes/nuevo/+page.svelte index 1ddd24f..f5187ea 100644 --- a/frontend/src/routes/dashboard/crm/solicitudes/nuevo/+page.svelte +++ b/frontend/src/routes/dashboard/crm/solicitudes/nuevo/+page.svelte @@ -9,7 +9,7 @@ import ServiceRequestFields from '$lib/components/crm/ServiceRequestFields.svelte'; import { toast } from 'svelte-sonner'; - let form = $state({ operation_type: 'exportacion', status: 'nueva', additional_services: [] }); + let form = $state({ operation_type: 'exportacion', status: 'nueva', additional_services: [], additional_service_costs: {} }); let accounts = $state([]); let suppliers = $state([]); let contacts = $state([]);