feat(crm): Cotizaciones/Cotizador vinculados (Fase B)

- Lista de cotizaciones muestra la Solicitud referenciada (QuoteResponse enriquecido
  con service_request_reference; columna con enlace a la solicitud).
- Cotizador vinculable a una solicitud (?service_request_id=) → prellena modo
  (mapper transport+load→RateMode), ruta, peso/dimensiones, etc.
- Cotizador vinculable a una cotización (?quote_id=) → botón "Agregar" por opción
  que crea el concepto de flete + cargos en la cotización y regresa a ella.
- Botón "Cotizador" en el detalle de la cotización (entrada vinculada).

Suite de cotizaciones en verde. svelte-check sin errores nuevos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ernesto Herrera
2026-08-07 07:43:53 -06:00
parent 8431132b10
commit b47dc542f2
6 changed files with 91 additions and 4 deletions

View File

@@ -85,6 +85,7 @@ class QuoteResponse(QuoteBase):
model_config = ConfigDict(from_attributes=True)
id: int
service_request_reference: str | None = None # folio de la solicitud referenciada
status: str
total_cost: Decimal
total_sale: Decimal

View File

@@ -73,7 +73,18 @@ def get_quotes(
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()
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:

View File

@@ -106,6 +106,7 @@ export interface Quote {
id: number;
reference: string | null;
service_request_id: number | null;
service_request_reference: string | null;
account_id: number | null;
currency: string;
load_type: string | null;

View File

@@ -93,6 +93,7 @@
<Table.Header>
<Table.Row>
<Table.Head>Folio</Table.Head>
<Table.Head>Solicitud</Table.Head>
<Table.Head>Estatus</Table.Head>
<Table.Head class="text-right">Total venta</Table.Head>
<Table.Head class="text-right">Margen</Table.Head>
@@ -103,6 +104,7 @@
{#each filtered as q (q.id)}
<Table.Row>
<Table.Cell class="font-medium"><a class="hover:underline" href={`/dashboard/crm/cotizaciones/${q.id}`}>{q.reference ?? `#${q.id}`}</a></Table.Cell>
<Table.Cell>{#if q.service_request_id}<a class="text-sm hover:underline" href={`/dashboard/crm/solicitudes/${q.service_request_id}`}>{q.service_request_reference ?? `#${q.service_request_id}`}</a>{:else}<span class="text-sm text-muted-foreground">—</span>{/if}</Table.Cell>
<Table.Cell><span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusClass[q.status] ?? ''}">{labelOf(QUOTE_STATUS, q.status)}</span></Table.Cell>
<Table.Cell class="text-right">{formatMoney(q.total_sale, q.currency)}</Table.Cell>
<Table.Cell class="text-right">{formatMoney(q.margin, q.currency)}</Table.Cell>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { ArrowLeft, Receipt, Plus, Trash2, Send, Check, X, Ship, FileText, Mail } from '@lucide/svelte';
import { ArrowLeft, Receipt, Plus, Trash2, Send, Check, X, Ship, FileText, Mail, Calculator } from '@lucide/svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
@@ -225,6 +225,7 @@
</p>
</div>
<div class="flex flex-wrap gap-2">
<Button size="sm" variant="outline" href={`/dashboard/crm/cotizador?quote_id=${quote.id}${quote.service_request_id ? `&service_request_id=${quote.service_request_id}` : ''}`}><Calculator class="mr-1 h-4 w-4" /> Cotizador</Button>
<Button size="sm" variant="outline" onclick={openPdf} disabled={busy}><FileText class="mr-1 h-4 w-4" /> Ver PDF</Button>
<Button size="sm" variant="outline" onclick={openEmail} disabled={busy}><Mail class="mr-1 h-4 w-4" /> Enviar por correo</Button>
{#if quote.status === 'borrador'}

View File

@@ -1,16 +1,30 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Calculator } from '@lucide/svelte';
import { Calculator, Plus } from '@lucide/svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { rateSheetsAPI, type CostOption, type RateMode } from '$lib/api/crm/rates';
import { serviceRequestsAPI, quoteItemsAPI } from '$lib/api/crm';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { formatMoney } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
const companyId = $derived(companyStore.activeCompany?.id ?? null);
// Vinculación: ?service_request_id= (prellenar) y ?quote_id= (enviar resultado a concepto)
const srId = $derived(Number(page.url.searchParams.get('service_request_id')) || null);
const quoteId = $derived(Number(page.url.searchParams.get('quote_id')) || null);
// transport_mode + load_type de la solicitud → modo del tarifario
function transportModeToRateMode(transport: string | null, load: string | null): RateMode {
if (transport === 'aereo') return 'aereo';
if (transport === 'terrestre') return 'terrestre';
if (transport === 'maritimo') return load === 'LCL' ? 'maritimo_lcl' : 'maritimo_fcl';
return 'aereo';
}
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
let f = $state({
@@ -48,6 +62,62 @@
onMount(() => void crmCatalogs.preload(['modo_tarifario', 'tipo_equipo']));
// Prellenar desde la solicitud vinculada (si viene ?service_request_id=)
$effect(() => {
const cid = companyId;
const id = srId;
if (!cid || !id) return;
void (async () => {
try {
const sr = await serviceRequestsAPI.get(id, cid);
f = {
...f,
mode: transportModeToRateMode(sr.transport_mode, sr.load_type),
origin: sr.origin_port || sr.origin || '',
destination: sr.destination_port || sr.destination || '',
on_date: sr.estimated_shipment_date || sr.required_date || '',
gross_weight_kg: sr.weight ?? null,
volume_m3: sr.volume ?? null,
length_cm: sr.length_cm ?? null,
width_cm: sr.width_cm ?? null,
height_cm: sr.height_cm ?? null,
equipment_type: sr.container_equipment || '',
quantity: sr.container_count || sr.pallets_count || sr.pieces_count || 1,
dangerous: !!sr.hazardous_imo
};
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo cargar la solicitud');
}
})();
});
// Enviar una opción del cotizador como concepto(s) de la cotización vinculada
async function addToQuote(o: CostOption) {
if (!companyId || !quoteId) return;
working = true;
try {
// Línea base (flete) + una línea por cada cargo adicional
await quoteItemsAPI.create({
quote_id: quoteId, concept: 'flete_internacional',
description: `${o.rate_sheet_name}${o.detail ? ' — ' + o.detail : ''}`,
supplier_id: o.supplier_id ?? undefined, quantity: 1,
unit_cost: o.base_cost, unit_sale: o.base_cost, currency: o.currency ?? undefined
}, companyId);
for (const c of o.charges) {
await quoteItemsAPI.create({
quote_id: quoteId, concept: 'otros', description: c.concept,
quantity: 1, unit_cost: c.amount, unit_sale: c.amount, currency: o.currency ?? undefined
}, companyId);
}
toast.success('Concepto(s) agregado(s) a la cotización');
await goto(`/dashboard/crm/cotizaciones/${quoteId}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo agregar a la cotización');
} finally {
working = false;
}
}
async function calc() {
if (!companyId) return;
if (!f.destination.trim()) { toast.error('Indica el destino'); return; }
@@ -139,7 +209,7 @@
{:else}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header><Table.Row><Table.Head>Tarifario</Table.Head><Table.Head>Base</Table.Head><Table.Head>Cargos</Table.Head><Table.Head>Total</Table.Head><Table.Head>Detalle</Table.Head><Table.Head>Tránsito</Table.Head></Table.Row></Table.Header>
<Table.Header><Table.Row><Table.Head>Tarifario</Table.Head><Table.Head>Base</Table.Head><Table.Head>Cargos</Table.Head><Table.Head>Total</Table.Head><Table.Head>Detalle</Table.Head><Table.Head>Tránsito</Table.Head>{#if quoteId}<Table.Head></Table.Head>{/if}</Table.Row></Table.Header>
<Table.Body>
{#each options as o, i (o.rate_sheet_id + '-' + i)}
<Table.Row class={i === 0 ? 'bg-emerald-50/60 dark:bg-emerald-950/20' : ''}>
@@ -149,6 +219,7 @@
<Table.Cell class="font-semibold">{money(o.total_cost, o.currency)}</Table.Cell>
<Table.Cell class="text-xs text-muted-foreground">{o.detail ?? '—'}</Table.Cell>
<Table.Cell>{o.transit_days ?? '—'}</Table.Cell>
{#if quoteId}<Table.Cell class="text-right"><Button size="sm" variant="outline" onclick={() => addToQuote(o)} disabled={working}><Plus class="mr-1 h-4 w-4" /> Agregar</Button></Table.Cell>{/if}
</Table.Row>
{/each}
</Table.Body>