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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user