Enhance invoice and item processing with balance tracking features

- Added `company_id` to `ClientProviderAddress` and `ClientProviderPrograms` for better association.
- Updated `_process_with_discharge` to simplify invoice review by removing redundant parameters.
- Improved `pre_validators` to ensure address validation checks for existence before accessing country.
- Enhanced balance comparison logic in `compare_balances` by tracking consumed quantities.
- Modified `fill_available_balances` to reflect invoice status changes from 'UNPROCESSED' to 'PENDING'.
- Introduced `get_lines_with_balance` method in `ItemService` to fetch invoice lines with available balance.
- Added new API endpoint to retrieve import invoice lines with balance information.
- Updated frontend components to display available balances and improve user experience in invoice selection.
This commit is contained in:
2026-03-18 00:55:17 -05:00
parent 5265ec6849
commit 2a861e480c
17 changed files with 554 additions and 46 deletions

View File

@@ -299,5 +299,52 @@ export const itemsApi = {
company_id: companyId.toString()
});
return api.delete(`/v1/a76/items/${itemId}/?${params.toString()}`);
},
/**
* Lista las líneas de una factura de importación con su saldo disponible.
* Solo las líneas con has_balance = true tienen mercancía disponible para descarga.
*
* @param invoiceId - ID de la factura de importación
* @param companyId - ID de la empresa
* @param asOfDate - Fecha corte opcional (ISO: "YYYY-MM-DD").
* Pasa la fecha de la factura de exportación para que
* los consumos futuros no se descuenten del saldo.
*/
listByInvoiceWithBalance: (
invoiceId: number,
companyId: number,
asOfDate?: string
) => {
const params = new URLSearchParams({ company_id: companyId.toString() });
if (asOfDate) params.append('as_of_date', asOfDate);
return api.get<ImportLineWithBalance[]>(
`/v1/a76/items/invoice/${invoiceId}/items-with-balance?${params.toString()}`
);
}
};
export interface ImportLineWithBalance {
id: number;
line_number: number;
// Invoice info
invoice_number?: string;
invoice_date?: string;
invoice_status?: string;
// Part / class
part_number?: string;
class_code?: string;
description_spanish?: string;
unit_of_measure_code?: string;
// Quantities
quantity?: number;
quantity_returned_temp?: number;
quantity_returned?: number;
// Balance
available_balance: number;
has_balance: boolean;
// FA / subitem
is_subitem?: boolean;
contains_subitems?: boolean;
subitem_count?: number;
}

View File

@@ -7,17 +7,19 @@
import { companyStore } from '$lib/stores/company.svelte';
import { toast } from 'svelte-sonner';
interface Props {
open: boolean;
regimen?: string;
operationType?: 'imp' | 'exp';
onSelect: (invoice: Invoice) => void;
}
let {
open = $bindable(false),
regimen = 'Temporal',
operationType = 'imp' as 'imp' | 'exp',
onSelect
}: {
open: boolean;
regimen?: string;
operationType?: 'imp' | 'exp';
onSelect: (invoice: Invoice) => void;
} = $props();
}: Props = $props();
let invoices = $state<Invoice[]>([]);
let loading = $state(false);

View File

