359 lines
14 KiB
TypeScript
359 lines
14 KiB
TypeScript
|
|
// Helper function to convert to number or undefined
|
|
const toNumberOrUndefined = (value: any): number | undefined => {
|
|
if (value === undefined || value === null || value === '') {
|
|
return undefined;
|
|
}
|
|
const numValue = Number(value);
|
|
return !isNaN(numValue) && isFinite(numValue) ? numValue : undefined;
|
|
};
|
|
|
|
// Fields that should be converted to numbers if they exist
|
|
const NUMERIC_FIELDS = [
|
|
// Top-level
|
|
'invoice_id', 'line_number', 'part_number_id', 'component_part_number_id', 'class_id',
|
|
'unit_of_measure', 'alternate_unit', 'consecutive_destination', 'consecutive_aphis',
|
|
'bom_version', 'bill_version', 'tlcan_value', 'validation_zero', 'validation_one',
|
|
'take_component_pt', 'pallet2', 'rectification',
|
|
|
|
// Financial
|
|
'unit_cost_capture', 'unit_cost_usd', 'unit_cost_commercial_usd', 'unit_cost_current_usd',
|
|
'unit_cost_depreciated_usd', 'unit_cost_subitem_usd', 'unit_cost_auxiliary_usd',
|
|
'sales_cost_usd', 'commercial_unit_cost', 'unit_cost_mxn', 'unit_cost_commercial_mxn',
|
|
'unit_cost_current_mxn', 'unit_cost_depreciated_mxn', 'unit_cost_subitem_mxn',
|
|
'sales_cost_mxn', 'unit_cost_mc', 'value_mxn', 'value_commercial_mxn', 'value_updated_mxn',
|
|
'value_subitem_mxn', 'sub_import_value_mxn', 'value_returned_mxn', 'value_depreciated_mxn',
|
|
'customs_value_mxn', 'value_total_mxn', 'value_temp_material_mxn', 'value_def_material_mxn',
|
|
'value_added_mxn', 'value_national_packing_mxn', 'vat_mxn', 'vat_used_mxn',
|
|
'advalorem_line_mxn', 'value_usd', 'value_commercial_usd', 'value_updated_usd',
|
|
'value_subitem_usd', 'sub_import_value_usd', 'value_returned_usd', 'value_depreciated_usd',
|
|
'customs_value_usd', 'value_auxiliary_usd', 'value_total_usd', 'value_temp_material_usd',
|
|
'value_def_material_usd', 'value_added_usd', 'value_national_packing_usd',
|
|
'value_us_packing_usd', 'vat_usd', 'vat_used_usd', 'value_non_originating_usd',
|
|
'value_originating_usd', 'igi_amount_usd', 'exempt_amount_usd', 'total_commercial_value',
|
|
'advalorem_line_usd', 'value_mc', 'sub_import_value_mc', 'vat_mc', 'value_added_mc',
|
|
'value_national_packing_mc', 'value_total_mc', 'value_temp_material_mc', 'value_def_material_mc',
|
|
|
|
// Quantity
|
|
'quantity', 'alternate_quantity', 'quantity_uma', 'auxiliary_quantity',
|
|
'quantity_temp_export', 'serial_count', 'net_weight', 'gross_weight',
|
|
'package_id', 'package_quantity', 'container_quantity',
|
|
|
|
// Customs
|
|
'advalorem_numeric', 'advalorem_american', 'advalorem_tlcan', 'depreciation_rate',
|
|
|
|
// Description
|
|
'eighth_rule_line',
|
|
|
|
// Series
|
|
'row', 'serie_row', 'import_line',
|
|
|
|
// Identifiers
|
|
'invoice_consecutive', 'part_line',
|
|
|
|
// FA Data
|
|
'return_import_date', 'subitem_number', 'search_line'
|
|
];
|
|
|
|
// UI/Display only fields that should be removed before sending to API
|
|
const UI_BLACKLIST = [
|
|
'class_code', 'class_unit_of_measure', 'class_description', 'part_description_es',
|
|
'part_description_en', 'part_number_display', 'unit_code', 'unit_description',
|
|
'includes_subitems', 'payment_method_description', 'class_unit_of_measure_description',
|
|
'origin_country_name', 'fraction_description', 'id' // Remove ID for creates, but update handles it via URL
|
|
];
|
|
|
|
// Clean nested data before sending to API
|
|
export function cleanLineData(line: any) {
|
|
// 1. Deep clone to unwrap proxies and avoid mutations
|
|
const cleaned = JSON.parse(JSON.stringify(line));
|
|
|
|
// 2. Helper to clean an object recursively
|
|
const processObject = (obj: any) => {
|
|
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return;
|
|
|
|
Object.keys(obj).forEach(key => {
|
|
// Remove blacklisted fields
|
|
if (UI_BLACKLIST.includes(key)) {
|
|
delete obj[key];
|
|
return;
|
|
}
|
|
|
|
// Convert numeric fields - ONLY if it's not one of our known containers
|
|
const containers = ['financial', 'quantity', 'customs', 'description', 'fa_data', 'reference'];
|
|
const isContainer = containers.includes(key);
|
|
|
|
if (NUMERIC_FIELDS.includes(key) && !isContainer) {
|
|
obj[key] = toNumberOrUndefined(obj[key]);
|
|
}
|
|
|
|
// Recurse into nested objects if it's one of our known containers
|
|
if (isContainer && obj[key]) {
|
|
processObject(obj[key]);
|
|
}
|
|
});
|
|
};
|
|
|
|
// 3. Process the top level and nested objects
|
|
processObject(cleaned);
|
|
|
|
// 4. Special handling for Series and Identifiers (arrays)
|
|
if (cleaned.series && Array.isArray(cleaned.series)) {
|
|
cleaned.series = cleaned.series.map((s: any) => {
|
|
if (typeof s !== 'object') return s;
|
|
const sClean = { ...s };
|
|
// Remove UI IDs from series rows if they are local temporary IDs
|
|
if (typeof sClean.id === 'string' && sClean.id.startsWith('temp-')) {
|
|
delete sClean.id;
|
|
}
|
|
processObject(sClean);
|
|
return sClean;
|
|
});
|
|
}
|
|
|
|
if (cleaned.identifiers && Array.isArray(cleaned.identifiers)) {
|
|
cleaned.identifiers = cleaned.identifiers.map((i: any) => {
|
|
if (typeof i !== 'object') return i;
|
|
processObject(i);
|
|
return i;
|
|
});
|
|
}
|
|
|
|
return cleaned;
|
|
}
|
|
|
|
// Normalize numeric values from strings to numbers (for editing)
|
|
export function normalizeItemData(item: Partial<Item>): Partial<Item> {
|
|
if (!item) return {};
|
|
|
|
// Deep clone to avoid side effects
|
|
const normalized = JSON.parse(JSON.stringify(item));
|
|
|
|
const processNormalization = (obj: any) => {
|
|
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return;
|
|
|
|
Object.keys(obj).forEach(key => {
|
|
const containers = ['financial', 'quantity', 'customs', 'description', 'fa_data', 'reference'];
|
|
const isContainer = containers.includes(key);
|
|
|
|
if (NUMERIC_FIELDS.includes(key) && !isContainer) {
|
|
const val = obj[key];
|
|
if (val !== undefined && val !== null && val !== '') {
|
|
const num = Number(val);
|
|
if (!isNaN(num)) {
|
|
obj[key] = num;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isContainer && obj[key]) {
|
|
processNormalization(obj[key]);
|
|
}
|
|
});
|
|
};
|
|
|
|
// Process top level and nested objects
|
|
processNormalization(normalized);
|
|
|
|
// Ensure mandatory containers exist to allow binding and partial updates
|
|
const mandatoryContainers = ['financial', 'quantity', 'customs', 'description', 'fa_data', 'reference'];
|
|
mandatoryContainers.forEach(container => {
|
|
if (!normalized[container]) {
|
|
normalized[container] = {};
|
|
}
|
|
});
|
|
|
|
// Process arrays
|
|
if (normalized.series && Array.isArray(normalized.series)) {
|
|
normalized.series.forEach((s: any) => processNormalization(s));
|
|
} else {
|
|
normalized.series = [];
|
|
}
|
|
|
|
if (normalized.identifiers && Array.isArray(normalized.identifiers)) {
|
|
normalized.identifiers.forEach((i: any) => processNormalization(i));
|
|
} else {
|
|
normalized.identifiers = [];
|
|
}
|
|
|
|
// 5. Popular campos de visualización para que el formulario los muestre al editar
|
|
if (normalized.part_number && !normalized.part_number_display) {
|
|
normalized.part_number_display = normalized.part_number;
|
|
}
|
|
|
|
if (normalized.unit_of_measure_code && !normalized.unit_code) {
|
|
normalized.unit_code = normalized.unit_of_measure_code;
|
|
}
|
|
|
|
if (normalized.customs?.origin_country && !normalized.origin_country_name) {
|
|
normalized.origin_country_name = normalized.customs.origin_country;
|
|
}
|
|
|
|
// 5. Popular campos de visualización para que el formulario los muestre al editar
|
|
if (normalized.part_number && !normalized.part_number_display) {
|
|
normalized.part_number_display = normalized.part_number;
|
|
}
|
|
|
|
if (normalized.unit_of_measure_code && !normalized.unit_code) {
|
|
normalized.unit_code = normalized.unit_of_measure_code;
|
|
}
|
|
|
|
if (normalized.customs && normalized.customs.origin_country && !normalized.origin_country_name) {
|
|
normalized.origin_country_name = normalized.customs.origin_country;
|
|
}
|
|
|
|
// Solo asegurar que el objeto description exista para que sea reactivo al editar
|
|
if (!normalized.description) {
|
|
normalized.description = {
|
|
description_spanish: normalized.part_description_es || '',
|
|
description_english: normalized.part_description_en || ''
|
|
};
|
|
} else {
|
|
// Sincronización bidireccional entre raíz (redundancia) y objeto anidado
|
|
const descES = normalized.description.description_spanish || normalized.part_description_es || '';
|
|
const descEN = normalized.description.description_english || normalized.part_description_en || '';
|
|
|
|
normalized.description.description_spanish = descES;
|
|
normalized.description.description_english = descEN;
|
|
normalized.part_description_es = descES;
|
|
normalized.part_description_en = descEN;
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
export interface Item {
|
|
id?: number;
|
|
[key: string]: any;
|
|
}
|
|
|
|
// Helper function to check if an object has any meaningful values
|
|
export function hasValues(obj: any): boolean {
|
|
if (!obj || typeof obj !== 'object') return false;
|
|
|
|
// Si el objeto está intencionalmente vacío o tiene campos que serán usados,
|
|
// es mejor dejar que el backend valide si es requerido.
|
|
const values = Object.values(obj);
|
|
if (values.length === 0) return false;
|
|
|
|
return values.some(
|
|
(val) =>
|
|
val !== undefined &&
|
|
val !== null &&
|
|
val !== '' &&
|
|
!(typeof val === 'object' && !hasValues(val))
|
|
);
|
|
}
|
|
|
|
// Human-readable mapping for field errors
|
|
const FIELD_MAP: Record<string, string> = {
|
|
'class_id': 'Clase',
|
|
'invoice_id': 'ID de Factura',
|
|
'line_number': 'Número de Línea',
|
|
'unit_of_measure': 'Unidad de Medida',
|
|
'description.description_spanish': 'Descripción en Español',
|
|
'description.description_english': 'Descripción en Inglés',
|
|
'quantity.quantity': 'Cantidad',
|
|
'quantity.net_weight': 'Peso Neto',
|
|
'quantity.gross_weight': 'Peso Bruto',
|
|
'financial.unit_cost_capture': 'Costo Unitario',
|
|
'customs.fraction': 'Fracción Arancelaria',
|
|
'customs.origin_country': 'País de Origen',
|
|
'customs.american_fraction': 'Fracción Americana',
|
|
'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'
|
|
};
|
|
|
|
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 mappedPath = fieldPath.replace(/^body\./i, '');
|
|
const fieldLabel = FIELD_MAP[mappedPath] || mappedPath.replace(/\./g, ' → ');
|
|
|
|
if (lineMatch) {
|
|
return `Partida ${lineMatch[1]} - ${fieldLabel}`;
|
|
}
|
|
|
|
return fieldLabel;
|
|
}
|
|
|
|
function humanizeValidationMessage(message: string): string {
|
|
const rawMessage = (message || '').trim();
|
|
if (!rawMessage) return 'error de validación';
|
|
|
|
return rawMessage
|
|
.replace(/line\[(\d+)\]\.(\w+(?:\.\w+)*)/gi, (_match, lineNumber, fieldPath) => {
|
|
return `Partida ${lineNumber} - ${humanizeFieldPath(fieldPath)}`;
|
|
})
|
|
.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');
|
|
}
|
|
|
|
/**
|
|
* Formats a backend error into a human-readable Spanish message.
|
|
* Handles 422 (Validation), 403 (Forbidden), 404 (Not Found), and 500 (Server Error).
|
|
* Supports both legacy Axios error objects and new ApiResponse objects.
|
|
*/
|
|
export function formatItemError(error: any): string {
|
|
if (!error) return 'Error desconocido';
|
|
|
|
// 1. Detect if it's a new ApiResponse structure or a legacy Axios error
|
|
const isApiResponse = 'status' in error && ('error' in error || 'validationErrors' in error);
|
|
|
|
const status = isApiResponse ? error.status : (error?.response?.status);
|
|
const validationErrors = isApiResponse ? error.validationErrors : null;
|
|
const data = !isApiResponse ? error?.response?.data : null;
|
|
const detail = isApiResponse ? error.error : data?.detail;
|
|
|
|
// 2. Handle Validation Errors (422)
|
|
// New structure (ApiResponse.validationErrors)
|
|
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');
|
|
|
|
return `• ${fieldName}: ${msg}`;
|
|
});
|
|
return `Por favor revisa los siguientes campos:\n${errors.join('\n')}`;
|
|
}
|
|
|
|
// Legacy structure (FastAPI detail array)
|
|
const legacyDetailArray = !isApiResponse && Array.isArray(detail) ? detail : null;
|
|
if (status === 422 && legacyDetailArray) {
|
|
const errors = legacyDetailArray.map((err: any) => {
|
|
const locPath = (err.loc || [])
|
|
.filter((l: string) => l !== 'body')
|
|
.join('.');
|
|
|
|
const fieldName = humanizeFieldPath(locPath);
|
|
const msg = humanizeValidationMessage(err.msg || 'error de validación');
|
|
|
|
return `• ${fieldName}: ${msg}`;
|
|
});
|
|
return `Por favor revisa los siguientes campos:\n${errors.join('\n')}`;
|
|
}
|
|
|
|
// 3. Handle known detail strings
|
|
if (typeof detail === 'string') {
|
|
const d = detail.trim();
|
|
if (d.includes('Access denied')) return 'No tienes permisos para realizar esta acción.';
|
|
if (d.includes('not found')) return 'El registro no existe o fue eliminado.';
|
|
if (d.includes('Class mismatch')) return 'Error de validación: ' + d;
|
|
return humanizeValidationMessage(d);
|
|
}
|
|
|
|
// 4. Fallbacks by status code
|
|
if (status === 403) return 'No tienes permisos para realizar esta operación.';
|
|
if (status === 404) return 'No se encontró el recurso solicitado.';
|
|
if (status >= 500) return 'Hubo un problema en el servidor. Reintenta en unos momentos.';
|
|
|
|
// 5. Default messages
|
|
return detail || data?.message || error.message || 'Error al procesar la solicitud.';
|
|
}
|