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