// 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): Partial { 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 = { '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.'; }