Files
plantillas-proyectos/frontend/src/lib/utils/items-logic.ts

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.';
}