feat(crm): tarifario — editor de cargos adicionales + alta manual de rutas

- Backend: CRUD de cargos (rate_charges) por tarifario.
- Frontend: detalle del tarifario con alta manual de rutas (con editor de quiebres
  para aéreo/LCL) y sección de cargos adicionales (agregar/eliminar).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ernesto Herrera
2026-07-27 09:46:25 -06:00
parent fa542ddf18
commit ef7e69ed57
5 changed files with 306 additions and 8 deletions

View File

@@ -21,6 +21,32 @@ class RateChargeDTO(BaseModel):
condition: str | None = None
class RateChargeCreate(BaseModel):
concept: str = Field(..., max_length=60)
charge_type: str = Field("fijo", max_length=20)
value: Decimal | None = None
condition: str | None = None
rate_lane_id: int | None = None
class RateChargeUpdate(BaseModel):
concept: str | None = Field(None, max_length=60)
charge_type: str | None = Field(None, max_length=20)
value: Decimal | None = None
condition: str | None = None
class RateChargeResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
rate_sheet_id: int | None
rate_lane_id: int | None
concept: str
charge_type: str
value: Decimal | None
condition: str | None
# ---------- Rutas ----------
class RateLaneBase(BaseModel):
origin: str | None = Field(None, max_length=20)

View File

