fix/impo-invoice-save
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}`;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user