@@ -11,7 +11,7 @@
import { Loader2, Package, Save, X, FileText, Folder } from 'lucide-svelte';
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
import { invoicesApi } from '$lib/api/dashboard/a76/invoices';
import { itemsApi, type Item } from '$lib/api/dashboard/a76/items';
import { itemsApi, type Item, type ImportLineWithBalance } from '$lib/api/dashboard/a76/items';
import { companyStore } from '$lib/stores/company.svelte';
// Child components
@@ -68,6 +68,9 @@
if (editingItem.fa_data.omit_annex31 === undefined) {
editingItem.fa_data.omit_annex31 = false;
}
if (editingItem.fa_data.discharge === undefined) {
editingItem.fa_data.discharge = false;
}
}
});
@@ -78,7 +81,7 @@
let showImportLinePicker = $state(false);
let selectedImportInvoiceId = $state<number | null>(null);
let selectedExportInvoiceId = $state<number | null>(null);
let importInvoiceLines = $state<Item[]>([]);
let importInvoiceLines = $state<ImportLineWithBalance[]>([]);
let exportInvoiceLines = $state<Item[]>([]);
let loadingImportLines = $state(false);
let loadingExportLines = $state(false);
@@ -88,8 +91,13 @@
if (!companyId) return;
loadingImportLines = true;
try {
const res = await itemsApi.listByInvoice(invoiceId, companyId);
importInvoiceLines = res.data?.items ?? [];
// Pass the export invoice date so consumption movements after that
// date are not subtracted from the available balance.
const asOfDate = invoice?.invoice_date
? invoice.invoice_date.split('T')[0]
: undefined;
const res = await itemsApi.listByInvoiceWithBalance(invoiceId, companyId, asOfDate);
importInvoiceLines = res.data ?? [];
} catch {
importInvoiceLines = [];
} finally {
@@ -334,7 +342,7 @@
value={editingItem.fa_data?.search_type || ''}
onValueChange={(v) => {
editingItem.fa_data = editingItem.fa_data || {};
editingItem.fa_data.search_type = v ?? undefined;
editingItem.fa_data.search_type = v ?? 'Factura';
}}
>
<Select.Trigger id="fa_rep_search_type" class="h-8 text-sm">
@@ -345,6 +353,7 @@
<Select.Content>
<Select.Item value="Factura">Factura</Select.Item>
<Select.Item value="NumParte">NumParte</Select.Item>
<Select.Item value="Clase">Clase</Select.Item>
</Select.Content>
</Select.Root>
</div>
@@ -630,25 +639,111 @@
<!-- Diálogo para elegir línea (Impo) -->
<Dialog.Root bind:open={showImportLinePicker}>
<Dialog.Content class="max-w-sm">
<Dialog.Content class="max-w-4xl">
<Dialog.Header>
<Dialog.Title class="text-sm">Seleccionar línea</Dialog.Title>
<Dialog.Title class="text-sm">Seleccionar línea de importación</Dialog.Title>
<p class="text-xs text-muted-foreground mt-0.5">
Solo se muestran líneas con saldo disponible
</p>
</Dialog.Header>
<div class="max-h-[280px] overflow-y-auto py-2">
{#each importInvoiceLines as lineItem}
{@const num = lineItem.line_number ?? lineItem.id}
<button
type="button"
class="w-full px-3 py-2 text-left text-sm hover:bg-muted rounded-md"
onclick={() => {
editingItem.fa_data = editingItem.fa_data || {};
editingItem.fa_data.search_line = typeof num === 'number' ? num : undefined;
showImportLinePicker = false;
}}
>
Línea {num}
</button>
{/each}
<!-- overflow-x on a wrapper that does NOT also do overflow-y.
The inner div handles vertical scroll so sticky columns work
independently from the horizontal scrollbar. -->
<div class="overflow-x-auto">
<div class="overflow-y-auto max-h-[500px]">
{#if importInvoiceLines.every(l => !l.has_balance)}
<p class="px-3 py-8 text-xs text-muted-foreground text-center">
No hay líneas con saldo disponible en esta factura.
</p>
{:else}
<table class="text-xs border-collapse" style="min-width: max-content; width: 100%;">
<thead class="sticky top-0 z-20">
<tr class="border-b border-border bg-muted">
<!-- sticky cols 1-3: left offsets match td widths below -->
<th class="sticky left-0 z-20 bg-muted px-2 py-2 text-left font-semibold text-muted-foreground whitespace-nowrap w-[48px]">Línea</th>
<th class="sticky left-[48px] z-20 bg-muted px-2 py-2 text-left font-semibold text-muted-foreground whitespace-nowrap w-[120px]">Factura</th>
<th class="sticky left-[168px] z-20 bg-muted px-2 py-2 text-left font-semibold text-muted-foreground whitespace-nowrap w-[88px]">Fecha</th>
<th class="px-2 py-2 text-left font-semibold text-muted-foreground whitespace-nowrap">Num. Parte</th>
<th class="px-2 py-2 text-left font-semibold text-muted-foreground whitespace-nowrap">Clase</th>
<th class="px-2 py-2 text-left font-semibold text-muted-foreground whitespace-nowrap w-[180px]">Descripción</th>
<th class="px-2 py-2 text-right font-semibold text-muted-foreground whitespace-nowrap">Cant. Imp.</th>
<th class="px-2 py-2 text-right font-semibold text-muted-foreground whitespace-nowrap">Ret. Temp.</th>
<th class="px-2 py-2 text-right font-semibold text-muted-foreground whitespace-nowrap">Ret. Def.</th>
<th class="px-2 py-2 text-right font-semibold text-muted-foreground whitespace-nowrap">Saldo Disp.</th>
<th class="px-2 py-2 text-center font-semibold text-muted-foreground whitespace-nowrap">Estatus</th>
<th class="px-2 py-2 text-center font-semibold text-muted-foreground whitespace-nowrap">Sub.</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
{#each importInvoiceLines as lineItem}
{#if lineItem.has_balance}
<tr
class="hover:bg-muted/50 cursor-pointer transition-colors group"
onclick={() => {
editingItem.fa_data = editingItem.fa_data || {};
editingItem.fa_data.search_line = lineItem.line_number;
showImportLinePicker = false;
}}
>
<td class="sticky left-0 z-10 bg-background group-hover:bg-muted/50 px-2 py-1.5 font-semibold text-primary whitespace-nowrap w-[48px]">{lineItem.line_number}</td>
<td class="sticky left-[48px] z-10 bg-background group-hover:bg-muted/50 px-2 py-1.5 font-mono whitespace-nowrap w-[120px]">{lineItem.invoice_number ?? '-'}</td>
<td class="sticky left-[168px] z-10 bg-background group-hover:bg-muted/50 px-2 py-1.5 text-muted-foreground whitespace-nowrap w-[88px]">
{lineItem.invoice_date ? lineItem.invoice_date.slice(0, 10) : '-'}
</td>
<td class="px-2 py-1.5 font-mono whitespace-nowrap">{lineItem.part_number ?? '-'}</td>
<td class="px-2 py-1.5 whitespace-nowrap">{lineItem.class_code ?? '-'}</td>
<td class="px-2 py-1.5 w-[180px] max-w-[180px] truncate text-muted-foreground" title={lineItem.description_spanish ?? ''}>
{lineItem.description_spanish ?? '-'}
</td>
<td class="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
{lineItem.quantity != null ? lineItem.quantity.toLocaleString('es-MX', { maximumFractionDigits: 4 }) : '-'}
<span class="text-muted-foreground">{lineItem.unit_of_measure_code ?? ''}</span>
</td>
<td class="px-2 py-1.5 text-right tabular-nums whitespace-nowrap text-amber-600 dark:text-amber-400">
{lineItem.quantity_returned_temp != null ? lineItem.quantity_returned_temp.toLocaleString('es-MX', { maximumFractionDigits: 4 }) : '-'}
</td>
<td class="px-2 py-1.5 text-right tabular-nums whitespace-nowrap text-blue-600 dark:text-blue-400">
{lineItem.quantity_returned != null ? lineItem.quantity_returned.toLocaleString('es-MX', { maximumFractionDigits: 4 }) : '-'}
</td>
<td class="px-2 py-1.5 text-right tabular-nums font-semibold whitespace-nowrap text-emerald-600 dark:text-emerald-400">
{lineItem.available_balance.toLocaleString('es-MX', { maximumFractionDigits: 4 })}
<span class="font-normal text-muted-foreground">{lineItem.unit_of_measure_code ?? ''}</span>
</td>
<td class="px-2 py-1.5 text-center whitespace-nowrap">
{#if lineItem.invoice_status === 'processed'}
<span class="inline-flex items-center rounded-full bg-emerald-100 dark:bg-emerald-900/40 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700 dark:text-emerald-300">
Procesada
</span>
{:else if lineItem.invoice_status === 'reversed'}
<span class="inline-flex items-center rounded-full bg-red-100 dark:bg-red-900/40 px-1.5 py-0.5 text-[10px] font-medium text-red-700 dark:text-red-300">
Revertida
</span>
{:else}
<span class="inline-flex items-center rounded-full bg-zinc-100 dark:bg-zinc-800 px-1.5 py-0.5 text-[10px] font-medium text-zinc-600 dark:text-zinc-400">
{lineItem.invoice_status ?? 'Pendiente'}
</span>
{/if}
</td>
<td class="px-2 py-1.5 text-center whitespace-nowrap">
{#if lineItem.is_subitem}
<span class="inline-flex items-center rounded-full bg-purple-100 dark:bg-purple-900/40 px-1.5 py-0.5 text-[10px] font-medium text-purple-700 dark:text-purple-300">
Sub
</span>
{:else if lineItem.contains_subitems}
<span class="inline-flex items-center rounded-full bg-indigo-100 dark:bg-indigo-900/40 px-1.5 py-0.5 text-[10px] font-medium text-indigo-700 dark:text-indigo-300" title="{lineItem.subitem_count} subpartida(s)">
{lineItem.subitem_count ?? 0} sub
</span>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
{/if}
</div>
</div>
</Dialog.Content>
</Dialog.Root>

View File

@@ -5,11 +5,21 @@
import { selectSearchContextKey, type SelectSearchContext } from './select-search-context';
import { type WithoutChild } from '$lib/utils.js';
// SelectPrimitive.RootProps is a discriminated union (single | multiple).
// Spreading a discriminated union collapses conflicting members (e.g. onValueChange) to `never`.
// We widen the props type so callers can pass either variant without hitting `never`.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type SelectRootProps = Omit<WithoutChild<SelectPrimitive.RootProps>, 'value' | 'onValueChange'> & {
type?: 'single' | 'multiple';
value?: string | string[];
onValueChange?: (value: any) => void;
};
let {
children,
value = $bindable(),
...restProps
}: WithoutChild<SelectPrimitive.RootProps> = $props();
}: SelectRootProps = $props();
let open = $state(false);
const query = writable('');