fix/impo-invoice-save

This commit is contained in:
2026-04-17 10:49:14 -06:00
parent 9007bb6c7a
commit db6c5a3372
4 changed files with 145 additions and 21 deletions

View File

@@ -26,6 +26,65 @@ export function humanizeLineReferences(text: string): string {
return text.replace(/\bline\[(\d+)\]/gi, 'partida $1');
}
function humanizeFieldPath(field: string): string {
const rawField = (field || '').trim();
if (!rawField) return 'campo';
const lineMatch = rawField.match(/^line\[(\d+)\]\.(.+)$/i);
const fieldPath = lineMatch?.[2] || rawField;
const label = fieldPath
.replace(/^body\./i, '')
.replace(/\./g, ' → ')
.replace(/_/g, ' ');
if (lineMatch) {
return `Partida ${lineMatch[1]} - ${label}`;
}
return label;
}
function humanizeValidationMessage(message: string): string {
const rawMessage = (message || '').trim();
if (!rawMessage) return 'error de validación';
return rawMessage
.replace(/\b(field required|is required)\b/gi, 'es obligatorio')
.replace(/\b(value is not a valid decimal)\b/gi, 'debe ser un número válido')
.replace(/\b(value is not a valid integer)\b/gi, 'debe ser un número entero válido');
}
function formatValidationHint(field: string, message: string, code?: string): string {
const fieldLabel = humanizeFieldPath(field);
const normalizedMessage = humanizeValidationMessage(message);
if (code === 'REQUIRED' || code === 'REQUIRED_FIELD' || /es obligatorio|es requerido/i.test(normalizedMessage)) {
return `Completa ${fieldLabel}.`;
}
if (code === 'AMERICAN_FRACTION_NOT_FOUND') {
return 'La fracción americana seleccionada no existe. Elige una opción del catálogo.';
}
if (code === 'UNIT_OF_MEASURE_NOT_FOUND') {
return 'La unidad de medida seleccionada no existe. Elige una opción del catálogo.';
}
if (code === 'ORIGIN_COUNTRY_NOT_FOUND') {
return 'El país de origen seleccionado no existe. Elige una opción del catálogo.';
}
if (code === 'CLASS_NOT_FOUND') {
return 'La clase seleccionada no existe. Elige una opción del catálogo.';
}
if (code === 'FRACTION_TYPE_INVALID') {
return 'Selecciona un tipo de tarifa válido.';
}
return normalizedMessage;
}
/**
* Título y descripción listos para toasts / alertas a partir de ApiResponse.
* Prioriza los mensajes que ya envía el backend y evita duplicar rutas técnicas.
@@ -34,7 +93,7 @@ export function friendlyApiErrorParts(res: ApiResponse): { title: string; descri
const validationErrors = res.validationErrors;
if (validationErrors?.length) {
const blocks = validationErrors.map((e) => {
const base = humanizeLineReferences((e.message || '').trim() || e.field);
const base = formatValidationHint(e.field || '', e.message || '', e.code);
const hints = e.solution?.filter(Boolean).length
? '\n' + e.solution!.map((s) => `${humanizeLineReferences(s)}`).join('\n')
: '';
@@ -52,7 +111,7 @@ export function friendlyApiErrorParts(res: ApiResponse): { title: string; descri
}
if (res.error) {
const err = humanizeLineReferences(res.error.trim());
const err = humanizeValidationMessage(humanizeLineReferences(res.error.trim()));
if (err.startsWith('Error de validación:')) {
return {
title: 'Revisa los datos ingresados',
@@ -241,6 +300,7 @@ async function fetchApi<T = any>(
if (response.status === 422) {
// HTTPException(detail={ message, errors }) — catálogo / CSV parity
const det = data.detail;
const validationErrors = (errors: unknown[]) => errors as NonNullable<ApiResponse['validationErrors']>;
if (
det &&
typeof det === 'object' &&
@@ -250,7 +310,7 @@ async function fetchApi<T = any>(
const d = det as { message?: string; errors: unknown[] };
return {
error: d.message || 'Error de validación',
validationErrors: d.errors,
validationErrors: validationErrors(d.errors),
status: response.status
};
}
@@ -258,7 +318,7 @@ async function fetchApi<T = any>(
if (data.errors && Array.isArray(data.errors)) {
return {
error: data.message || 'Error de validación',
validationErrors: data.errors,
validationErrors: validationErrors(data.errors),
status: response.status
};
}
@@ -421,7 +481,7 @@ async function fetchApiFormDataPost<T = any>(
if (data.errors && Array.isArray(data.errors)) {
resolve({
error: data.message || 'Error de validación',
validationErrors: data.errors,
validationErrors: data.errors as NonNullable<ApiResponse['validationErrors']>,
status: 422
});
return;
@@ -431,8 +491,8 @@ async function fetchApiFormDataPost<T = any>(
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}`;
const field = err.loc ? err.loc.filter((loc: string) => loc !== 'body').join('.') : 'campo desconocido';
return `${humanizeFieldPath(field)}: ${humanizeValidationMessage(err.msg || 'error de validación')}`;
})
.join(', ');
errorMessage += errors;

View File

@@ -134,12 +134,12 @@
<div class="grid grid-cols-12 gap-2 items-end">
<div class="col-span-2 space-y-1">
<Label for="cantidad_bultos" class="text-xs">Quantity:</Label>
<Label for="cantidad_bultos" class="text-xs">Quantity: <span class="text-red-500">*</span></Label>
<Input id="cantidad_bultos" type="number" step="1" min="0" bind:value={quantities.package_quantity} disabled={disabled} class="h-7 text-xs text-right" />
</div>
<div class="col-span-3 space-y-1">
<Label for="clave_bultos" class="text-xs">Package Code:</Label>
<Label for="clave_bultos" class="text-xs">Package Code: <span class="text-red-500">*</span></Label>
<div class="flex gap-1">
<Input
id="clave_bultos"

View File

@@ -225,6 +225,9 @@
{#if editingItem}
<div class="max-h-[calc(90vh-96px)] overflow-auto bg-slate-50/60 p-6 dark:bg-black">
<p class="mb-3 text-[10px] text-muted-foreground">
Los campos marcados con * son obligatorios.
</p>
<Tabs.Root bind:value={activeTab} class="mt-0">
<Tabs.List class="grid w-full grid-cols-4">
<Tabs.Trigger value="general">General</Tabs.Trigger>
@@ -272,7 +275,7 @@
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-2 md:col-span-2">
<Label for="class_code">Clase</Label>
<Label for="class_code">Clase <span class="text-red-500">*</span></Label>
<div class="flex gap-1">
<Input
id="class_code"
@@ -295,13 +298,13 @@
</div>
<div class="space-y-2">
<Label for="quantity_general">Cantidad</Label>
<Label for="quantity_general">Cantidad <span class="text-red-500">*</span></Label>
{#if editingItem?.quantity}
<Input id="quantity_general" type="number" step="0.00000001" min="0" bind:value={editingItem.quantity.quantity} />
{/if}
</div>
<div class="space-y-2">
<Label for="unit_general">U.M.</Label>
<Label for="unit_general">U.M. <span class="text-red-500">*</span></Label>
<div class="flex gap-1">
<Input
id="unit_general"
@@ -321,13 +324,13 @@
</div>
<div class="space-y-2">
<Label for="unit_cost_capture">Costo Unitario</Label>
<Label for="unit_cost_capture">Costo Unitario <span class="text-red-500">*</span></Label>
{#if editingItem?.financial}
<Input id="unit_cost_capture" type="number" step="0.00000001" min="0" bind:value={editingItem.financial.unit_cost_capture} />
{/if}
</div>
<div class="space-y-2">
<Label for="origin_country_general">País de Origen</Label>
<Label for="origin_country_general">País de Origen <span class="text-red-500">*</span></Label>
<div class="flex gap-1">
<Input
id="origin_country_general"
@@ -347,7 +350,7 @@
</div>
<div class="space-y-2">
<Label for="fraction_general">Fracción</Label>
<Label for="fraction_general">Fracción <span class="text-red-500">*</span></Label>
<div class="flex gap-1">
<Input
id="fraction_general"
@@ -366,7 +369,7 @@
</div>
</div>
<div class="space-y-2">
<Label for="fraction_type_general">Tipo de Tarifa</Label>
<Label for="fraction_type_general">Tipo de Tarifa <span class="text-red-500">*</span></Label>
{#if editingItem?.customs}
<select id="fraction_type_general" bind:value={editingItem.customs.fraction_type} class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<option value=""></option>
@@ -464,7 +467,7 @@
<Tabs.Content value="clasificacion" class="mt-4 space-y-4">
<div class="space-y-4">
<div class="space-y-2">
<Label for="tariff_fraction">Fracción Arancelaria</Label>
<Label for="tariff_fraction">Fracción Arancelaria <span class="text-red-500">*</span></Label>
{#if editingItem?.customs}
<Input
id="tariff_fraction"
@@ -504,7 +507,7 @@
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="country_origin">País de Origen</Label>
<Label for="country_origin">País de Origen <span class="text-red-500">*</span></Label>
{#if editingItem?.customs}
<Input id="country_origin" placeholder="Código del país" bind:value={editingItem.customs.origin_country} />
{/if}

View File

@@ -263,7 +263,28 @@ const FIELD_MAP: Record<string, string> = {
'fa_data.search_invoice': 'Factura de Referencia',
'fa_data.search_line': 'Línea de Referencia',
'fa_data.search_type': 'Tipo de Búsqueda',
'fa_data.movement_type_import': 'Tipo de Importación'
'fa_data.movement_type_import': 'Tipo de Importación',
'fa_data.is_subitem': 'Es Subpartida',
'fa_data.subitem_number': 'Número de Partida Principal'
};
const FIELD_GUIDANCE: Record<string, string> = {
class_id: 'Selecciona una clase.',
unit_of_measure: 'Selecciona una unidad de medida.',
'quantity.quantity': 'Captura una cantidad válida mayor a cero.',
'quantity.net_weight': 'Captura un peso neto válido mayor a cero.',
'customs.fraction': 'Selecciona una fracción arancelaria válida.',
'customs.origin_country': 'Selecciona un país de origen válido.',
'customs.fraction_type': 'Selecciona un tipo de tarifa.',
'customs.american_fraction': 'Selecciona una fracción americana válida.',
'description.description_spanish': 'Captura la descripción en español.',
'description.description_english': 'Captura la descripción en inglés.',
'financial.unit_cost_capture': 'Captura un costo unitario válido.',
'fa_data.search_invoice': 'Selecciona una factura de referencia.',
'fa_data.search_line': 'Selecciona una línea de referencia.',
'fa_data.search_type': 'Selecciona un tipo de búsqueda.',
'fa_data.movement_type_import': 'Selecciona TEM o DEF.',
'fa_data.subitem_number': 'Captura el número de la partida principal.'
};
function humanizeFieldPath(field: string): string {
@@ -295,6 +316,46 @@ function humanizeValidationMessage(message: string): string {
.replace(/\b(value is not a valid integer)\b/gi, 'debe ser un número entero válido');
}
function formatFriendlyFieldMessage(fieldName: string, message: string, code?: string): string {
const cleanFieldName = fieldName.replace(/^Partida \d+ - /, '');
const guidance = FIELD_GUIDANCE[cleanFieldName] || FIELD_GUIDANCE[fieldName];
const normalizedMessage = humanizeValidationMessage(message);
if (code === 'REQUIRED' || code === 'REQUIRED_FIELD' || /es requerido|es obligatorio/i.test(normalizedMessage)) {
return guidance || `Completa ${fieldName}.`;
}
if (code === 'AMERICAN_FRACTION_NOT_FOUND') {
return `La fracción americana seleccionada no existe. Elige una opción del catálogo.`;
}
if (code === 'FRACTION_TYPE_INVALID') {
return 'Selecciona un tipo de tarifa válido.';
}
if (code === 'UNIT_OF_MEASURE_NOT_FOUND') {
return 'La unidad de medida seleccionada no existe. Elige una opción del catálogo.';
}
if (code === 'ORIGIN_COUNTRY_NOT_FOUND') {
return 'El país de origen seleccionado no existe. Elige una opción del catálogo.';
}
if (code === 'CLASS_NOT_FOUND') {
return 'La clase seleccionada no existe. Elige una opción del catálogo.';
}
if (code === 'PACKAGE_NOT_FOUND' || code === 'PACKAGE_ID_REQUIRED') {
return 'El paquete seleccionado no es válido. Elige una opción del catálogo.';
}
if (code === 'MOVEMENT_TYPE_IMPORT_INVALID') {
return 'Selecciona TEM o DEF para el tipo de importación.';
}
return normalizedMessage;
}
/**
* Formats a backend error into a human-readable Spanish message.
* Handles 422 (Validation), 403 (Forbidden), 404 (Not Found), and 500 (Server Error).
@@ -316,7 +377,7 @@ export function formatItemError(error: any): string {
if (status === 422 && Array.isArray(validationErrors)) {
const errors = validationErrors.map((err: any) => {
const fieldName = humanizeFieldPath(err.field || '');
const msg = humanizeValidationMessage(err.message || 'error de validación');
const msg = formatFriendlyFieldMessage(fieldName, err.message || 'error de validación', err.code);
return `${fieldName}: ${msg}`;
});
@@ -332,7 +393,7 @@ export function formatItemError(error: any): string {
.join('.');
const fieldName = humanizeFieldPath(locPath);
const msg = humanizeValidationMessage(err.msg || 'error de validación');
const msg = formatFriendlyFieldMessage(fieldName, err.msg || 'error de validación', err.type);
return `${fieldName}: ${msg}`;
});