/** * Cliente API para comunicación con el backend */ import { getToken } from './auth'; import { browser } from '$app/environment'; import { toast } from 'svelte-sonner'; import { clearAccessTokenOnDocument, setAccessTokenOnDocument } from '$lib/access-token-cookie-browser'; /** Base URL absoluta para fetch; corrige `http:host` sin `//` y añade `http://` si no hay esquema. */ function normalizeAbsoluteApiBaseUrl(raw: string): string { let s = (raw ?? '').trim().replace(/\/+$/, ''); if (!s) return ''; if (s.startsWith('http:') && !s.startsWith('http://')) { s = 'http://' + s.slice('http:'.length).replace(/^\/+/, ''); } if (s.startsWith('https:') && !s.startsWith('https://')) { s = 'https://' + s.slice('https:'.length).replace(/^\/+/, ''); } if (s.startsWith('/')) return s; if (/^https?:\/\//i.test(s)) return s; return `http://${s.replace(/^\/+/, '')}`; } const API_BASE_URL = normalizeAbsoluteApiBaseUrl(String(import.meta.env.VITE_API_URL ?? '')); export interface ApiResponse { data?: T; error?: string; validationErrors?: Array<{ field: string; message: string; code?: string; solution?: string[]; value?: any; }>; status: number; } /** Reemplaza referencias técnicas `line[n]` por texto más claro para el usuario. */ 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. */ export function friendlyApiErrorParts(res: ApiResponse): { title: string; description: string } { const validationErrors = res.validationErrors; if (validationErrors?.length) { const blocks = validationErrors.map((e) => { 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') : ''; return base + hints; }); const description = blocks.join('\n\n').trim(); const rawTitle = (res.error || '').trim(); const title = rawTitle && !rawTitle.startsWith('Error de validación') && rawTitle !== 'Error de validación' ? rawTitle : 'Revisa los datos de la partida'; return { title, description: description || rawTitle || 'Corrige los datos e intenta de nuevo.' }; } if (res.error) { const err = humanizeValidationMessage(humanizeLineReferences(res.error.trim())); if (err.startsWith('Error de validación:')) { return { title: 'Revisa los datos ingresados', description: err.replace(/^Error de validación:\s*/i, '').trim() || err }; } return { title: 'No se pudo completar la acción', description: err }; } return { title: 'Error', description: 'Ocurrió un error inesperado. Intenta de nuevo o contacta a soporte si continúa.' }; } let isRefreshing = false; let refreshSubscribers: ((token: string) => void)[] = []; /** * Agrega una petición a la cola de espera mientras se refresca el token */ function subscribeTokenRefresh(callback: (token: string) => void) { refreshSubscribers.push(callback); } /** * Notifica a todas las peticiones en espera que el token se ha refrescado */ function onTokenRefreshed(token: string) { refreshSubscribers.forEach((callback) => callback(token)); refreshSubscribers = []; } /** * Refresca el token silenciosamente usando el endpoint server-side. * * El servidor lee el refresh_token desde la cookie HttpOnly, * llama a Keycloak, actualiza las cookies y devuelve el nuevo access_token. * El refresh_token NUNCA es leído por este código JavaScript. */ async function refreshToken(): Promise { if (!browser) return null; try { const response = await fetch('/api-sveltekit/auth/silent-refresh', { method: 'POST', credentials: 'include', // Envía cookies HttpOnly automáticamente headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { console.error('❌ [API] Silent refresh falló, status:', response.status); clearAccessTokenOnDocument(); setTimeout(() => { window.location.href = '/login'; }, 1500); return null; } const data = await response.json() as { access_token?: string }; if (data.access_token) { setAccessTokenOnDocument(data.access_token); // Actualizar authStore en memoria try { const { authStore } = await import('./auth'); authStore.setToken(data.access_token); } catch {} return data.access_token; } return null; } catch (error) { console.error('❌ [API] Error en silent refresh:', error); return null; } } /** * Realiza una petición al API con manejo automático de refresh token */ async function fetchApi( endpoint: string, options: RequestInit = {}, retryCount = 0 ): Promise> { // Si ya estamos refrescando el token, esperar if (isRefreshing && retryCount === 0) { return new Promise((resolve) => { subscribeTokenRefresh((newToken) => { resolve(fetchApi(endpoint, options, 1)); }); }); } const token = getToken(); if (!token && !endpoint.includes('/auth/login')) { console.warn('⚠️ [API] No hay token disponible para', endpoint); } const headers: Record = { ...((options.headers as Record) || {}) }; // Only set Content-Type to application/json if not already set and body is not FormData if (!headers['Content-Type'] && !(options.body instanceof FormData)) { headers['Content-Type'] = 'application/json'; } if (token) { headers['Authorization'] = `Bearer ${token}`; } // Incluir tenant override para flujo SSO multi-tenant. // sso_tenant_pub es una cookie no-HttpOnly que el servidor setea junto con sso_tenant_id. if (browser) { const tenantPub = document.cookie .split('; ') .find((c) => c.startsWith('sso_tenant_pub=')) ?.split('=')[1]; if (tenantPub) { headers['X-Tenant-Override'] = tenantPub; } } try { const response = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, headers, credentials: 'include' // Importante: envía cookies con cada request }); // 403 = permisos, no autenticación: nunca intentar refresh. if (response.status === 403 && !endpoint.includes('/auth/refresh') && retryCount === 0) { if (browser) { toast.error('No tienes permisos para realizar esta acción', { duration: 4000, description: 'Contacta a tu administrador si crees que esto es un error' }); } const data = await response.json(); return { error: data.detail || 'No tienes permisos para realizar esta acción', status: 403 }; } // 402 = licencia inválida/expirada: no intentar refresh. if (response.status === 402 && !endpoint.includes('/auth/refresh') && retryCount === 0) { const data = await response.json().catch(() => ({})); return { error: data.message || data.detail || 'Licencia inválida o expirada', status: 402 }; } // Solo 401 dispara silent refresh. if (response.status === 401 && !endpoint.includes('/auth/refresh') && retryCount === 0) { // Si es 401, intentar refrescar el token isRefreshing = true; try { const newToken = await refreshToken(); if (newToken) { // Token refrescado exitosamente onTokenRefreshed(newToken); isRefreshing = false; // Reintentar la petición original con el nuevo token return await fetchApi(endpoint, options, 1); } else { console.error('❌ [API] No se pudo refrescar el token'); isRefreshing = false; // Retornar error 401 para que la capa superior lo maneje return { error: 'Sesión expirada. Por favor, inicia sesión nuevamente.', status: 401 }; } } catch (refreshError) { console.error('❌ [API] Error al refrescar:', refreshError); isRefreshing = false; return { error: 'Error al refrescar la sesión', status: 401 }; } } // Manejar respuestas sin contenido (204 No Content) if (response.status === 204) { return { data: null as T, status: response.status }; } const data = await response.json(); if (!response.ok) { // Manejo especial para errores 422 (validation error) if (response.status === 422) { // HTTPException(detail={ message, errors }) — catálogo / CSV parity const det = data.detail; const validationErrors = (errors: unknown[]) => errors as NonNullable; if ( det && typeof det === 'object' && !Array.isArray(det) && Array.isArray((det as { errors?: unknown }).errors) ) { const d = det as { message?: string; errors: unknown[] }; return { error: d.message || 'Error de validación', validationErrors: validationErrors(d.errors), status: response.status }; } // Errores de validación personalizados (con array errors en raíz) if (data.errors && Array.isArray(data.errors)) { return { error: data.message || 'Error de validación', validationErrors: validationErrors(data.errors), status: response.status }; } // Errores de validación de FastAPI (con detail) else if (data.detail) { let errorMessage = 'Error de validación: '; // FastAPI devuelve errores de validación en data.detail como array 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}`; }).join(', '); errorMessage += errors; } else if (typeof data.detail === 'string') { errorMessage = data.detail; } else { errorMessage += JSON.stringify(data.detail); } return { error: errorMessage, status: response.status }; } } return { error: data.message || (typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail)) || 'Error en la petición', status: response.status }; } return { data, status: response.status }; } catch (error) { console.error(`❌ [API] Error de conexión en ${endpoint}:`, error); return { error: 'Error de conexión con el servidor', status: 0 }; } } /** Opciones para subidas CSV (FormData) con progreso de red. */ export type CsvFormDataUploadOptions = { onUploadProgress?: (e: { loaded: number; total: number }) => void; }; /** * POST multipart/form-data con XMLHttpRequest para exponer progreso de subida. * Misma semántica de auth/401/403/422 que fetchApi. */ async function fetchApiFormDataPost( endpoint: string, formData: FormData, opts: CsvFormDataUploadOptions & { retryCount?: number } = {} ): Promise> { const retryCount = opts.retryCount ?? 0; if (isRefreshing && retryCount === 0) { return new Promise((resolve) => { subscribeTokenRefresh(() => { resolve(fetchApiFormDataPost(endpoint, formData, { ...opts, retryCount: 1 })); }); }); } return new Promise((resolve) => { const token = getToken(); const xhr = new XMLHttpRequest(); xhr.open('POST', `${API_BASE_URL}${endpoint}`); xhr.withCredentials = true; if (token) { xhr.setRequestHeader('Authorization', `Bearer ${token}`); } xhr.upload.onprogress = (ev) => { if (!opts.onUploadProgress) return; if (ev.lengthComputable) { opts.onUploadProgress({ loaded: ev.loaded, total: ev.total }); } else { opts.onUploadProgress({ loaded: ev.loaded, total: 0 }); } }; xhr.onload = () => { void (async () => { const status = xhr.status; let data: any = null; if (xhr.responseText) { try { data = JSON.parse(xhr.responseText) as any; } catch { data = null; } } if ((status === 401 || status === 403) && !endpoint.includes('/auth/refresh') && retryCount === 0) { if (status === 403) { if (browser) { toast.error('No tienes permisos para realizar esta acción', { duration: 4000, description: 'Contacta a tu administrador si crees que esto es un error' }); } resolve({ error: data?.detail || 'No tienes permisos para realizar esta acción', status: 403 }); return; } isRefreshing = true; try { const newToken = await refreshToken(); if (newToken) { onTokenRefreshed(newToken); isRefreshing = false; resolve(await fetchApiFormDataPost(endpoint, formData, { ...opts, retryCount: 1 })); } else { console.error('❌ [API] No se pudo refrescar el token'); isRefreshing = false; resolve({ error: 'Sesión expirada. Por favor, inicia sesión nuevamente.', status: 401 }); } } catch (refreshError) { console.error('❌ [API] Error al refrescar:', refreshError); isRefreshing = false; resolve({ error: 'Error al refrescar la sesión', status: 401 }); } return; } if (status === 204) { resolve({ data: null as T, status }); return; } if (status === 0) { resolve({ error: 'Error de conexión con el servidor', status: 0 }); return; } if (status < 200 || status >= 300) { if (status === 422 && data) { if (data.errors && Array.isArray(data.errors)) { resolve({ error: data.message || 'Error de validación', validationErrors: data.errors as NonNullable, status: 422 }); return; } if (data.detail) { let errorMessage = 'Error de validación: '; if (Array.isArray(data.detail)) { const errors = data.detail .map((err: any) => { 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; } else if (typeof data.detail === 'string') { errorMessage = data.detail; } else { errorMessage += JSON.stringify(data.detail); } resolve({ error: errorMessage, status: 422 }); return; } } resolve({ error: data?.message || (typeof data?.detail === 'string' ? data.detail : JSON.stringify(data?.detail)) || 'Error en la petición', status }); return; } if (data === null && xhr.responseText) { resolve({ error: 'Respuesta inválida del servidor', status }); return; } resolve({ data, status }); })(); }; xhr.onerror = () => { resolve({ error: 'Error de conexión con el servidor', status: 0 }); }; try { xhr.send(formData); } catch (error) { console.error(`❌ [API] Error al enviar ${endpoint}:`, error); resolve({ error: 'Error de conexión con el servidor', status: 0 }); } }); } /** * Convierte cuerpos de error (JSON o texto) en un mensaje legible para toasts/UX. * Evita mostrar JSON crudo p. ej. `{"error":"HTTP_ERROR","message":"..."}`. */ function messageFromBlobErrorResponse(text: string, status: number): string { const raw = (text || '').trim(); if (!raw) { return status === 404 ? 'No se encontró el recurso. Prueba otro rango o vuelve a intentar.' : `Error ${status} al descargar el archivo.`; } try { const data = JSON.parse(raw) as Record; if (typeof data.message === 'string' && data.message.trim()) { return data.message.trim(); } const d = data.detail; if (typeof d === 'string' && d.trim()) { return d.trim(); } if (Array.isArray(d) && d[0] && typeof (d[0] as { msg?: string }).msg === 'string') { return String((d[0] as { msg: string }).msg).trim(); } } catch { // no es JSON: usar texto plano si es corto y legible } if (raw.length < 500 && !raw.startsWith('{')) { return raw; } if (raw.startsWith('{')) { return status === 404 ? 'No se encontró información para exportar. Prueba otras fechas o amplía el rango.' : `Error ${status} al descargar el archivo.`; } return raw; } async function fetchBlob(endpoint: string, options: RequestInit = {}): Promise { const token = getToken(); const headers: Record = { ...((options.headers as Record) || {}) }; if (token) { headers['Authorization'] = `Bearer ${token}`; } const response = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, headers, credentials: 'include' }); if (!response.ok) { const text = await response.text().catch(() => ''); throw new Error(messageFromBlobErrorResponse(text, response.status)); } return await response.blob(); } // Métodos HTTP export const api = { get: (endpoint: string) => fetchApi(endpoint, { method: 'GET' }), getBlob: (endpoint: string) => fetchBlob(endpoint, { method: 'GET' }), postBlob: (endpoint: string, body: any) => fetchBlob(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }), post: (endpoint: string, body: any, options: RequestInit = {}) => fetchApi(endpoint, { method: 'POST', body: JSON.stringify(body), ...options }), put: (endpoint: string, body: any, options: RequestInit = {}) => fetchApi(endpoint, { method: 'PUT', body: JSON.stringify(body), ...options }), patch: (endpoint: string, body: any, options: RequestInit = {}) => fetchApi(endpoint, { method: 'PATCH', body: JSON.stringify(body), ...options }), delete: (endpoint: string, options: RequestInit = {}) => fetchApi(endpoint, { method: 'DELETE', ...options }), /** * Download CSV template by template_id (generated from code, no static file). * Returns blob and suggested filename for the browser download. */ async getCsvTemplateDownload( templateId: string ): Promise<{ blob: Blob; filename: string }> { const token = getToken(); const headers: Record = {}; if (token) headers['Authorization'] = `Bearer ${token}`; const response = await fetch(`${API_BASE_URL}/v1/a76/csv-templates/${templateId}`, { method: 'GET', headers, credentials: 'include' }); if (!response.ok) { const msg = response.status === 404 ? 'Plantilla no encontrada' : `Error ${response.status}`; throw new Error(msg); } const blob = await response.blob(); let filename = `plantilla_${templateId}.csv`; const disposition = response.headers.get('Content-Disposition'); if (disposition) { const match = /filename="?([^";\n]+)"?/.exec(disposition); if (match) filename = match[1].trim(); } return { blob, filename }; }, // Endpoints específicos auth: { login: (credentials: { username: string; password: string; tenant_slug: string }) => api.post('/v1/auth/login/', credentials), refresh: (refreshToken: string) => api.post('/v1/auth/refresh/', { refresh_token: refreshToken }), logout: (data: { refresh_token: string, username?: string }) => api.post('/v1/auth/logout', data, { keepalive: true }), me: () => api.get('/v1/auth/me/'), health: () => api.get('/health'), register: (data: { username: string; email: string; password: string; first_name: string; last_name: string; tenant_slug: string; invite_token?: string; }) => api.post('/v1/auth/register', data), }, tenants: { list: (page = 1, pageSize = 50) => api.get(`/v1/tenants/?page=${page}&page_size=${pageSize}`), get: (id: number) => api.get(`/v1/tenants/${id}/`), create: (data: any) => api.post('/v1/tenants/', data), update: (id: number, data: any) => api.put(`/v1/tenants/${id}/`, data) }, licenses: { get: (tenantId: number) => api.get(`/v1/licenses/tenant/${tenantId}/`), myLicense: () => api.get('/v1/licenses/my-license/'), usage: (tenantId: number) => api.get(`/v1/licenses/usage/${tenantId}/`), validate: (tenantId: number) => api.get(`/v1/licenses/validate/${tenantId}/`) }, // CSV import for Operaciones de Importación/Exportación (facturas: encabezados y partidas). // Backend: api/v1/modules/a76/layouts_csv/facturas (rutas bajo /v1/a76/imports/). imports: { upload: ( file: File, modelTarget: string, footerConfig: any, companyId: number, operationType: string, templateId?: string, uploadOptions?: CsvFormDataUploadOptions ) => { const formData = new FormData(); formData.append('file', file); if (footerConfig) { formData.append('footer_config', JSON.stringify(footerConfig)); } if (templateId) { formData.append('template_id', templateId); } const queryParams = new URLSearchParams({ company_id: String(companyId), operation_type: operationType || 'imp' }).toString(); return fetchApiFormDataPost( `/v1/a76/imports/upload/${modelTarget}?${queryParams}`, formData, uploadOptions ); }, status: (jobId: string) => api.get(`/v1/a76/imports/${jobId}/status`), commit: (jobId: string, modelTarget: string) => api.post(`/v1/a76/imports/${jobId}/commit`, { model_target: modelTarget }), downloadScanErrorsCsv: (jobId: string) => fetchBlob(`/v1/a76/imports/${jobId}/errors/scan-csv`) }, // CSV import for Operaciones de Exportación (encabezado y partidas). // Backend: layouts_csv/exportacion — rutas /v1/a76/imports/exportacion/ exportacionImports: { upload: ( file: File, modelTarget: string, footerConfig: any, companyId: number, templateId?: string, uploadOptions?: CsvFormDataUploadOptions ) => { const formData = new FormData(); formData.append('file', file); if (footerConfig) { formData.append('footer_config', JSON.stringify(footerConfig)); } if (templateId) { formData.append('template_id', templateId); } const queryParams = new URLSearchParams({ company_id: String(companyId), operation_type: 'exp' }).toString(); return fetchApiFormDataPost( `/v1/a76/imports/exportacion/upload/${modelTarget}?${queryParams}`, formData, uploadOptions ); }, status: (jobId: string) => api.get(`/v1/a76/imports/exportacion/${jobId}/status`), commit: (jobId: string, modelTarget: string) => api.post(`/v1/a76/imports/exportacion/${jobId}/commit`, { model_target: modelTarget }), downloadScanErrorsCsv: (jobId: string) => fetchBlob(`/v1/a76/imports/exportacion/${jobId}/errors/scan-csv`) }, // CSV import for Agentes Aduanales (flujo propio en customs_brokers/imports) customsBrokerImports: { upload: (file: File, companyId: number, uploadOptions?: CsvFormDataUploadOptions) => { const formData = new FormData(); formData.append('file', file); return fetchApiFormDataPost( `/v1/a76/customs-brokers/imports/upload?company_id=${companyId}`, formData, uploadOptions ); }, status: (jobId: string) => api.get(`/v1/a76/customs-brokers/imports/${jobId}/status`), commit: (jobId: string) => api.post(`/v1/a76/customs-brokers/imports/${jobId}/commit`, {}), downloadScanErrorsCsv: (jobId: string) => fetchBlob(`/v1/a76/customs-brokers/imports/${jobId}/errors/scan-csv`) }, // CSV import for Clientes y Proveedores (flujo propio en clients_and_providers/imports) clientProviderImports: { upload: (file: File, companyId: number, uploadOptions?: CsvFormDataUploadOptions) => { const formData = new FormData(); formData.append('file', file); return fetchApiFormDataPost( `/v1/a76/clients-providers/imports/upload?company_id=${companyId}`, formData, uploadOptions ); }, status: (jobId: string) => api.get(`/v1/a76/clients-providers/imports/${jobId}/status`), commit: (jobId: string) => api.post(`/v1/a76/clients-providers/imports/${jobId}/commit`, {}), downloadScanErrorsCsv: (jobId: string) => fetchBlob(`/v1/a76/clients-providers/imports/${jobId}/errors/scan-csv`) }, // CSV import for Tipos de Cambio (flujo en general_catalogs/exchange_rate/imports) exchangeRateImports: { upload: ( file: File, companyId: number, params?: { reemplazar_sin_preguntar?: boolean; date_format?: string }, uploadOptions?: CsvFormDataUploadOptions ) => { const formData = new FormData(); formData.append('file', file); const search = new URLSearchParams({ company_id: String(companyId) }); if (params?.reemplazar_sin_preguntar !== undefined) search.set('reemplazar_sin_preguntar', String(!!params.reemplazar_sin_preguntar)); if (params?.date_format != null && params.date_format !== '') search.set('date_format', params.date_format); return fetchApiFormDataPost( `/v1/a76/exchange-rate/imports/upload?${search.toString()}`, formData, uploadOptions ); }, status: (jobId: string) => api.get(`/v1/a76/exchange-rate/imports/${jobId}/status`), commit: (jobId: string) => api.post(`/v1/a76/exchange-rate/imports/${jobId}/commit`, {}), downloadScanErrorsCsv: (jobId: string) => fetchBlob(`/v1/a76/exchange-rate/imports/${jobId}/errors/scan-csv`) }, // CSV import for Pedimentos (pedimentos/imports) pedimentosImports: { upload: ( file: File, companyId: number, params?: { actualizar?: boolean; dateFormat?: string }, uploadOptions?: CsvFormDataUploadOptions ) => { const formData = new FormData(); formData.append('file', file); const search = new URLSearchParams({ company_id: String(companyId) }); if (params?.actualizar !== undefined) search.set('actualizar', String(!!params.actualizar)); if (params?.dateFormat != null) search.set('dateFormat', params.dateFormat); return fetchApiFormDataPost( `/v1/a76/pedimentos/imports/upload?${search.toString()}`, formData, uploadOptions ); }, status: (jobId: string) => api.get(`/v1/a76/pedimentos/imports/${jobId}/status`), commit: (jobId: string) => api.post(`/v1/a76/pedimentos/imports/${jobId}/commit`, {}), downloadScanErrorsCsv: (jobId: string) => fetchBlob(`/v1/a76/pedimentos/imports/${jobId}/errors/scan-csv`) }, // CSV import for Clases de Materiales (classes/imports) materialClassImports: { upload: ( file: File, companyId: number, params?: { actualizar?: boolean; siempre_toda?: boolean }, uploadOptions?: CsvFormDataUploadOptions ) => { const formData = new FormData(); formData.append('file', file); const search = new URLSearchParams({ company_id: String(companyId) }); if (params?.actualizar !== undefined) search.set('actualizar', String(!!params.actualizar)); if (params?.siempre_toda !== undefined) search.set('siempre_toda', String(!!params.siempre_toda)); return fetchApiFormDataPost( `/v1/a76/classes/imports/upload?${search.toString()}`, formData, uploadOptions ); }, status: (jobId: string) => api.get(`/v1/a76/classes/imports/${jobId}/status`), commit: (jobId: string) => api.post(`/v1/a76/classes/imports/${jobId}/commit`, {}), downloadScanErrorsCsv: (jobId: string) => fetchBlob(`/v1/a76/classes/imports/${jobId}/errors/scan-csv`) }, // CSV import for Vehículos / Transportes (transportation/vehicles/imports) vehicleImports: { upload: ( file: File, companyId: number, options?: { actualizar?: boolean; onUploadProgress?: CsvFormDataUploadOptions['onUploadProgress'] } ) => { const formData = new FormData(); formData.append('file', file); const params = new URLSearchParams({ company_id: String(companyId) }); if (options?.actualizar !== undefined) params.set('actualizar', String(options.actualizar)); const uploadOpts = options?.onUploadProgress != null ? { onUploadProgress: options.onUploadProgress } : undefined; return fetchApiFormDataPost( `/v1/a76/transportation/vehicles/imports/upload?${params.toString()}`, formData, uploadOpts ); }, status: (jobId: string) => api.get(`/v1/a76/transportation/vehicles/imports/${jobId}/status`), commit: (jobId: string) => api.post(`/v1/a76/transportation/vehicles/imports/${jobId}/commit`, {}), downloadScanErrorsCsv: (jobId: string) => fetchBlob(`/v1/a76/transportation/vehicles/imports/${jobId}/errors/scan-csv`) }, // CSV import for Conductores (drivers/imports) driverImports: { upload: (file: File, companyId: number, uploadOptions?: CsvFormDataUploadOptions) => { const formData = new FormData(); formData.append('file', file); return fetchApiFormDataPost( `/v1/a76/drivers/imports/upload?company_id=${companyId}`, formData, uploadOptions ); }, status: (jobId: string) => api.get(`/v1/a76/drivers/imports/${jobId}/status`), commit: (jobId: string) => api.post(`/v1/a76/drivers/imports/${jobId}/commit`, {}), downloadScanErrorsCsv: (jobId: string) => fetchBlob(`/v1/a76/drivers/imports/${jobId}/errors/scan-csv`) }, // CSV import for Trailers y Cajas (transportation/trailers/imports) trailerImports: { upload: ( file: File, companyId: number, params?: { actualizar?: boolean; onUploadProgress?: CsvFormDataUploadOptions['onUploadProgress'] } ) => { const formData = new FormData(); formData.append('file', file); const actualizar = params?.actualizar ?? false; const uploadOpts = params?.onUploadProgress != null ? { onUploadProgress: params.onUploadProgress } : undefined; return fetchApiFormDataPost( `/v1/a76/transportation/trailers/imports/upload?company_id=${companyId}&actualizar=${actualizar}`, formData, uploadOpts ); }, status: (jobId: string) => api.get(`/v1/a76/transportation/trailers/imports/${jobId}/status`), commit: (jobId: string) => api.post(`/v1/a76/transportation/trailers/imports/${jobId}/commit`, {}), downloadScanErrorsCsv: (jobId: string) => fetchBlob(`/v1/a76/transportation/trailers/imports/${jobId}/errors/scan-csv`) }, // CSV import for Transportistas (transporters/imports) transporterImports: { upload: ( file: File, companyId: number, params?: { actualizar?: boolean; onUploadProgress?: CsvFormDataUploadOptions['onUploadProgress'] } ) => { const formData = new FormData(); formData.append('file', file); const actualizar = params?.actualizar ?? false; const uploadOpts = params?.onUploadProgress != null ? { onUploadProgress: params.onUploadProgress } : undefined; return fetchApiFormDataPost( `/v1/a76/transporters/imports/upload?company_id=${companyId}&actualizar=${actualizar}`, formData, uploadOpts ); }, status: (jobId: string) => api.get(`/v1/a76/transporters/imports/${jobId}/status`), commit: (jobId: string) => api.post(`/v1/a76/transporters/imports/${jobId}/commit`, {}), downloadScanErrorsCsv: (jobId: string) => fetchBlob(`/v1/a76/transporters/imports/${jobId}/errors/scan-csv`) }, // CSV import for Números de parte (parts/imports) partNumberImports: { upload: ( file: File, companyId: number, options?: { actualizar?: boolean; reemplazar_sin_preguntar?: boolean; onUploadProgress?: CsvFormDataUploadOptions['onUploadProgress']; } ) => { const formData = new FormData(); formData.append('file', file); const params = new URLSearchParams({ company_id: String(companyId) }); if (options?.actualizar !== undefined) params.set('actualizar', String(options.actualizar)); if (options?.reemplazar_sin_preguntar !== undefined) params.set('reemplazar_sin_preguntar', String(options.reemplazar_sin_preguntar)); const uploadOpts = options?.onUploadProgress != null ? { onUploadProgress: options.onUploadProgress } : undefined; return fetchApiFormDataPost( `/v1/a76/parts/imports/upload?${params.toString()}`, formData, uploadOpts ); }, status: (jobId: string) => api.get(`/v1/a76/parts/imports/${jobId}/status`), commit: (jobId: string) => api.post(`/v1/a76/parts/imports/${jobId}/commit`, {}), downloadScanErrorsCsv: (jobId: string) => fetchBlob(`/v1/a76/parts/imports/${jobId}/errors/scan-csv`) }, // CSV import for BOMs (boms/imports) bomImports: { upload: (file: File, companyId: number, uploadOptions?: CsvFormDataUploadOptions) => { const formData = new FormData(); formData.append('file', file); return fetchApiFormDataPost( `/v1/a76/boms/imports/upload?company_id=${companyId}`, formData, uploadOptions ); }, status: (jobId: string) => api.get(`/v1/a76/boms/imports/${jobId}/status`), commit: (jobId: string) => api.post(`/v1/a76/boms/imports/${jobId}/commit`, {}), downloadScanErrorsCsv: (jobId: string) => fetchBlob(`/v1/a76/boms/imports/${jobId}/errors/scan-csv`) }, // Generic request for custom needs (like file uploads) request: (endpoint: string, options: RequestInit = {}) => fetchApi(endpoint, options) };