@@ -14,6 +14,9 @@ from .dto import (
CostResult,
ImportPreview,
RateBreakDTO,
RateChargeCreate,
RateChargeResponse,
RateChargeUpdate,
RateLaneCreate,
RateLaneResponse,
RateSheetCreate,
@@ -187,6 +190,56 @@ def delete_lane(
service.delete_lane(db, tenant_id, sheet_id, lane_id)
# ---------------- Cargos adicionales ----------------
@router.get("/{sheet_id}/charges", response_model=list[RateChargeResponse])
def list_charges(
sheet_id: int,
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id, _ = _ctx(current_user)
service.get_sheet(db, tenant_id, company_id, sheet_id)
return service.list_charges(db, tenant_id, sheet_id)
@router.post("/{sheet_id}/charges", response_model=RateChargeResponse, status_code=status.HTTP_201_CREATED)
def create_charge(
sheet_id: int,
data: RateChargeCreate,
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id, _ = _ctx(current_user)
return service.create_charge(db, tenant_id, company_id, sheet_id, data)
@router.patch("/{sheet_id}/charges/{charge_id}", response_model=RateChargeResponse)
def update_charge(
sheet_id: int,
charge_id: int,
data: RateChargeUpdate,
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id, _ = _ctx(current_user)
return service.update_charge(db, tenant_id, sheet_id, charge_id, data)
@router.delete("/{sheet_id}/charges/{charge_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_charge(
sheet_id: int,
charge_id: int,
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id, _ = _ctx(current_user)
service.delete_charge(db, tenant_id, sheet_id, charge_id)
# ---------------- Motor de costeo ----------------
cost_router = APIRouter(tags=["Tarifario"])

View File

@@ -149,6 +149,60 @@ def delete_lane(db: Session, tenant_id: int, sheet_id: int, lane_id: int) -> Non
db.commit()
# ============================================================ Cargos adicionales
def list_charges(db: Session, tenant_id: int, sheet_id: int) -> list[RateCharge]:
return (
db.query(RateCharge)
.filter(RateCharge.rate_sheet_id == sheet_id, RateCharge.tenant_id == tenant_id,
RateCharge.deleted_at.is_(None))
.order_by(RateCharge.concept)
.all()
)
def create_charge(db: Session, tenant_id: int, company_id: int, sheet_id: int, data) -> RateCharge:
get_sheet(db, tenant_id, company_id, sheet_id)
ch = RateCharge(
tenant_id=tenant_id, company_id=company_id, rate_sheet_id=sheet_id,
rate_lane_id=data.rate_lane_id, concept=data.concept, charge_type=data.charge_type,
value=data.value, condition=data.condition,
)
db.add(ch)
db.commit()
db.refresh(ch)
return ch
def update_charge(db: Session, tenant_id: int, sheet_id: int, charge_id: int, data) -> RateCharge:
ch = (
db.query(RateCharge)
.filter(RateCharge.id == charge_id, RateCharge.rate_sheet_id == sheet_id,
RateCharge.tenant_id == tenant_id)
.first()
)
if not ch:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cargo no encontrado")
for field, value in data.model_dump(exclude_unset=True).items():
setattr(ch, field, value)
db.commit()
db.refresh(ch)
return ch
def delete_charge(db: Session, tenant_id: int, sheet_id: int, charge_id: int) -> None:
from sqlalchemy import func
ch = (
db.query(RateCharge)
.filter(RateCharge.id == charge_id, RateCharge.rate_sheet_id == sheet_id,
RateCharge.tenant_id == tenant_id)
.first()
)
if not ch:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cargo no encontrado")
ch.deleted_at = func.now()
db.commit()
# ============================================================ Importación Excel
# Plantillas por modo: encabezados esperados (orden libre, se detectan por nombre).
TEMPLATES: dict[str, list[str]] = {

View File

@@ -24,6 +24,12 @@ export type RateSheetInput = Partial<Omit<RateSheet, 'id' | 'created_at' | 'upda
mode: RateMode; name: string;
};
export interface RateCharge {
id: number; rate_sheet_id: number | null; rate_lane_id: number | null;
concept: string; charge_type: string; value: number | null; condition: string | null;
}
export interface RateChargeInput { concept: string; charge_type: string; value?: number | null; condition?: string | null; rate_lane_id?: number | null; }
export interface ImportPreviewRow { row: number; data: Record<string, unknown>; ok: boolean; warnings: string[]; errors: string[]; }
export interface ImportPreview { mode: RateMode; total: number; valid: number; rows: ImportPreviewRow[]; columns: string[]; }
@@ -61,6 +67,9 @@ export const rateSheetsAPI = {
lanes: (id: number, companyId: number) => unwrap<RateLane[]>(api.get(`/v1/crm/rate-sheets/${id}/lanes?${qp(companyId)}`)),
addLane: (id: number, data: Partial<RateLane>, companyId: number) => unwrap<RateLane>(api.post(`/v1/crm/rate-sheets/${id}/lanes?${qp(companyId)}`, data)),
removeLane: (id: number, laneId: number, companyId: number) => unwrap(api.delete(`/v1/crm/rate-sheets/${id}/lanes/${laneId}?${qp(companyId)}`)),
charges: (id: number, companyId: number) => unwrap<RateCharge[]>(api.get(`/v1/crm/rate-sheets/${id}/charges?${qp(companyId)}`)),
addCharge: (id: number, data: RateChargeInput, companyId: number) => unwrap<RateCharge>(api.post(`/v1/crm/rate-sheets/${id}/charges?${qp(companyId)}`, data)),
removeCharge: (id: number, chargeId: number, companyId: number) => unwrap(api.delete(`/v1/crm/rate-sheets/${id}/charges/${chargeId}?${qp(companyId)}`)),
/** Descarga la plantilla Excel del modo. */
async downloadTemplate(mode: RateMode, companyId: number): Promise<void> {

View File

@@ -1,29 +1,48 @@
<script lang="ts">
import { onMount } from 'svelte';
import { ArrowLeft, FileSpreadsheet, Trash2, CheckCircle2 } from '@lucide/svelte';
import { ArrowLeft, FileSpreadsheet, Trash2, CheckCircle2, Plus } from '@lucide/svelte';
import { page } from '$app/state';
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 RateSheet, type RateLane } from '$lib/api/crm/rates';
import { rateSheetsAPI, type RateSheet, type RateLane, type RateCharge } from '$lib/api/crm/rates';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { formatDate } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
const sheetId = $derived(Number(page.params.id));
const companyId = $derived(companyStore.activeCompany?.id ?? null);
const inputCls = 'rounded-md border bg-transparent px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
let sheet = $state<RateSheet | null>(null);
let lanes = $state<RateLane[]>([]);
let charges = $state<RateCharge[]>([]);
let loading = $state(false);
onMount(() => void crmCatalogs.preload(['modo_tarifario', 'tipo_equipo']));
const isAir = $derived(sheet?.mode === 'aereo');
const isLcl = $derived(sheet?.mode === 'maritimo_lcl');
const isFcl = $derived(sheet?.mode === 'maritimo_fcl');
const usesBreaks = $derived(isAir || isLcl);
const rateUnit = $derived(sheet?.mode === 'aereo' ? 'per_kg' : sheet?.mode === 'maritimo_lcl' ? 'per_wm' : sheet?.mode === 'maritimo_fcl' ? 'per_container' : 'flat');
// alta de ruta
let showLane = $state(false);
let lf = $state({ origin: '', destination: '', region: '', equipment_type: '', min_charge: null as number | null, flat_rate: null as number | null, transit_days: null as number | null, notes: '' });
let brks = $state<{ from_qty: number | null; rate: number | null }[]>([{ from_qty: null, rate: null }]);
// alta de cargo
let cf = $state({ concept: '', charge_type: 'fijo', value: null as number | null, condition: '' });
let working = $state(false);
onMount(() => void crmCatalogs.preload(['modo_tarifario', 'tipo_equipo', 'concepto_cargo']));
$effect(() => { const cid = companyId, id = sheetId; if (cid && id) void load(cid, id); });
async function load(cid: number, id: number) {
loading = true;
try { sheet = await rateSheetsAPI.get(id, cid); lanes = await rateSheetsAPI.lanes(id, cid); }
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo cargar el tarifario'); }
try {
sheet = await rateSheetsAPI.get(id, cid);
[lanes, charges] = await Promise.all([rateSheetsAPI.lanes(id, cid), rateSheetsAPI.charges(id, cid)]);
} catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo cargar el tarifario'); }
finally { loading = false; }
}
async function setStatus(status: string) {
@@ -36,8 +55,60 @@
try { await rateSheetsAPI.removeLane(sheet.id, l.id, companyId); await load(companyId, sheet.id); }
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo eliminar'); }
}
function openLane() {
lf = { origin: sheet?.default_origin ?? '', destination: '', region: '', equipment_type: '', min_charge: null, flat_rate: null, transit_days: null, notes: '' };
brks = [{ from_qty: null, rate: null }];
showLane = true;
}
async function saveLane() {
if (!companyId || !sheet) return;
if (!lf.destination.trim()) { toast.error('Indica el destino'); return; }
const breaks = usesBreaks
? brks.filter((b) => b.rate != null).map((b) => ({ from_qty: b.from_qty ?? 0, rate: b.rate as number }))
: [];
working = true;
try {
await rateSheetsAPI.addLane(sheet.id, {
origin: lf.origin || null, destination: lf.destination, region: lf.region || null,
equipment_type: isFcl ? (lf.equipment_type || null) : null, rate_unit: rateUnit,
min_charge: usesBreaks ? lf.min_charge : null,
flat_rate: usesBreaks ? null : lf.flat_rate,
transit_days: lf.transit_days, notes: lf.notes || null, breaks
} as any, companyId);
toast.success('Ruta agregada');
showLane = false;
await load(companyId, sheet.id);
} catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo guardar la ruta'); }
finally { working = false; }
}
async function addCharge() {
if (!companyId || !sheet) return;
if (!cf.concept) { toast.error('Elige el concepto del cargo'); return; }
working = true;
try {
await rateSheetsAPI.addCharge(sheet.id, { concept: cf.concept, charge_type: cf.charge_type, value: cf.value, condition: cf.condition || null }, companyId);
toast.success('Cargo agregado');
cf = { concept: '', charge_type: 'fijo', value: null, condition: '' };
charges = await rateSheetsAPI.charges(sheet.id, companyId);
} catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo agregar el cargo'); }
finally { working = false; }
}
async function delCharge(c: RateCharge) {
if (!companyId || !sheet) return;
try { await rateSheetsAPI.removeCharge(sheet.id, c.id, companyId); charges = await rateSheetsAPI.charges(sheet.id, companyId); }
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo eliminar'); }
}
const eq = (c: string | null) => c ? crmCatalogs.label('tipo_equipo', c) : '—';
const conceptLabel = (c: string) => crmCatalogs.label('concepto_cargo', c);
const breaksTxt = (l: RateLane) => (l.breaks ?? []).map((b) => `${b.from_qty}: ${b.rate}`).join(' · ') || '—';
const CHARGE_TYPES = [
{ v: 'fijo', l: 'Fijo' }, { v: 'por_kg', l: 'Por kg' }, { v: 'por_guia', l: 'Por guía' },
{ v: 'por_contenedor', l: 'Por contenedor' }, { v: 'porcentaje', l: '% sobre tarifa' }
];
const chargeTypeLabel = (t: string) => CHARGE_TYPES.find((x) => x.v === t)?.l ?? t;
</script>
<div class="space-y-6">
@@ -64,12 +135,15 @@
</div>
<Card.Root>
<Card.Header><Card.Title class="text-base">Rutas ({lanes.length})</Card.Title>
<Card.Header class="flex flex-row items-center justify-between">
<div><Card.Title class="text-base">Rutas ({lanes.length})</Card.Title>
<Card.Description>El motor de cotización usa estas rutas cuando el tarifario está <b>activo</b> y vigente.</Card.Description>
</div>
<Button size="sm" variant="outline" onclick={openLane}><Plus class="mr-1 h-4 w-4" /> Agregar ruta</Button>
</Card.Header>
<Card.Content>
{#if lanes.length === 0}
<p class="text-sm text-muted-foreground">Sin rutas.</p>
<p class="text-sm text-muted-foreground">Sin rutas. Agrega una o importa por Excel.</p>
{:else}
<div class="overflow-x-auto">
<Table.Root>
@@ -93,5 +167,87 @@
{/if}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header><Card.Title class="text-base">Cargos adicionales ({charges.length})</Card.Title>
<Card.Description>Recargos que el motor suma a la tarifa base (combustible, DGR, THC, maniobras…).</Card.Description>
</Card.Header>
<Card.Content>
<div class="mb-4 flex flex-wrap items-end gap-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Concepto</span>
<select class={inputCls} bind:value={cf.concept}><option value=""></option>{#each crmCatalogs.options('concepto_cargo') as c (c.value)}<option value={c.value}>{c.label}</option>{/each}</select>
</label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo</span>
<select class={inputCls} bind:value={cf.charge_type}>{#each CHARGE_TYPES as t (t.v)}<option value={t.v}>{t.l}</option>{/each}</select>
</label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Valor</span><input type="number" step="0.01" class="{inputCls} w-28" bind:value={cf.value} placeholder="monto o %" /></label>
<label class="flex flex-1 flex-col gap-1 text-sm"><span class="font-medium">Condición</span><input class={inputCls} bind:value={cf.condition} placeholder="opcional (ej. solo DGR)" /></label>
<Button size="sm" onclick={addCharge} disabled={working}><Plus class="mr-1 h-4 w-4" /> Agregar</Button>
</div>
{#if charges.length === 0}
<p class="text-sm text-muted-foreground">Sin cargos adicionales.</p>
{:else}
<Table.Root>
<Table.Header><Table.Row><Table.Head>Concepto</Table.Head><Table.Head>Tipo</Table.Head><Table.Head>Valor</Table.Head><Table.Head>Condición</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
<Table.Body>
{#each charges as c (c.id)}
<Table.Row>
<Table.Cell class="font-medium">{conceptLabel(c.concept)}</Table.Cell>
<Table.Cell class="text-xs">{chargeTypeLabel(c.charge_type)}</Table.Cell>
<Table.Cell>{c.value ?? '—'}{c.charge_type === 'porcentaje' ? ' %' : ''}</Table.Cell>
<Table.Cell class="text-xs text-muted-foreground">{c.condition ?? '—'}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => delCharge(c)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>
{#if showLane && sheet}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" role="presentation" onclick={() => (showLane = false)}>
<div class="max-h-[92vh] w-full max-w-xl overflow-y-auto rounded-lg border bg-card p-6 shadow-lg" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<h3 class="mb-4 text-base font-semibold">Nueva ruta</h3>
<div class="grid gap-3 sm:grid-cols-3">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Origen</span><input class={inputCls} bind:value={lf.origin} placeholder={sheet.default_origin ?? 'NLU'} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Destino *</span><input class={inputCls} bind:value={lf.destination} placeholder="FRA / CNSHA" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Región</span><input class={inputCls} bind:value={lf.region} /></label>
{#if isFcl}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de equipo</span>
<select class={inputCls} bind:value={lf.equipment_type}><option value=""></option>{#each crmCatalogs.options('tipo_equipo') as t (t.value)}<option value={t.value}>{t.label}</option>{/each}</select>
</label>
{/if}
{#if usesBreaks}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cargo mínimo</span><input type="number" step="0.01" class={inputCls} bind:value={lf.min_charge} /></label>
{:else}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tarifa</span><input type="number" step="0.01" class={inputCls} bind:value={lf.flat_rate} /></label>
{/if}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tránsito (días)</span><input type="number" class={inputCls} bind:value={lf.transit_days} /></label>
</div>
{#if usesBreaks}
<div class="mt-4">
<div class="mb-1 flex items-center justify-between"><span class="text-sm font-medium">Quiebres ({isAir ? 'kg' : 'W/M'} → tarifa)</span>
<Button size="sm" variant="ghost" onclick={() => (brks = [...brks, { from_qty: null, rate: null }])}><Plus class="mr-1 h-4 w-4" /> Fila</Button>
</div>
{#each brks as b, i (i)}
<div class="mb-1 flex items-center gap-2">
<input type="number" step="0.001" class="{inputCls} w-32" bind:value={b.from_qty} placeholder="desde (100)" />
<input type="number" step="0.0001" class="{inputCls} w-32" bind:value={b.rate} placeholder="tarifa (1.00)" />
<button type="button" class="text-destructive" onclick={() => (brks = brks.filter((_, j) => j !== i))} aria-label="Quitar">×</button>
</div>
{/each}
</div>
{/if}
<label class="mt-3 flex flex-col gap-1 text-sm"><span class="font-medium">Notas</span><input class={inputCls} bind:value={lf.notes} /></label>
<div class="mt-5 flex justify-end gap-2 border-t pt-4">
<Button variant="outline" onclick={() => (showLane = false)}>Cancelar</Button>
<Button onclick={saveLane} disabled={working}>{working ? 'Guardando…' : 'Guardar ruta'}</Button>
</div>
</div>
</div>
{/if}