Files
plantillas-proyectos/frontend/src/lib/api.ts

1079 lines
34 KiB
TypeScript

/**
* 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<T = any> {
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<string | null> {
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<T = any>(
endpoint: string,
options: RequestInit = {},
retryCount = 0
): Promise<ApiResponse<T>> {
// Si ya estamos refrescando el token, esperar
if (isRefreshing && retryCount === 0) {
return new Promise((resolve) => {
subscribeTokenRefresh((newToken) => {
resolve(fetchApi<T>(endpoint, options, 1));
});
});
}
const token = getToken();
if (!token && !endpoint.includes('/auth/login')) {
console.warn('⚠️ [API] No hay token disponible para', endpoint);
}
const headers: Record<string, string> = {
...((options.headers as Record<string, string>) || {})
};
// 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<T>(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<ApiResponse['validationErrors']>;
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<T = any>(
endpoint: string,
formData: FormData,
opts: CsvFormDataUploadOptions & { retryCount?: number } = {}
): Promise<ApiResponse<T>> {
const retryCount = opts.retryCount ?? 0;
if (isRefreshing && retryCount === 0) {
return new Promise((resolve) => {
subscribeTokenRefresh(() => {
resolve(fetchApiFormDataPost<T>(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<T>(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<ApiResponse['validationErrors']>,
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<string, unknown>;
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<Blob> {
const token = getToken();
const headers: Record<string, string> = {
...((options.headers as Record<string, string>) || {})
};
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: <T = any>(endpoint: string) => fetchApi<T>(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: <T = any>(endpoint: string, body: any, options: RequestInit = {}) =>
fetchApi<T>(endpoint, {
method: 'POST',
body: JSON.stringify(body),
...options
}),
put: <T = any>(endpoint: string, body: any, options: RequestInit = {}) =>
fetchApi<T>(endpoint, {
method: 'PUT',
body: JSON.stringify(body),
...options
}),
patch: <T = any>(endpoint: string, body: any, options: RequestInit = {}) =>
fetchApi<T>(endpoint, {
method: 'PATCH',
body: JSON.stringify(body),
...options
}),
delete: <T = any>(endpoint: string, options: RequestInit = {}) => fetchApi<T>(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<string, string> = {};
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: <T = any>(endpoint: string, options: RequestInit = {}) => fetchApi<T>(endpoint, options)
};