feat: enhance invoice validation and error handling with detailed messages

This commit is contained in:
AlexeerCT
2026-01-10 23:42:05 -06:00
parent 25c51120f9
commit 887cbfa5ce
9 changed files with 255 additions and 115 deletions

View File

@@ -10,6 +10,13 @@ const API_BASE_URL = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, '');
export interface ApiResponse<T = any> {
data?: T;
error?: string;
validationErrors?: Array<{
field: string;
message: string;
code?: string;
solution?: string[];
value?: any;
}>;
status: number;
}
@@ -207,30 +214,41 @@ async function fetchApi<T = any>(
if (!response.ok) {
// Manejo especial para errores 422 (validation error)
if (response.status === 422 && data.detail) {
let errorMessage = 'Error de validación: ';
// FastAPI devuelve errores de validación en data.detail como array
if (Array.isArray(data.detail)) {
const errors = data.detail.map((err: any) => {
const field = err.loc ? err.loc.join('.') : 'campo desconocido';
return `${field}: ${err.msg}`;
}).join(', ');
errorMessage += errors;
} else if (typeof data.detail === 'string') {
errorMessage = data.detail;
} else {
errorMessage += JSON.stringify(data.detail);
if (response.status === 422) {
// Errores de validación personalizados (con array errors)
if (data.errors && Array.isArray(data.errors)) {
return {
error: data.message || 'Error de validación',
validationErrors: data.errors,
status: response.status
};
}
// Errores de validación de FastAPI (con detail)
else if (data.detail) {
let errorMessage = 'Error de validación: ';
// FastAPI devuelve errores de validación en data.detail como array
if (Array.isArray(data.detail)) {
const errors = data.detail.map((err: any) => {
const field = err.loc ? err.loc.join('.') : 'campo desconocido';
return `${field}: ${err.msg}`;
}).join(', ');
errorMessage += errors;
} else if (typeof data.detail === 'string') {
errorMessage = data.detail;
} else {
errorMessage += JSON.stringify(data.detail);
}
return {
error: errorMessage,
status: response.status
};
}
return {
error: errorMessage,
status: response.status
};
}
return {
error: data.detail || 'Error en la petición',
error: data.message || data.detail || 'Error en la petición',
status: response.status
};
}

View File

@@ -276,7 +276,7 @@ export interface CreateInvoiceData {
enajenation_goods?: boolean | null;
compliance_mx?: Omit<InvoiceComplianceMx, 'invoice_id'> | null;
financials?: Omit<InvoiceFinancials, 'id' | 'invoice_id'> | null;
logistics?: Omit<InvoiceLogistics, 'id' | 'invoice_id'>[] | null;
logistics?: Omit<InvoiceLogistics, 'id' | 'invoice_id'> | null;
details?: Omit<InvoiceSalesDetails, 'id' | 'invoice_id'>[] | null;
collections?: Omit<InvoiceCollections, 'id' | 'invoice_id'>[] | null;
}

View File

@@ -11,20 +11,16 @@
let {
invoice,
formData = $bindable(),
invoiceTypes = [],
customsBrokers = [],
clients = [],
providers = [],
currencyTypes = [],
transportTypes = [],
transporters = [],
vehicles = [],
drivers = [],
trailers = [],
customsSections = [],
codePedimentoRegimens = [],
defaultOperationType = undefined,
defaultInvoiceType = undefined,
operationType = undefined,
exchangeRate = undefined
}: {
@@ -76,7 +72,7 @@
weight_type: 'kgs',
iva_factor: invoice.financials?.iva_factor || null,
carrier_id: invoice.logistics?.[0]?.carrier_id || null,
transport_id: '',
transport_id: invoice.logistics?.[0]?.transport_id || '',
driver_name: invoice.logistics?.[0]?.driver_name || '',
transport_type: invoice.logistics?.[0]?.transport_type || '',
transport_num: invoice.logistics?.[0]?.vehicle_num || '',
@@ -133,6 +129,21 @@
{ value: 'lbs', label: 'Libras (lb)' }
];
// Opciones de tipo de transporte
const transportTypeOptions = [
{ value: 'none', label: 'Ninguno' },
{ value: 'transport', label: 'Transporte' },
{ value: 'box', label: 'Caja' },
{ value: 'licence_plates', label: 'Placas' },
{ value: 'truck', label: 'Camión' },
{ value: 'vessel', label: 'Buque' },
{ value: 'rail_barge', label: 'Ferrobarcaza' },
{ value: 'container', label: 'Contenedor' },
{ value: 'airplane', label: 'Avión' },
{ value: 'gondola', label: 'Góndola' },
{ value: 'flatbed', label: 'Plataforma' }
];
// Opciones de encabezados
const providerHeaderOptions = [
{ value: 'proveedor', label: 'Proveedor' },
@@ -540,18 +551,18 @@
</div>
<div class="col-span-2 space-y-1.5">
<Label for="transport_type" class="text-xs">Clave Transporte:</Label>
<Label for="transport_id" class="text-xs">Clave Transporte:</Label>
<Select.Root
type="single"
value={formData.transport_type || ''}
value={formData.transport_id || ''}
onValueChange={(v) => {
formData.transport_type = v ?? '';
formData.transport_id = v ?? '';
}}
>
<Select.Trigger id="transport_type" class="h-7 text-xs min-w-[120px] max-w-[250px]">
<Select.Trigger id="transport_id" class="h-7 text-xs min-w-[120px] max-w-[250px]">
<span class="truncate">
{#if formData.transport_type}
{vehicles.find(v => v.vehicle_key === formData.transport_type)?.vehicle_key || formData.transport_type}
{#if formData.transport_id}
{vehicles.find(v => v.vehicle_key === formData.transport_id)?.vehicle_key || formData.transport_id}
{:else if vehicles.length > 0}
Selecciona vehículo...
{:else}
@@ -605,28 +616,22 @@
<Label for="transport_type" class="text-xs">Tipo Transporte:</Label>
<Select.Root
type="single"
value={formData.transport_id || 'Ninguno'}
value={formData.transport_type || 'none'}
onValueChange={(v) => {
formData.transport_id = v ?? 'Ninguno';
formData.transport_type = v ?? 'none';
}}
>
<Select.Trigger id="transport_id" class="h-7 text-xs min-w-[120px] max-w-[250px]">
<Select.Trigger id="transport_type" class="h-7 text-xs min-w-[120px] max-w-[250px]">
<span class="truncate">
{formData.transport_id || 'Ninguno'}
{transportTypeOptions.find(t => t.value === formData.transport_type)?.label || 'Ninguno'}
</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="Ninguno">Ninguno</Select.Item>
<Select.Item value="Transporte">Transporte</Select.Item>
<Select.Item value="Caja">Caja</Select.Item>
<Select.Item value="Placas">Placas</Select.Item>
<Select.Item value="Camión">Camión</Select.Item>
<Select.Item value="Buque">Buque</Select.Item>
<Select.Item value="Ferrobarcaza">Ferrobarcaza</Select.Item>
<Select.Item value="Contenedor">Contenedor</Select.Item>
<Select.Item value="Avion">Avion</Select.Item>
<Select.Item value="Gondola">Gondola</Select.Item>
<Select.Item value="Plataforma">Plataforma</Select.Item>
{#each transportTypeOptions as option}
<Select.Item value={option.value}>
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>

View File

@@ -17,7 +17,20 @@ interface SaveInvoiceOptions {
formData: FormDataSet;
}
export async function saveInvoice(options: SaveInvoiceOptions): Promise<{ success: boolean; error?: string; newInvoiceId?: number }> {
interface SaveInvoiceResult {
success: boolean;
error?: string;
newInvoiceId?: number;
validationErrors?: Array<{
field: string;
message: string;
code?: string;
solution?: string[];
value?: any;
}>;
}
export async function saveInvoice(options: SaveInvoiceOptions): Promise<SaveInvoiceResult> {
const { invoiceId, isCreate, companyId, formData } = options;
const { InvoiceTopFieldsFormData, generalFormData, observationFormData, itemsFormData, othersFormData, continuationFormData } = formData;
@@ -61,9 +74,13 @@ export async function saveInvoice(options: SaveInvoiceOptions): Promise<{ succes
if (isCreate) {
// Crear nueva factura con todos sus sub-recursos
const response = await invoicesApi.create(companyId, payload as CreateInvoiceData);
console.log('Create response:', response);
if (response.error) {
const errorMsg = typeof response.error === 'string' ? response.error : 'Error al crear la factura';
throw new Error(errorMsg);
const error: any = new Error(errorMsg);
error.validationErrors = response.validationErrors;
console.log('Throwing error with validationErrors:', error.validationErrors);
throw error;
}
if (!response.data?.id) throw new Error('No se recibió el ID de la factura creada');
newInvoiceId = response.data.id;
@@ -73,13 +90,22 @@ export async function saveInvoice(options: SaveInvoiceOptions): Promise<{ succes
} else {
// Actualizar factura existente con todos sus sub-recursos
const response = await invoicesApi.update(invoiceId!, companyId, payload as UpdateInvoiceData);
if (response.error) throw new Error(response.error);
console.log('Update response:', response);
if (response.error) {
const error: any = new Error(response.error);
error.validationErrors = response.validationErrors;
console.log('Throwing error with validationErrors:', error.validationErrors);
throw error;
}
}
return { success: true, newInvoiceId: newInvoiceId ?? undefined };
} catch (e) {
console.log('Caught error in saveInvoice:', e);
console.log('Error has validationErrors?', (e as any)?.validationErrors);
const error = e instanceof Error ? e.message : 'Error al guardar los cambios';
return { success: false, error };
const validationErrors = (e as any)?.validationErrors;
return { success: false, error, validationErrors };
}
}
@@ -136,14 +162,7 @@ function buildInvoicePayload(formData: FormDataSet): CreateInvoiceData | UpdateI
}
// Logistics
const hasLogisticsFromGeneral = generalFormData?.carrier_id ||
generalFormData?.driver_name || generalFormData?.transport_type || generalFormData?.transport_num;
const hasLogisticsFromObservations = observationFormData?.incoterm;
if (hasLogisticsFromGeneral || hasLogisticsFromObservations) {
payload.logistics = buildLogisticsData(generalFormData, observationFormData);
}
payload.logistics = buildLogisticsData(generalFormData, observationFormData);
// Eliminar campos undefined para no enviarlos
Object.keys(payload).forEach(key => {
@@ -206,12 +225,12 @@ function buildFinancialsData(generalFormData: any, observationFormData: any, oth
}
function buildLogisticsData(generalFormData: any, observationFormData: any) {
// Crear un solo entry de logistics con los datos de generalFormData y observationFormData
return [{
// Retornar logistics como objeto único
return {
carrier_id: generalFormData?.carrier_id || null,
transport_type: generalFormData?.transport_type || null,
transport_type: generalFormData?.transport_type || 'none',
driver_name: generalFormData?.driver_name || null,
vehicle_num: generalFormData?.transport_num || null,
incoterm: observationFormData?.incoterm || null,
}];
};
}

View File

@@ -144,13 +144,23 @@
}
});
console.log('Save result:', result);
if (!result.success) {
throw new Error(result.error || 'Error al guardar la factura');
// Crear un error con validationErrors si existen
const error: any = new Error(result.error || 'Error al guardar la factura');
if (result.validationErrors) {
error.validationErrors = result.validationErrors;
}
throw error;
}
toast.success('Todos los cambios se guardaron correctamente');
} catch (e) {
console.error('Error saving all:', e);
console.log('Error object:', e);
console.log('Has validationErrors?', (e as any)?.validationErrors);
if (e instanceof Error && e.message.includes('401')) {
toast.error('Sesión expirada. Recargando página...');
setTimeout(() => {
@@ -158,9 +168,23 @@
}, 1500);
} else {
const errorMessage = e instanceof Error ? e.message : 'Error al guardar los cambios';
toast.error(errorMessage, {
description: 'Revisa la consola para más detalles'
});
// Si hay errores de validación, mostrarlos en detalle
if (e && typeof e === 'object' && 'validationErrors' in e && Array.isArray((e as any).validationErrors)) {
const validationErrors = (e as any).validationErrors;
console.log('Found validationErrors:', validationErrors);
const errorList = validationErrors.map((err: any) =>
`• ${err.field}: ${err.message}${err.solution ? ' - ' + err.solution.join(', ') : ''}`
).join('\n');
toast.error(errorMessage, {
description: errorList
});
} else {
toast.error(errorMessage, {
description: 'Revisa la consola para más detalles'
});
}
}
} finally {
saving = false;