Merge pull request 'feature/table-celery-tasks' (#257) from feature/table-celery-tasks into development

Reviewed-on: ADUANASOFT/anexo76#257
This commit is contained in:
2026-03-25 14:06:24 +00:00
30 changed files with 1718 additions and 139 deletions

View File

@@ -167,6 +167,7 @@ async def commit_import_job(
db=db,
current_user=current_user,
redis_client=r,
layout_job_id=job_id,
meta_redis_key=f"{BOM_IMPORT_META_PREFIX}{job_id}",
celery_task=insert_valid_rows,
task_name="boms_insert_valid_rows",

View File

@@ -176,6 +176,7 @@ async def commit_import_job(
db=db,
current_user=current_user,
redis_client=r,
layout_job_id=job_id,
meta_redis_key=meta_key,
celery_task=insert_valid_rows,
task_name="cambio_regimen_regularizacion_insert_valid_rows",

View File

@@ -194,6 +194,7 @@ async def commit_import_job(
db=db,
current_user=current_user,
redis_client=r,
layout_job_id=job_id,
meta_redis_key=f"{CLS_IMPORT_META_PREFIX}{job_id}",
celery_task=insert_valid_rows,
task_name="classes_insert_valid_rows",

View File

@@ -179,6 +179,7 @@ async def commit_import_job(
db=db,
current_user=current_user,
redis_client=r,
layout_job_id=job_id,
meta_redis_key=f"{CP_IMPORT_META_PREFIX}{job_id}",
celery_task=insert_valid_rows,
task_name="clients_and_providers_insert_valid_rows",

View File

@@ -0,0 +1,69 @@
"""
Claves Redis por job de import CSV (layouts_csv): las de storage_keys más extras por módulo.
Usado por la limpieza periódica (victor "el senior de limpieza") para saber si un job sigue vivo en Redis.
"""
from __future__ import annotations
from typing import Any, List
from . import storage as common_storage
# Prefijo en nombre de archivo `{prefix}_{uuid}.csv` -> job_type (vacío = solo `{uuid}.csv`)
FILE_PREFIX_TO_JOB_TYPE: dict[str, str] = {
"veh": "veh",
"fa": "fa",
"trl": "trl",
"trp": "trp",
"ped": "ped",
"part": "part",
"er": "er",
"cb": "cb",
"drv": "drv",
"cls": "cls",
"cp": "cp",
"bom": "bom",
"exp": "exp",
"crreg": "crreg",
}
def all_redis_keys_for_layout_import(job_type: str, job_id: str) -> List[str]:
"""Todas las claves Redis que pueden existir para un job (file, meta, error_lines, extras)."""
file_k, meta_k, err_k = common_storage.storage_keys(job_type, job_id)
keys: List[str] = [file_k, meta_k, err_k]
if job_type == "veh":
keys.append(f"veh_import_status:{job_id}")
elif job_type == "trl":
keys.append(f"trl_import_status:{job_id}")
elif job_type == "trp":
keys.append(f"trp_import_status:{job_id}")
elif job_type == "drv":
keys.append(f"drv_import_status:{job_id}")
keys.append(f"drv_import_transporter_map:{job_id}")
return keys
def any_layout_import_key_exists(redis_client: Any, job_type: str, job_id: str) -> bool:
for k in all_redis_keys_for_layout_import(job_type, job_id):
if redis_client.exists(k):
return True
return False
def delete_extra_layout_import_keys_from_redis(redis_client: Any, job_type: str, job_id: str) -> None:
"""Borra claves que delete_import_from_redis no cubre (status, mapas)."""
extras: list[str] = []
if job_type == "veh":
extras.append(f"veh_import_status:{job_id}")
elif job_type == "trl":
extras.append(f"trl_import_status:{job_id}")
elif job_type == "trp":
extras.append(f"trp_import_status:{job_id}")
elif job_type == "drv":
extras.append(f"drv_import_status:{job_id}")
extras.append(f"drv_import_transporter_map:{job_id}")
if extras:
try:
redis_client.delete(*extras)
except Exception:
pass

View File

@@ -18,6 +18,7 @@ def dispatch_tracked_layouts_csv_commit(
db: Session,
current_user: dict[str, Any],
redis_client: Any,
layout_job_id: str,
meta_redis_key: str,
celery_task: Task,
task_name: str,
@@ -54,5 +55,6 @@ def dispatch_tracked_layouts_csv_commit(
task_origin=task_origin,
args=args,
task_id=commit_id,
meta_payload={"layout_import_job_id": layout_job_id},
)
return commit_id

View File

@@ -0,0 +1,172 @@
"""
Limpieza periódica de archivos huérfanos de import CSV (layouts/imports/temp y errors).
"""
from __future__ import annotations
import logging
import os
import re
import time
from typing import Optional, Tuple
from sqlalchemy import or_
from api.v1.modules.core.tasks_tracking.models import TaskRun, TaskStatus
from core.celery_app import celery_app
from core.database import CoreSessionLocal
from . import storage as common_storage
from .import_redis_keys import (
FILE_PREFIX_TO_JOB_TYPE,
any_layout_import_key_exists,
delete_extra_layout_import_keys_from_redis,
)
logger = logging.getLogger(__name__)
# Evita carreras con uploads/commits recién creados (segundos)
MIN_ORPHAN_FILE_AGE_SEC = int(os.getenv("LAYOUT_ORPHAN_MIN_AGE_SEC", "600"))
_UUID_RE = r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
def _get_redis():
import redis
url = os.getenv("VALKEY_URL", os.getenv("REDIS_URL", "redis://valkey:6379/0"))
return redis.Redis.from_url(url, decode_responses=False)
def parse_temp_csv_filename(name: str) -> Optional[Tuple[str, str]]:
"""Devuelve (job_type, job_id) o None si no es un CSV de import conocido."""
if not name.endswith(".csv"):
return None
base = name[:-4]
m = re.match(rf"^({_UUID_RE})$", base, re.I)
if m:
return "", m.group(1)
m = re.match(rf"^([a-z0-9]+)_({_UUID_RE})$", base, re.I)
if not m:
return None
prefix, jid = m.group(1).lower(), m.group(2)
jt = FILE_PREFIX_TO_JOB_TYPE.get(prefix)
if jt is None:
return None
return jt, jid
def parse_errors_jsonl_filename(name: str) -> Optional[Tuple[str, str]]:
if not name.endswith(".jsonl"):
return None
base = name[:-6]
m = re.match(rf"^({_UUID_RE})$", base, re.I)
if m:
return "", m.group(1)
m = re.match(rf"^([a-z0-9]+)_({_UUID_RE})$", base, re.I)
if not m:
return None
prefix, jid = m.group(1).lower(), m.group(2)
jt = FILE_PREFIX_TO_JOB_TYPE.get(prefix)
if jt is None:
return None
return jt, jid
def _layout_import_protected_by_task_run(db, job_id: str) -> bool:
row = (
db.query(TaskRun)
.filter(
TaskRun.task_group == "layouts_csv",
TaskRun.status.in_([TaskStatus.PENDING.value, TaskStatus.ACTIVE.value]),
or_(
TaskRun.task_id == job_id,
TaskRun.meta_payload.contains({"layout_import_job_id": job_id}),
),
)
.first()
)
return row is not None
def _reference_mtime(job_type: str, job_id: str) -> Optional[float]:
csv_path = common_storage.file_path_for_job(job_type, job_id)
err_path = common_storage.error_path_for_job(job_type, job_id)
mtimes = []
if os.path.isfile(csv_path):
mtimes.append(os.path.getmtime(csv_path))
if os.path.isfile(err_path):
mtimes.append(os.path.getmtime(err_path))
if not mtimes:
return None
return max(mtimes)
def _try_cleanup_layout_import_job(r, db, job_type: str, job_id: str) -> bool:
if any_layout_import_key_exists(r, job_type, job_id):
return False
if _layout_import_protected_by_task_run(db, job_id):
return False
mtime = _reference_mtime(job_type, job_id)
if mtime is None:
return False
if time.time() - mtime < MIN_ORPHAN_FILE_AGE_SEC:
return False
file_path = common_storage.file_path_for_job(job_type, job_id)
error_path = common_storage.error_path_for_job(job_type, job_id)
meta_path = None
if os.path.isfile(file_path):
meta_path = file_path.replace(".csv", ".meta.json")
common_storage.cleanup_import_job(
job_type,
job_id,
file_path=file_path if os.path.isfile(file_path) else None,
error_path=error_path if os.path.isfile(error_path) else None,
meta_path=meta_path if meta_path and os.path.isfile(meta_path) else None,
)
delete_extra_layout_import_keys_from_redis(r, job_type, job_id)
return True
def run_orphan_layout_import_cleanup() -> dict:
"""Barrido síncrono; devuelve contadores para logs/resultado Celery."""
removed_jobs: list[str] = []
r = _get_redis()
db = CoreSessionLocal()
try:
temp_dir = common_storage.upload_dir()
os.makedirs(temp_dir, exist_ok=True)
for name in os.listdir(temp_dir):
parsed = parse_temp_csv_filename(name)
if not parsed:
continue
job_type, job_id = parsed
if _try_cleanup_layout_import_job(r, db, job_type, job_id):
removed_jobs.append(f"{job_type or 'invoice'}:{job_id}")
err_dir = common_storage.error_dir()
if os.path.isdir(err_dir):
for name in os.listdir(err_dir):
parsed = parse_errors_jsonl_filename(name)
if not parsed:
continue
job_type, job_id = parsed
csv_path = common_storage.file_path_for_job(job_type, job_id)
if os.path.isfile(csv_path):
continue
if _try_cleanup_layout_import_job(r, db, job_type, job_id):
rid = f"{job_type or 'invoice'}:{job_id}:errors_only"
if rid not in removed_jobs:
removed_jobs.append(rid)
finally:
db.close()
if removed_jobs:
logger.info("layout import cleanup removed %s job(s): %s", len(removed_jobs), removed_jobs[:20])
return {"removed_count": len(removed_jobs), "removed": removed_jobs}
@celery_app.task(name="cleanup_orphan_layout_imports")
def cleanup_orphan_layout_imports():
return run_orphan_layout_import_cleanup()

View File

@@ -179,6 +179,7 @@ async def commit_import_job(
db=db,
current_user=current_user,
redis_client=r,
layout_job_id=job_id,
meta_redis_key=f"{CB_IMPORT_META_PREFIX}{job_id}",
celery_task=insert_valid_rows,
task_name="customs_brokers_insert_valid_rows",

View File

@@ -189,6 +189,7 @@ async def commit_import_job(
db=db,
current_user=current_user,
redis_client=r,
layout_job_id=job_id,
meta_redis_key=f"{ER_IMPORT_META_PREFIX}{job_id}",
celery_task=insert_valid_rows,
task_name="exchange_rate_insert_valid_rows",

View File

@@ -181,6 +181,7 @@ async def commit_import_job(
db=db,
current_user=current_user,
redis_client=r,
layout_job_id=job_id,
meta_redis_key=meta_key,
celery_task=insert_valid_rows,
task_name="exportacion_insert_valid_rows",

View File

@@ -256,6 +256,7 @@ async def commit_import_job(
db=db,
current_user=current_user,
redis_client=r,
layout_job_id=job_id,
meta_redis_key=f"{IMPORT_META_KEY_PREFIX}{job_id}",
celery_task=insert_valid_rows,
task_name="facturas_insert_valid_rows",

View File

@@ -192,6 +192,7 @@ async def commit_import_job(
db=db,
current_user=current_user,
redis_client=r,
layout_job_id=job_id,
meta_redis_key=f"{PART_IMPORT_META_PREFIX}{job_id}",
celery_task=insert_valid_rows,
task_name="parts_insert_valid_rows",

View File

@@ -187,6 +187,7 @@ async def commit_import_job(
db=db,
current_user=current_user,
redis_client=r,
layout_job_id=job_id,
meta_redis_key=meta_key,
celery_task=insert_valid_rows,
task_name="pedmientos_insert_valid_rows",

View File

@@ -181,6 +181,7 @@ async def commit_import_job(
db=db,
current_user=current_user,
redis_client=r,
layout_job_id=job_id,
meta_redis_key=f"{FA_IMPORT_META_PREFIX}{job_id}",
celery_task=insert_valid_rows,
task_name="us_tariff_fractions_insert_valid_rows",

View File

@@ -37,7 +37,6 @@ def _map_row(row: TaskRun) -> TaskRunListItem:
else None
),
tenant_id=row.tenant_id,
company_id=row.company_id,
requested_by_user=row.requested_by_user,
started_at=row.started_at,
finished_at=row.finished_at,

View File

@@ -27,7 +27,6 @@ class TaskRunListItem(BaseModel):
retries: int | None = None
error: TaskError | None = None
tenant_id: int
company_id: int | None = None
requested_by_user: str | None = None
started_at: datetime | None = None
finished_at: datetime | None = None

View File

@@ -66,6 +66,7 @@ celery_app.conf.update(
"api.v1.modules.a76.invoices.imports.revert.task",
"api.v1.modules.a76.invoices.exports.process.task",
"api.v1.modules.a76.invoices.exports.revert.task",
"api.v1.modules.a76.layouts_csv.common.victor",
] # Ruta al módulo donde están las tareas
)
@@ -84,6 +85,10 @@ celery_app.conf.beat_schedule = {
"task": "sync_from_hub_task",
"schedule": 60.0, # Run every 60 seconds
},
"cleanup-orphan-layout-imports-hourly": {
"task": "cleanup_orphan_layout_imports",
"schedule": 3600.0,
},
}
if __name__ == "__main__":

View File

@@ -187,6 +187,7 @@ services:
condition: service_healthy
volumes:
- backend_uploads:/app/uploads
- backend_layouts:/app/layouts
- ./scripts/backend-entrypoint.sh:/entrypoint.sh:ro
networks:
- backend-net
@@ -233,9 +234,37 @@ services:
depends_on:
- backend
- valkey
volumes:
- backend_layouts:/app/layouts
networks:
- backend-net
celery_beat:
image: dev.aduanasoft.com/anexo76/backend:latest
container_name: celery_beat
command: celery -A core.celery_app beat --loglevel=info
environment:
- VALKEY_URL=${VALKEY_URL:-redis://valkey:6379/0}
- CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""}
- SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production}
- SPOKE_URLS=${SPOKE_URLS:-""}
- CORE_DB_HOST=${CORE_DB_HOST:-postgres-a76}
- CORE_DB_PORT=${CORE_DB_PORT:-5432}
- CORE_DB_NAME=${CORE_DB_NAME:-anexo76_core}
- CORE_DB_USER=${CORE_DB_USER:-postgres}
- CORE_DB_PASSWORD=${POSTGRES_APP_PASSWORD:-postgres}
- SITAR_API_URL=${SITAR_API_URL}
- SITAR_API_USER=${SITAR_API_USER}
- SITAR_API_PASSWORD=${SITAR_API_PASSWORD}
depends_on:
- backend
- valkey
volumes:
- backend_layouts:/app/layouts
networks:
- backend-net
restart: unless-stopped
valkey:
image: valkey/valkey:7.2
container_name: valkey
@@ -305,6 +334,8 @@ volumes:
driver: local
backend_uploads:
driver: local
backend_layouts:
driver: local
networks:
backend-net:

View File

@@ -243,6 +243,193 @@ async function fetchApi<T = any>(
}
}
/** 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,
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.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);
}
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
});
}
});
}
async function fetchBlob(endpoint: string, options: RequestInit = {}): Promise<Blob> {
const token = getToken();
const headers: Record<string, string> = {
@@ -357,7 +544,8 @@ export const api = {
footerConfig: any,
companyId: number,
operationType: string,
templateId?: string
templateId?: string,
uploadOptions?: CsvFormDataUploadOptions
) => {
const formData = new FormData();
formData.append('file', file);
@@ -373,10 +561,11 @@ export const api = {
operation_type: operationType || 'imp'
}).toString();
return fetchApi(`/v1/a76/imports/upload/${modelTarget}?${queryParams}`, {
method: 'POST',
body: formData
});
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) =>
@@ -393,7 +582,8 @@ export const api = {
modelTarget: string,
footerConfig: any,
companyId: number,
templateId?: string
templateId?: string,
uploadOptions?: CsvFormDataUploadOptions
) => {
const formData = new FormData();
formData.append('file', file);
@@ -409,10 +599,11 @@ export const api = {
operation_type: 'exp'
}).toString();
return fetchApi(`/v1/a76/imports/exportacion/upload/${modelTarget}?${queryParams}`, {
method: 'POST',
body: formData
});
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) =>
@@ -423,12 +614,13 @@ export const api = {
// CSV import for Agentes Aduanales (flujo propio en customs_brokers/imports)
customsBrokerImports: {
upload: (file: File, companyId: number) => {
upload: (file: File, companyId: number, uploadOptions?: CsvFormDataUploadOptions) => {
const formData = new FormData();
formData.append('file', file);
return fetchApi(
return fetchApiFormDataPost(
`/v1/a76/customs-brokers/imports/upload?company_id=${companyId}`,
{ method: 'POST', body: formData }
formData,
uploadOptions
);
},
status: (jobId: string) => api.get(`/v1/a76/customs-brokers/imports/${jobId}/status`),
@@ -440,12 +632,13 @@ export const api = {
// CSV import for Clientes y Proveedores (flujo propio en clients_and_providers/imports)
clientProviderImports: {
upload: (file: File, companyId: number) => {
upload: (file: File, companyId: number, uploadOptions?: CsvFormDataUploadOptions) => {
const formData = new FormData();
formData.append('file', file);
return fetchApi(
return fetchApiFormDataPost(
`/v1/a76/clients-providers/imports/upload?company_id=${companyId}`,
{ method: 'POST', body: formData }
formData,
uploadOptions
);
},
status: (jobId: string) => api.get(`/v1/a76/clients-providers/imports/${jobId}/status`),
@@ -460,7 +653,8 @@ export const api = {
upload: (
file: File,
companyId: number,
params?: { reemplazar_sin_preguntar?: boolean; date_format?: string }
params?: { reemplazar_sin_preguntar?: boolean; date_format?: string },
uploadOptions?: CsvFormDataUploadOptions
) => {
const formData = new FormData();
formData.append('file', file);
@@ -469,9 +663,10 @@ export const api = {
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 fetchApi(
return fetchApiFormDataPost(
`/v1/a76/exchange-rate/imports/upload?${search.toString()}`,
{ method: 'POST', body: formData }
formData,
uploadOptions
);
},
status: (jobId: string) => api.get(`/v1/a76/exchange-rate/imports/${jobId}/status`),
@@ -483,12 +678,13 @@ export const api = {
// CSV import for Fracción Americana (us_tariff_fractions/imports)
americanFractionImports: {
upload: (file: File, companyId: number) => {
upload: (file: File, companyId: number, uploadOptions?: CsvFormDataUploadOptions) => {
const formData = new FormData();
formData.append('file', file);
return fetchApi(
return fetchApiFormDataPost(
`/v1/a76/us-tariff-fractions/imports/upload?company_id=${companyId}`,
{ method: 'POST', body: formData }
formData,
uploadOptions
);
},
status: (jobId: string) => api.get(`/v1/a76/us-tariff-fractions/imports/${jobId}/status`),
@@ -503,16 +699,18 @@ export const api = {
upload: (
file: File,
companyId: number,
params?: { actualizar?: boolean; dateFormat?: string }
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 fetchApi(
return fetchApiFormDataPost(
`/v1/a76/pedimentos/imports/upload?${search.toString()}`,
{ method: 'POST', body: formData }
formData,
uploadOptions
);
},
status: (jobId: string) => api.get(`/v1/a76/pedimentos/imports/${jobId}/status`),
@@ -527,16 +725,18 @@ export const api = {
upload: (
file: File,
companyId: number,
params?: { actualizar?: boolean; siempre_toda?: boolean }
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 fetchApi(
return fetchApiFormDataPost(
`/v1/a76/classes/imports/upload?${search.toString()}`,
{ method: 'POST', body: formData }
formData,
uploadOptions
);
},
status: (jobId: string) => api.get(`/v1/a76/classes/imports/${jobId}/status`),
@@ -548,14 +748,21 @@ export const api = {
// CSV import for Vehículos / Transportes (transportation/vehicles/imports)
vehicleImports: {
upload: (file: File, companyId: number, options?: { actualizar?: boolean }) => {
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));
return fetchApi(
const uploadOpts =
options?.onUploadProgress != null ? { onUploadProgress: options.onUploadProgress } : undefined;
return fetchApiFormDataPost(
`/v1/a76/transportation/vehicles/imports/upload?${params.toString()}`,
{ method: 'POST', body: formData }
formData,
uploadOpts
);
},
status: (jobId: string) => api.get(`/v1/a76/transportation/vehicles/imports/${jobId}/status`),
@@ -567,12 +774,13 @@ export const api = {
// CSV import for Conductores (drivers/imports)
driverImports: {
upload: (file: File, companyId: number) => {
upload: (file: File, companyId: number, uploadOptions?: CsvFormDataUploadOptions) => {
const formData = new FormData();
formData.append('file', file);
return fetchApi(
return fetchApiFormDataPost(
`/v1/a76/drivers/imports/upload?company_id=${companyId}`,
{ method: 'POST', body: formData }
formData,
uploadOptions
);
},
status: (jobId: string) => api.get(`/v1/a76/drivers/imports/${jobId}/status`),
@@ -583,13 +791,20 @@ export const api = {
// CSV import for Trailers y Cajas (transportation/trailers/imports)
trailerImports: {
upload: (file: File, companyId: number, params?: { actualizar?: boolean }) => {
upload: (
file: File,
companyId: number,
params?: { actualizar?: boolean; onUploadProgress?: CsvFormDataUploadOptions['onUploadProgress'] }
) => {
const formData = new FormData();
formData.append('file', file);
const actualizar = params?.actualizar ?? false;
return fetchApi(
const uploadOpts =
params?.onUploadProgress != null ? { onUploadProgress: params.onUploadProgress } : undefined;
return fetchApiFormDataPost(
`/v1/a76/transportation/trailers/imports/upload?company_id=${companyId}&actualizar=${actualizar}`,
{ method: 'POST', body: formData }
formData,
uploadOpts
);
},
status: (jobId: string) => api.get(`/v1/a76/transportation/trailers/imports/${jobId}/status`),
@@ -601,13 +816,20 @@ export const api = {
// CSV import for Transportistas (transporters/imports)
transporterImports: {
upload: (file: File, companyId: number, params?: { actualizar?: boolean }) => {
upload: (
file: File,
companyId: number,
params?: { actualizar?: boolean; onUploadProgress?: CsvFormDataUploadOptions['onUploadProgress'] }
) => {
const formData = new FormData();
formData.append('file', file);
const actualizar = params?.actualizar ?? false;
return fetchApi(
const uploadOpts =
params?.onUploadProgress != null ? { onUploadProgress: params.onUploadProgress } : undefined;
return fetchApiFormDataPost(
`/v1/a76/transporters/imports/upload?company_id=${companyId}&actualizar=${actualizar}`,
{ method: 'POST', body: formData }
formData,
uploadOpts
);
},
status: (jobId: string) => api.get(`/v1/a76/transporters/imports/${jobId}/status`),
@@ -618,15 +840,27 @@ export const api = {
// CSV import for Números de parte (parts/imports)
partNumberImports: {
upload: (file: File, companyId: number, options?: { actualizar?: boolean; reemplazar_sin_preguntar?: boolean }) => {
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));
return fetchApi(
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()}`,
{ method: 'POST', body: formData }
formData,
uploadOpts
);
},
status: (jobId: string) => api.get(`/v1/a76/parts/imports/${jobId}/status`),
@@ -637,12 +871,13 @@ export const api = {
// CSV import for BOMs (boms/imports)
bomImports: {
upload: (file: File, companyId: number) => {
upload: (file: File, companyId: number, uploadOptions?: CsvFormDataUploadOptions) => {
const formData = new FormData();
formData.append('file', file);
return fetchApi(
return fetchApiFormDataPost(
`/v1/a76/boms/imports/upload?company_id=${companyId}`,
{ method: 'POST', body: formData }
formData,
uploadOptions
);
},
status: (jobId: string) => api.get(`/v1/a76/boms/imports/${jobId}/status`),

View File

@@ -21,7 +21,6 @@ export interface UnifiedTask {
message?: string | null;
} | null;
tenant_id: number;
company_id?: number | null;
requested_by_user?: string | null;
started_at?: string | null;
finished_at?: string | null;

View File

@@ -0,0 +1,215 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import * as Sheet from '$lib/components/ui/sheet/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import {
CSV_IMPORT_PENDING_CHANGED,
CSV_IMPORT_PENDING_KEY,
type CsvImportPendingEntry,
countCsvImportPendingForCompany,
listCsvImportPendingForCompany,
removeCsvImportPending,
updateCsvImportPendingSnapshot
} from '$lib/csv-import-pending';
import { fetchCsvImportStatus, isWaitingConfirmationPayload } from '$lib/csv-import-status-api';
import { Loader2, RefreshCw } from 'lucide-svelte';
type ValidatedRow = CsvImportPendingEntry & { checking?: boolean };
let {
open = $bindable(false),
companyId,
onResume
}: {
open?: boolean;
companyId: number | undefined;
onResume: (entry: CsvImportPendingEntry, scanPayload: Record<string, unknown>) => void;
} = $props();
let rows = $state<ValidatedRow[]>([]);
let refreshing = $state(false);
let badgeCount = $state(0);
function syncBadge() {
badgeCount = companyId !== undefined ? countCsvImportPendingForCompany(companyId) : 0;
}
function profileLabel(p: CsvImportPendingEntry['profile']): string {
const map: Record<CsvImportPendingEntry['profile'], string> = {
customs_brokers: 'Agentes aduanales',
clients_providers: 'Clientes / proveedores',
exchange_rates: 'Tipos de cambio',
american_fractions: 'Fracciones arancelarias US',
pedimentos: 'Pedimentos',
material_classes: 'Clases de material',
vehicles: 'Vehículos',
drivers: 'Conductores',
trailers: 'Remolques',
transporters: 'Transportistas',
part_numbers: 'Números de parte',
boms: 'BOMs',
exportacion: 'Exportación (operaciones)',
imports: 'Importación (operaciones)'
};
return map[p] ?? p;
}
function isStaleJob(status: number, err: string | undefined): boolean {
if (status === 404) return true;
const m = (err || '').toLowerCase();
return /not found|no encontrado|404|expir|no disponible|invalid|inexistente/i.test(m);
}
async function validateAndLoad() {
if (companyId === undefined) {
rows = [];
return;
}
refreshing = true;
const raw = listCsvImportPendingForCompany(companyId);
const next: ValidatedRow[] = [];
for (const entry of raw) {
const res = await fetchCsvImportStatus(entry.jobId, entry.profile);
if (res.error && !res.data) {
if (isStaleJob(res.status, res.error)) {
removeCsvImportPending(entry.jobId);
continue;
}
next.push({ ...entry, checking: false });
continue;
}
if (res.data && isWaitingConfirmationPayload(res.data)) {
const d = res.data as Record<string, unknown>;
const tr = typeof d.total_rows === 'number' ? d.total_rows : undefined;
const vr = typeof d.valid_rows === 'number' ? d.valid_rows : undefined;
if (tr !== undefined || vr !== undefined) {
updateCsvImportPendingSnapshot(entry.jobId, { totalRows: tr, validRows: vr });
}
const updated = { ...entry, totalRows: tr ?? entry.totalRows, validRows: vr ?? entry.validRows };
next.push(updated);
} else {
removeCsvImportPending(entry.jobId);
}
}
rows = next;
refreshing = false;
syncBadge();
}
$effect(() => {
companyId;
syncBadge();
});
$effect(() => {
if (!browser || !open || companyId === undefined) return;
void validateAndLoad();
});
onMount(() => {
if (!browser) return;
const onPending = () => {
syncBadge();
if (open && companyId !== undefined) void validateAndLoad();
};
const onStorage = (e: StorageEvent) => {
if (e.key === CSV_IMPORT_PENDING_KEY) {
syncBadge();
if (open && companyId !== undefined) void validateAndLoad();
}
};
window.addEventListener(CSV_IMPORT_PENDING_CHANGED, onPending);
window.addEventListener('storage', onStorage);
return () => {
window.removeEventListener(CSV_IMPORT_PENDING_CHANGED, onPending);
window.removeEventListener('storage', onStorage);
};
});
function removeLocal(jobId: string) {
removeCsvImportPending(jobId);
rows = rows.filter((r) => r.jobId !== jobId);
syncBadge();
}
</script>
<Button variant="outline" size="sm" class="shrink-0" type="button" onclick={() => (open = true)}>
Pendientes
{#if badgeCount > 0}
<span class="ml-1.5 rounded-full bg-primary/15 px-2 py-0.5 text-xs font-semibold text-primary">
{badgeCount}
</span>
{/if}
</Button>
<Sheet.Root bind:open>
<Sheet.Content side="right" class="flex w-full max-w-lg flex-col sm:max-w-xl">
<Sheet.Header>
<Sheet.Title>Importaciones pendientes de confirmar</Sheet.Title>
<Sheet.Description>
Escaneos listos para insertar en base de datos. Si el trabajo ya expiró en el servidor, desaparecerán al
actualizar.
</Sheet.Description>
</Sheet.Header>
<div class="flex items-center justify-end gap-2 border-b px-4 py-2">
<Button variant="outline" size="sm" disabled={refreshing} onclick={() => void validateAndLoad()}>
{#if refreshing}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{:else}
<RefreshCw class="mr-2 h-4 w-4" />
{/if}
Actualizar
</Button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{#if rows.length === 0 && !refreshing}
<p class="text-sm text-muted-foreground">No hay importaciones pendientes para esta empresa.</p>
{:else if rows.length === 0 && refreshing}
<p class="text-sm text-muted-foreground">Comprobando con el servidor…</p>
{:else}
<ul class="space-y-3">
{#each rows as row (row.jobId)}
<li class="rounded-lg border border-border bg-card p-3 text-sm shadow-sm">
<div class="font-medium text-foreground">
{row.label || profileLabel(row.profile)}
</div>
<div class="mt-0.5 text-xs text-muted-foreground">{profileLabel(row.profile)}</div>
{#if row.totalRows != null || row.validRows != null}
<div class="mt-1 text-xs text-muted-foreground">
{#if row.totalRows != null}
Total filas: {row.totalRows}
{/if}
{#if row.validRows != null}
<span class={row.totalRows != null ? ' · ' : ''}>Válidas: {row.validRows}</span>
{/if}
</div>
{/if}
<div class="mt-1 font-mono text-[10px] text-muted-foreground/80">{row.jobId}</div>
<div class="mt-3 flex flex-wrap gap-2">
<Button
size="sm"
disabled={refreshing}
onclick={async () => {
const res = await fetchCsvImportStatus(row.jobId, row.profile);
if (res.data && isWaitingConfirmationPayload(res.data)) {
onResume(row, res.data as Record<string, unknown>);
open = false;
} else {
removeCsvImportPending(row.jobId);
rows = rows.filter((r) => r.jobId !== row.jobId);
syncBadge();
}
}}
>
Reanudar
</Button>
<Button size="sm" variant="ghost" onclick={() => removeLocal(row.jobId)}>Quitar</Button>
</div>
</li>
{/each}
</ul>
{/if}
</div>
</Sheet.Content>
</Sheet.Root>

View File

@@ -480,32 +480,58 @@
</div>
<!-- Footer Actions -->
<div class="px-6 py-4 bg-muted/20 border-t flex items-center justify-end gap-3">
{#if isPending}
<Button
variant="ghost"
onclick={onCancel}
disabled={isUploading}
class="text-muted-foreground hover:bg-muted/50"
>
Cancelar Operación
</Button>
<Button
onclick={onConfirm}
disabled={isUploading}
class="bg-primary hover:bg-primary/90 text-primary-foreground min-w-[140px] shadow-sm"
>
{#if isUploading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
Procesando...
{:else}
<UploadCloud class="mr-2 h-4 w-4" />
Confirmar Carga
{/if}
</Button>
{:else if isFinished}
<Button variant="outline" onclick={onClose} class="min-w-[100px]">Cerrar</Button>
<div class="flex flex-col gap-3 border-t bg-muted/20 px-6 py-4">
{#if isPending && isUploading}
<div class="space-y-2" role="status" aria-live="polite" aria-busy="true">
<p class="text-xs font-medium text-muted-foreground">Importando registros…</p>
<div class="relative h-2 w-full overflow-hidden rounded-full bg-secondary">
<div
class="csv-commit-indeterminate-bar absolute top-0 h-full w-2/5 rounded-full bg-primary"
/>
</div>
</div>
{/if}
<div class="flex items-center justify-end gap-3">
{#if isPending}
<Button
variant="ghost"
onclick={onCancel}
disabled={isUploading}
class="text-muted-foreground hover:bg-muted/50"
>
Cancelar Operación
</Button>
<Button
onclick={onConfirm}
disabled={isUploading}
class="min-w-[140px] bg-primary text-primary-foreground shadow-sm hover:bg-primary/90"
>
{#if isUploading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
Procesando...
{:else}
<UploadCloud class="mr-2 h-4 w-4" />
Confirmar Carga
{/if}
</Button>
{:else if isFinished}
<Button variant="outline" onclick={onClose} class="min-w-[100px]">Cerrar</Button>
{/if}
</div>
</div>
</Dialog.Content>
</Dialog.Root>
<style>
@keyframes csv-commit-indeterminate {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(400%);
}
}
.csv-commit-indeterminate-bar {
animation: csv-commit-indeterminate 1.2s ease-in-out infinite;
}
</style>

View File

@@ -21,7 +21,7 @@
{...$$restProps}
>
<div
class="h-full w-full flex-1 bg-primary transition-all"
class="h-full w-full flex-1 bg-primary transition-transform duration-300 ease-out"
style="transform: translateX(-{100 - (percentage || 0)}%)"
></div>
</div>

View File

@@ -0,0 +1,151 @@
import { browser } from '$app/environment';
import type { CsvImportProfile, CsvImportTab } from '$lib/csv-import-session';
const VALID_PROFILES: CsvImportProfile[] = [
'customs_brokers',
'clients_providers',
'exchange_rates',
'american_fractions',
'pedimentos',
'material_classes',
'vehicles',
'drivers',
'trailers',
'transporters',
'part_numbers',
'boms',
'exportacion',
'imports'
];
const VALID_TABS: CsvImportTab[] = ['catalogos', 'transportes', 'importacion', 'exportacion'];
export const CSV_IMPORT_PENDING_KEY = 'anexo76_csv_pending_v1';
export const CSV_IMPORT_PENDING_CHANGED = 'csvImportPendingChanged';
const MAX_ITEMS = 20;
export interface CsvImportPendingEntry {
companyId: number;
jobId: string;
profile: CsvImportProfile;
activeModelTarget: string | null;
activeTab: CsvImportTab;
label?: string | null;
savedAt: string;
totalRows?: number;
validRows?: number;
}
interface PendingStoreV1 {
v: 1;
items: CsvImportPendingEntry[];
}
function notifyPendingChanged() {
if (browser) {
window.dispatchEvent(new CustomEvent(CSV_IMPORT_PENDING_CHANGED));
}
}
function parseStore(raw: string | null): PendingStoreV1 {
if (!raw) return { v: 1, items: [] };
try {
const data = JSON.parse(raw) as Partial<PendingStoreV1>;
if (data.v !== 1 || !Array.isArray(data.items)) return { v: 1, items: [] };
return { v: 1, items: data.items.filter(isValidEntry) };
} catch {
return { v: 1, items: [] };
}
}
function isValidEntry(x: unknown): x is CsvImportPendingEntry {
if (!x || typeof x !== 'object') return false;
const o = x as Record<string, unknown>;
return (
typeof o.companyId === 'number' &&
typeof o.jobId === 'string' &&
typeof o.profile === 'string' &&
VALID_PROFILES.includes(o.profile as CsvImportProfile) &&
typeof o.savedAt === 'string' &&
(o.activeModelTarget === null || typeof o.activeModelTarget === 'string') &&
typeof o.activeTab === 'string' &&
VALID_TABS.includes(o.activeTab as CsvImportTab)
);
}
function writeStore(store: PendingStoreV1, notify = true) {
if (!browser) return;
try {
localStorage.setItem(CSV_IMPORT_PENDING_KEY, JSON.stringify(store));
if (notify) notifyPendingChanged();
} catch {
//
}
}
export function readCsvImportPendingStore(): PendingStoreV1 {
if (!browser) return { v: 1, items: [] };
return parseStore(localStorage.getItem(CSV_IMPORT_PENDING_KEY));
}
export function listCsvImportPendingForCompany(companyId: number | undefined): CsvImportPendingEntry[] {
if (companyId === undefined) return [];
const { items } = readCsvImportPendingStore();
return items
.filter((i) => i.companyId === companyId)
.sort((a, b) => (a.savedAt < b.savedAt ? 1 : -1));
}
export function countCsvImportPendingForCompany(companyId: number | undefined): number {
return listCsvImportPendingForCompany(companyId).length;
}
/** Inserta o actualiza por jobId; mantiene como máximo MAX_ITEMS (más recientes primero). */
export function upsertCsvImportPending(partial: Omit<CsvImportPendingEntry, 'savedAt'> & { savedAt?: string }): void {
if (!browser) return;
const store = readCsvImportPendingStore();
const now = partial.savedAt ?? new Date().toISOString();
const next: CsvImportPendingEntry = {
companyId: partial.companyId,
jobId: partial.jobId,
profile: partial.profile,
activeModelTarget: partial.activeModelTarget,
activeTab: partial.activeTab,
savedAt: now,
...(partial.label !== undefined && partial.label !== null && String(partial.label).trim()
? { label: String(partial.label).trim() }
: {}),
...(typeof partial.totalRows === 'number' ? { totalRows: partial.totalRows } : {}),
...(typeof partial.validRows === 'number' ? { validRows: partial.validRows } : {})
};
const without = store.items.filter((i) => i.jobId !== next.jobId);
const merged = [next, ...without].sort((a, b) => (a.savedAt < b.savedAt ? 1 : -1)).slice(0, MAX_ITEMS);
writeStore({ v: 1, items: merged });
}
export function removeCsvImportPending(jobId: string): void {
if (!browser) return;
const store = readCsvImportPendingStore();
const filtered = store.items.filter((i) => i.jobId !== jobId);
if (filtered.length === store.items.length) return;
writeStore({ v: 1, items: filtered });
}
export function updateCsvImportPendingSnapshot(
jobId: string,
patch: Pick<CsvImportPendingEntry, 'totalRows' | 'validRows'>
): void {
if (!browser) return;
const store = readCsvImportPendingStore();
const idx = store.items.findIndex((i) => i.jobId === jobId);
if (idx < 0) return;
const cur = store.items[idx];
store.items[idx] = {
...cur,
...(typeof patch.totalRows === 'number' ? { totalRows: patch.totalRows } : {}),
...(typeof patch.validRows === 'number' ? { validRows: patch.validRows } : {})
};
writeStore({ v: 1, items: [...store.items] }, false);
}

View File

@@ -0,0 +1,131 @@
import { browser } from '$app/environment';
export const CSV_IMPORT_SESSION_KEY = 'anexo76_csv_import_v1';
export const CSV_IMPORT_SESSION_CHANGED = 'csvImportSessionChanged';
/** Debe coincidir con la cadena de `pollStatus` en csv-upload/+page.svelte */
export type CsvImportProfile =
| 'customs_brokers'
| 'clients_providers'
| 'exchange_rates'
| 'american_fractions'
| 'pedimentos'
| 'material_classes'
| 'vehicles'
| 'drivers'
| 'trailers'
| 'transporters'
| 'part_numbers'
| 'boms'
| 'exportacion'
| 'imports';
export type CsvImportTab = 'catalogos' | 'transportes' | 'importacion' | 'exportacion';
export interface CsvImportSessionV1 {
v: 1;
companyId: number;
jobId: string;
profile: CsvImportProfile;
activeModelTarget: string | null;
activeTab: CsvImportTab;
/** Título del ítem de carga (p. ej. nombre del catálogo) para banner / contexto */
label?: string | null;
}
function notifySessionChanged() {
if (browser) {
window.dispatchEvent(new CustomEvent(CSV_IMPORT_SESSION_CHANGED));
}
}
export function readCsvImportSession(activeCompanyId: number | undefined): CsvImportSessionV1 | null {
if (!browser || activeCompanyId === undefined) return null;
try {
const raw = sessionStorage.getItem(CSV_IMPORT_SESSION_KEY);
if (!raw) return null;
const data = JSON.parse(raw) as Partial<CsvImportSessionV1>;
if (data.v !== 1 || typeof data.jobId !== 'string' || typeof data.companyId !== 'number') {
return null;
}
if (data.companyId !== activeCompanyId) return null;
if (!isCsvImportProfile(data.profile)) return null;
if (!isCsvImportTab(data.activeTab)) return null;
const label =
data.label === null || data.label === undefined
? undefined
: typeof data.label === 'string'
? data.label
: undefined;
return {
v: 1,
companyId: data.companyId,
jobId: data.jobId,
profile: data.profile,
activeModelTarget:
data.activeModelTarget === null || data.activeModelTarget === undefined
? null
: typeof data.activeModelTarget === 'string'
? data.activeModelTarget
: null,
activeTab: data.activeTab,
...(label !== undefined ? { label } : {})
};
} catch {
return null;
}
}
export function hasCsvImportSessionForCompany(activeCompanyId: number | undefined): boolean {
return readCsvImportSession(activeCompanyId) !== null;
}
function isCsvImportProfile(p: unknown): p is CsvImportProfile {
return (
typeof p === 'string' &&
[
'customs_brokers',
'clients_providers',
'exchange_rates',
'american_fractions',
'pedimentos',
'material_classes',
'vehicles',
'drivers',
'trailers',
'transporters',
'part_numbers',
'boms',
'exportacion',
'imports'
].includes(p)
);
}
function isCsvImportTab(t: unknown): t is CsvImportTab {
return (
typeof t === 'string' &&
['catalogos', 'transportes', 'importacion', 'exportacion'].includes(t)
);
}
export function saveCsvImportSession(session: CsvImportSessionV1): void {
if (!browser) return;
try {
sessionStorage.setItem(CSV_IMPORT_SESSION_KEY, JSON.stringify(session));
notifySessionChanged();
} catch {
// quota / private mode
}
}
export function clearCsvImportSession(): void {
if (!browser) return;
try {
sessionStorage.removeItem(CSV_IMPORT_SESSION_KEY);
notifySessionChanged();
} catch {
//
}
}

View File

@@ -0,0 +1,49 @@
import { api, type ApiResponse } from '$lib/api';
import type { CsvImportProfile } from '$lib/csv-import-session';
/**
* Consulta el estado de un job CSV según el perfil de importación (misma lógica que pollStatus en csv-upload).
*/
export function fetchCsvImportStatus(
jobId: string,
profile: CsvImportProfile
): Promise<ApiResponse> {
switch (profile) {
case 'customs_brokers':
return api.customsBrokerImports.status(jobId);
case 'clients_providers':
return api.clientProviderImports.status(jobId);
case 'exchange_rates':
return api.exchangeRateImports.status(jobId);
case 'american_fractions':
return api.americanFractionImports.status(jobId);
case 'pedimentos':
return api.pedimentosImports.status(jobId);
case 'material_classes':
return api.materialClassImports.status(jobId);
case 'vehicles':
return api.vehicleImports.status(jobId);
case 'drivers':
return api.driverImports.status(jobId);
case 'trailers':
return api.trailerImports.status(jobId);
case 'transporters':
return api.transporterImports.status(jobId);
case 'part_numbers':
return api.partNumberImports.status(jobId);
case 'boms':
return api.bomImports.status(jobId);
case 'exportacion':
return api.exportacionImports.status(jobId);
case 'imports':
default:
return api.imports.status(jobId);
}
}
export function isWaitingConfirmationPayload(data: unknown): boolean {
if (!data || typeof data !== 'object') return false;
const d = data as Record<string, unknown>;
if (d.status === 'waiting_confirmation') return true;
return typeof d.job_id === 'string' && typeof d.total_rows === 'number';
}

View File

@@ -10,6 +10,8 @@
import ExchangeRateGuard from '$lib/components/dashboard/exchange_rate/exchange-rate-guard.svelte';
import SessionTimeoutWarning from '$lib/components/session-timeout-warning.svelte';
import { page } from '$app/state';
import { browser } from '$app/environment';
import { CSV_IMPORT_SESSION_CHANGED, readCsvImportSession } from '$lib/csv-import-session';
import {
createSessionManager,
destroySessionManager,
@@ -21,6 +23,35 @@
let { data, children }: { data: LayoutData; children: any } = $props();
let csvImportBanner = $state(false);
let csvImportBannerLabel = $state<string | null>(null);
function updateCsvImportBanner() {
if (!browser) {
csvImportBanner = false;
csvImportBannerLabel = null;
return;
}
const path = page.url.pathname;
const cid = companyStore.activeCompany?.id;
if (path.includes('/dashboard/csv-upload')) {
csvImportBanner = false;
csvImportBannerLabel = null;
return;
}
const session = readCsvImportSession(cid);
csvImportBanner = session != null;
const raw = session?.label;
csvImportBannerLabel = typeof raw === 'string' && raw.trim() ? raw.trim() : null;
}
$effect(() => {
if (!browser) return;
page.url.pathname;
companyStore.activeCompany?.id;
updateCsvImportBanner();
});
// Hacer disponible el usuario en el contexto para los componentes hijos
setContext('user', data.user);
setContext('userTenants', data.userTenants ?? []);
@@ -71,9 +102,12 @@
// ── Escuchar cambios de compañía y recargar datos ─────────────────────
const handleCompanyChange = () => invalidateAll();
window.addEventListener('companyChanged', handleCompanyChange);
window.addEventListener(CSV_IMPORT_SESSION_CHANGED, updateCsvImportBanner);
updateCsvImportBanner();
return () => {
window.removeEventListener('companyChanged', handleCompanyChange);
window.removeEventListener(CSV_IMPORT_SESSION_CHANGED, updateCsvImportBanner);
window.removeEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired);
};
});
@@ -108,6 +142,21 @@
</div>
</header>
<div class="flex flex-1 flex-col min-h-0 gap-4 overflow-x-hidden p-4 pt-0">
{#if csvImportBanner}
<a
href="/dashboard/csv-upload"
class="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-primary/25 bg-primary/5 px-4 py-2.5 text-sm text-foreground shadow-sm transition-colors hover:bg-primary/10"
>
<span>
{#if csvImportBannerLabel}
Importación CSV en curso: {csvImportBannerLabel}.
{:else}
Importación CSV en curso para esta empresa.
{/if}
</span>
<span class="font-semibold text-primary">Ir a importación masiva</span>
</a>
{/if}
<!-- Contenido de cada página -->
{@render children?.()}
</div>

View File

@@ -182,7 +182,6 @@
<tr>
<th class="p-2 text-left">Task ID</th>
<th class="p-2 text-left">Tipo</th>
<th class="p-2 text-left">Empresa</th>
<th class="p-2 text-left">Estado</th>
<th class="p-2 text-left">Progreso</th>
<th class="p-2 text-left">Reintentos</th>
@@ -192,11 +191,11 @@
<tbody>
{#if tasks.length === 0 && !loading}
<tr>
<td class="p-4 text-center text-muted-foreground" colspan="7">Sin tareas registradas</td>
<td class="p-4 text-center text-muted-foreground" colspan="6">Sin tareas registradas</td>
</tr>
{:else if tasks.length === 0 && loading}
<tr>
<td class="p-4 text-center text-muted-foreground italic" colspan="7">Cargando...</td>
<td class="p-4 text-center text-muted-foreground italic" colspan="6">Cargando...</td>
</tr>
{:else}
{#each tasks as task}
@@ -208,7 +207,6 @@
>
<td class="p-2 font-mono text-xs">{task.task_id}</td>
<td class="p-2">{task.task_group} / {task.task_name}</td>
<td class="p-2 text-muted-foreground">{task.company_id ?? '—'}</td>
<td class={`p-2 font-medium ${statusClass(task.status)}`}>
{statusLabel(task.status)}
<span class="ml-1 text-xs font-normal text-muted-foreground"

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import UploadLauncherGrid from '$lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte';
import CsvParamsBar from '$lib/components/dashboard/csv-upload/CsvParamsBar.svelte';
@@ -13,8 +15,24 @@
type CsvUploadItem
} from '$lib/config/csv-upload';
import { api } from '$lib/api';
import { Progress } from '$lib/components/ui/progress/index.js';
import { toast } from 'svelte-sonner';
import { companyStore } from '$lib/stores/company.svelte';
import {
CSV_IMPORT_SESSION_KEY,
clearCsvImportSession,
readCsvImportSession,
saveCsvImportSession,
type CsvImportProfile,
type CsvImportSessionV1
} from '$lib/csv-import-session';
import {
removeCsvImportPending,
upsertCsvImportPending,
type CsvImportPendingEntry
} from '$lib/csv-import-pending';
import { fetchCsvImportStatus } from '$lib/csv-import-status-api';
import CsvPendingImportsSheet from '$lib/components/dashboard/csv-upload/CsvPendingImportsSheet.svelte';
/** Intenta extraer un objeto tipo scan desde string tipo repr de Python. */
function parsePythonReprScan(s: string): Record<string, unknown> | null {
@@ -167,6 +185,300 @@
// Cuando es true, usamos API de importación de Exportación (layouts_csv/exportacion)
let useExportacionImport = $state(false);
type CsvProgressPhase = 'idle' | 'upload' | 'scan' | 'commit';
let csvProgressPhase = $state<CsvProgressPhase>('idle');
let uploadProgressPct = $state(0);
/** true cuando el navegador reporta tamaño total en XHR (lengthComputable) */
let uploadLengthComputable = $state(false);
let scanProgressCurrent = $state(0);
let scanProgressTotal = $state(0);
let currentImportLabel = $state<string | null>(null);
let csvResumeOverlayHint = $state(false);
let skipScanCompleteToastOnce = $state(false);
/** Job id del escaneo listo para confirmar (para quitar de pendientes al hacer commit). */
let scanPhaseJobId = $state<string | null>(null);
const csvProgressStepTitle = $derived.by(() => {
switch (csvProgressPhase) {
case 'upload':
return 'Paso 1 de 2 — Subiendo el archivo';
case 'scan':
return 'Paso 2 de 2 — Escaneando en el servidor';
case 'commit':
return 'Finalizando importación';
default:
return '';
}
});
const csvProgressDetailLine = $derived.by(() => {
switch (csvProgressPhase) {
case 'upload':
return '';
case 'scan':
return scanProgressTotal > 0
? `Filas procesadas: ${scanProgressCurrent} / ${scanProgressTotal}`
: 'Preparando resultados…';
case 'commit':
return 'Escribiendo registros en base de datos…';
default:
return '';
}
});
const csvProgressBarIndeterminate = $derived(
csvProgressPhase === 'commit' ||
(csvProgressPhase === 'scan' && scanProgressTotal <= 0) ||
(csvProgressPhase === 'upload' && !uploadLengthComputable)
);
const csvProgressBarValue = $derived(
csvProgressPhase === 'upload'
? uploadProgressPct
: csvProgressPhase === 'scan' && scanProgressTotal > 0
? Math.min(100, Math.round((scanProgressCurrent / scanProgressTotal) * 100))
: 0
);
const csvProgressPercentText = $derived(
csvProgressBarIndeterminate ? null : `${csvProgressBarValue}%`
);
const csvProgressAriaValueText = $derived(
csvProgressPercentText
? `${csvProgressStepTitle}, ${csvProgressPercentText}`
: csvProgressDetailLine
? `${csvProgressStepTitle}. ${csvProgressDetailLine}`
: csvProgressStepTitle
);
$effect(() => {
if (!isUploading) {
csvProgressPhase = 'idle';
uploadProgressPct = 0;
uploadLengthComputable = false;
scanProgressCurrent = 0;
scanProgressTotal = 0;
currentImportLabel = null;
csvResumeOverlayHint = false;
scanPhaseJobId = null;
}
});
function resetAllImportFlags() {
useCustomsBrokerImport = false;
useClientProviderImport = false;
useExchangeRateImport = false;
useAmericanFractionImport = false;
usePedimentosImport = false;
useMaterialClassesImport = false;
useVehicleImport = false;
useDriverImport = false;
useTrailerImport = false;
useTransporterImport = false;
usePartNumbersImport = false;
useBomImport = false;
useExportacionImport = false;
}
function applyImportProfileFromSession(session: CsvImportSessionV1) {
resetAllImportFlags();
switch (session.profile) {
case 'customs_brokers':
useCustomsBrokerImport = true;
break;
case 'clients_providers':
useClientProviderImport = true;
break;
case 'exchange_rates':
useExchangeRateImport = true;
break;
case 'american_fractions':
useAmericanFractionImport = true;
break;
case 'pedimentos':
usePedimentosImport = true;
break;
case 'material_classes':
useMaterialClassesImport = true;
break;
case 'vehicles':
useVehicleImport = true;
break;
case 'drivers':
useDriverImport = true;
break;
case 'trailers':
useTrailerImport = true;
break;
case 'transporters':
useTransporterImport = true;
break;
case 'part_numbers':
usePartNumbersImport = true;
break;
case 'boms':
useBomImport = true;
break;
case 'exportacion':
useExportacionImport = true;
break;
default:
break;
}
activeTab = session.activeTab;
currentImportLabel = session.label ?? null;
}
function resolveProfileFromFlags(): CsvImportProfile | null {
if (useCustomsBrokerImport) return 'customs_brokers';
if (useClientProviderImport) return 'clients_providers';
if (useExchangeRateImport) return 'exchange_rates';
if (useAmericanFractionImport) return 'american_fractions';
if (usePedimentosImport) return 'pedimentos';
if (useMaterialClassesImport) return 'material_classes';
if (useVehicleImport) return 'vehicles';
if (useDriverImport) return 'drivers';
if (useTrailerImport) return 'trailers';
if (useTransporterImport) return 'transporters';
if (usePartNumbersImport) return 'part_numbers';
if (useBomImport) return 'boms';
if (useExportacionImport) return 'exportacion';
return 'imports';
}
function pushScanToPendingLocal(scanData: Record<string, unknown>) {
const cid = companyStore.activeCompany?.id;
if (cid === undefined) return;
const jid =
typeof scanData.job_id === 'string' && scanData.job_id
? scanData.job_id
: currentJobId
? currentJobId
: null;
if (!jid) return;
scanPhaseJobId = jid;
upsertCsvImportPending({
companyId: cid,
jobId: jid,
profile: resolveProfileFromFlags() ?? 'imports',
activeModelTarget,
activeTab: activeTab as CsvImportSessionV1['activeTab'],
label: currentImportLabel ?? undefined,
totalRows: typeof scanData.total_rows === 'number' ? scanData.total_rows : undefined,
validRows: typeof scanData.valid_rows === 'number' ? scanData.valid_rows : undefined
});
}
function finalizeCommitAndClearPending() {
if (scanPhaseJobId) {
removeCsvImportPending(scanPhaseJobId);
scanPhaseJobId = null;
}
}
/** Quita pendientes ligados al flujo actual (escaneo y/o commit pueden tener distinto job id). */
function removePendingLinkedToCurrentFlow() {
const ids = new Set<string>();
if (currentJobId) ids.add(currentJobId);
if (scanPhaseJobId) ids.add(scanPhaseJobId);
for (const id of ids) removeCsvImportPending(id);
}
function resumePendingImport(entry: CsvImportPendingEntry, scanPayload: Record<string, unknown>) {
const session: CsvImportSessionV1 = {
v: 1,
companyId: entry.companyId,
jobId: entry.jobId,
profile: entry.profile,
activeModelTarget: entry.activeModelTarget,
activeTab: entry.activeTab,
...(entry.label ? { label: entry.label } : {})
};
applyImportProfileFromSession(session);
activeModelTarget = entry.activeModelTarget;
activeTab = entry.activeTab;
currentJobId = entry.jobId;
currentImportLabel = entry.label ?? null;
scanPhaseJobId = entry.jobId;
scanResults = scanPayload;
commitResults = null;
showResultModal = true;
isUploading = false;
saveCsvImportSession(session);
skipScanCompleteToastOnce = true;
}
function persistCsvJobFromState() {
if (!browser || !currentJobId) return;
const profile = resolveProfileFromFlags() ?? 'imports';
const companyId = companyStore.activeCompany?.id;
if (companyId === undefined) return;
const payload: CsvImportSessionV1 = {
v: 1,
companyId,
jobId: currentJobId,
profile,
activeModelTarget,
activeTab: activeTab as CsvImportSessionV1['activeTab'],
...(currentImportLabel ? { label: currentImportLabel } : {})
};
saveCsvImportSession(payload);
}
function beginCsvScanAfterUpload() {
persistCsvJobFromState();
csvProgressPhase = 'scan';
uploadProgressPct = 100;
pollStatus();
}
let csvSessionRestoreAttempted = $state(false);
$effect(() => {
if (!browser) return;
const cid = companyStore.activeCompany?.id;
if (cid === undefined) return;
try {
const raw = sessionStorage.getItem(CSV_IMPORT_SESSION_KEY);
if (!raw) return;
const p = JSON.parse(raw) as { companyId?: number };
if (p.companyId != null && p.companyId !== cid) clearCsvImportSession();
} catch {
//
}
});
$effect(() => {
if (!browser || csvSessionRestoreAttempted) return;
const cid = companyStore.activeCompany?.id;
if (cid === undefined) return;
csvSessionRestoreAttempted = true;
const session = readCsvImportSession(cid);
if (!session) return;
applyImportProfileFromSession(session);
activeModelTarget = session.activeModelTarget;
activeTab = session.activeTab;
currentJobId = session.jobId;
currentImportLabel = session.label ?? null;
isUploading = true;
csvProgressPhase = 'scan';
uploadProgressPct = 100;
uploadLengthComputable = true;
csvResumeOverlayHint = true;
skipScanCompleteToastOnce = true;
queueMicrotask(() => void pollStatus());
});
onMount(() => {
const onVis = () => {
if (!browser || document.visibilityState !== 'visible') return;
if (currentJobId) void pollStatus();
};
document.addEventListener('visibilitychange', onVis);
return () => document.removeEventListener('visibilitychange', onVis);
});
// Initialize settings for all tabs upfront to avoid reactivity loops (sync init so child never receives undefined)
const _initialSettings: Record<string, any> = {};
for (const tab in tabSettings) {
@@ -206,6 +518,23 @@
async function handleUpload(file: File, config: CsvUploadItem) {
console.log('handleUpload started', { file, config });
isUploading = true;
csvProgressPhase = 'upload';
uploadProgressPct = 0;
uploadLengthComputable = false;
scanProgressCurrent = 0;
scanProgressTotal = 0;
csvResumeOverlayHint = false;
skipScanCompleteToastOnce = false;
scanPhaseJobId = null;
currentImportLabel = (config.title && String(config.title).trim()) || null;
const onCsvFileUploadProgress = (e: { loaded: number; total: number }) => {
if (e.total > 0) {
uploadLengthComputable = true;
uploadProgressPct = Math.min(100, Math.round((e.loaded / e.total) * 100));
}
};
activeModelTarget = config.modelTarget || null;
scanResults = null;
useCustomsBrokerImport = config.id === 'customs_brokers';
@@ -226,10 +555,12 @@
if (useCustomsBrokerImport) {
try {
const res = await api.customsBrokerImports.upload(file, companyId);
const res = await api.customsBrokerImports.upload(file, companyId, {
onUploadProgress: onCsvFileUploadProgress
});
if (res.data?.job_id) {
currentJobId = res.data.job_id;
pollStatus();
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
isUploading = false;
@@ -244,10 +575,12 @@
if (useClientProviderImport) {
try {
const res = await api.clientProviderImports.upload(file, companyId);
const res = await api.clientProviderImports.upload(file, companyId, {
onUploadProgress: onCsvFileUploadProgress
});
if (res.data?.job_id) {
currentJobId = res.data.job_id;
pollStatus();
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
isUploading = false;
@@ -271,10 +604,10 @@
const res = await api.exchangeRateImports.upload(file, companyId, {
reemplazar_sin_preguntar,
date_format: dateFormat
});
}, { onUploadProgress: onCsvFileUploadProgress });
if (res.data?.job_id) {
currentJobId = res.data.job_id;
pollStatus();
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
isUploading = false;
@@ -289,10 +622,12 @@
if (useAmericanFractionImport) {
try {
const res = await api.americanFractionImports.upload(file, companyId);
const res = await api.americanFractionImports.upload(file, companyId, {
onUploadProgress: onCsvFileUploadProgress
});
if (res.data?.job_id) {
currentJobId = res.data.job_id;
pollStatus();
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
isUploading = false;
@@ -314,10 +649,10 @@
const res = await api.pedimentosImports.upload(file, companyId, {
actualizar,
dateFormat
});
}, { onUploadProgress: onCsvFileUploadProgress });
if (res.data?.job_id) {
currentJobId = res.data.job_id;
pollStatus();
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
isUploading = false;
@@ -337,10 +672,10 @@
const res = await api.materialClassImports.upload(file, companyId, {
actualizar,
siempre_toda: false
});
}, { onUploadProgress: onCsvFileUploadProgress });
if (res.data?.job_id) {
currentJobId = res.data.job_id;
pollStatus();
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
isUploading = false;
@@ -357,10 +692,13 @@
try {
const catalogosSettings = allSettings['catalogos'] || {};
const actualizar = catalogosSettings['mode'] === 'update';
const res = await api.vehicleImports.upload(file, companyId, { actualizar });
const res = await api.vehicleImports.upload(file, companyId, {
actualizar,
onUploadProgress: onCsvFileUploadProgress
});
if (res.data?.job_id) {
currentJobId = res.data.job_id;
pollStatus();
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
isUploading = false;
@@ -375,10 +713,12 @@
if (useDriverImport) {
try {
const res = await api.driverImports.upload(file, companyId);
const res = await api.driverImports.upload(file, companyId, {
onUploadProgress: onCsvFileUploadProgress
});
if (res.data?.job_id) {
currentJobId = res.data.job_id;
pollStatus();
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
isUploading = false;
@@ -395,10 +735,13 @@
try {
const transportesSettings = allSettings['transportes'] || {};
const actualizar = transportesSettings['mode'] === 'update';
const res = await api.trailerImports.upload(file, companyId, { actualizar });
const res = await api.trailerImports.upload(file, companyId, {
actualizar,
onUploadProgress: onCsvFileUploadProgress
});
if (res.data?.job_id) {
currentJobId = res.data.job_id;
pollStatus();
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
isUploading = false;
@@ -415,10 +758,13 @@
try {
const transportesSettings = allSettings['transportes'] || {};
const actualizar = transportesSettings['mode'] === 'update';
const res = await api.transporterImports.upload(file, companyId, { actualizar });
const res = await api.transporterImports.upload(file, companyId, {
actualizar,
onUploadProgress: onCsvFileUploadProgress
});
if (res.data?.job_id) {
currentJobId = res.data.job_id;
pollStatus();
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
isUploading = false;
@@ -437,11 +783,12 @@
const actualizar = catalogosSettings['mode'] === 'update';
const res = await api.partNumberImports.upload(file, companyId, {
actualizar,
reemplazar_sin_preguntar: true
reemplazar_sin_preguntar: true,
onUploadProgress: onCsvFileUploadProgress
});
if (res.data?.job_id) {
currentJobId = res.data.job_id;
pollStatus();
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
isUploading = false;
@@ -456,10 +803,12 @@
if (useBomImport) {
try {
const res = await api.bomImports.upload(file, companyId);
const res = await api.bomImports.upload(file, companyId, {
onUploadProgress: onCsvFileUploadProgress
});
if (res.data?.job_id) {
currentJobId = res.data.job_id;
pollStatus();
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
isUploading = false;
@@ -488,11 +837,12 @@
config.modelTarget || '',
footerConfig,
companyId,
config.templateId || config.id
config.templateId || config.id,
{ onUploadProgress: onCsvFileUploadProgress }
);
if (res.data?.job_id) {
currentJobId = res.data.job_id;
pollStatus();
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
isUploading = false;
@@ -514,11 +864,12 @@
footerConfig,
companyId,
opType,
config.templateId || config.id
config.templateId || config.id,
{ onUploadProgress: onCsvFileUploadProgress }
);
if (res.data?.job_id) {
currentJobId = res.data.job_id;
pollStatus();
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
isUploading = false;
@@ -530,52 +881,54 @@
}
}
function isStaleImportJobError(status: number, err: string | undefined): boolean {
if (status === 404) return true;
const m = (err || '').toLowerCase();
return /not found|no encontrado|404|expir|no disponible|invalid|inexistente/i.test(m);
}
async function pollStatus() {
if (!currentJobId) return;
try {
const res = useCustomsBrokerImport
? await api.customsBrokerImports.status(currentJobId)
: useClientProviderImport
? await api.clientProviderImports.status(currentJobId)
: useExchangeRateImport
? await api.exchangeRateImports.status(currentJobId)
: useAmericanFractionImport
? await api.americanFractionImports.status(currentJobId)
: usePedimentosImport
? await api.pedimentosImports.status(currentJobId)
: useMaterialClassesImport
? await api.materialClassImports.status(currentJobId)
: useVehicleImport
? await api.vehicleImports.status(currentJobId)
: useDriverImport
? await api.driverImports.status(currentJobId)
: useTrailerImport
? await api.trailerImports.status(currentJobId)
: useTransporterImport
? await api.transporterImports.status(currentJobId)
: usePartNumbersImport
? await api.partNumberImports.status(currentJobId)
: useBomImport
? await api.bomImports.status(currentJobId)
: useExportacionImport
? await api.exportacionImports.status(currentJobId)
: await api.imports.status(currentJobId);
const profile = resolveProfileFromFlags() ?? 'imports';
const res = await fetchCsvImportStatus(currentJobId, profile);
console.log('Poll response', res);
csvResumeOverlayHint = false;
if (res.error && !res.data) {
toast.error(res.error || 'Error al consultar el estado');
const errMsg = res.error || '';
if (isStaleImportJobError(res.status, errMsg)) {
removePendingLinkedToCurrentFlow();
toast.info(
'Esta importación ya no está disponible (sesión expirada o trabajo eliminado). Puedes iniciar una nueva carga.'
);
} else {
toast.error(errMsg || 'Error al consultar el estado');
}
isUploading = false;
clearCsvImportSession();
currentJobId = null;
return;
}
if (res.data?.status === 'processing') {
const p = (res.data as { progress?: unknown }).progress;
const t = (res.data as { total?: unknown }).total;
if (typeof p === 'number') scanProgressCurrent = p;
if (typeof t === 'number') scanProgressTotal = t;
}
// Tratar como resultado de escaneo si viene status waiting_confirmation O si el payload tiene forma de scan (job_id + total_rows)
const looksLikeScanResult =
res.data?.status === 'waiting_confirmation' ||
(res.data?.job_id && typeof res.data?.total_rows === 'number');
if (looksLikeScanResult) {
scanResults = res.data;
pushScanToPendingLocal(res.data as Record<string, unknown>);
showResultModal = true;
toast.success('Escaneo completado. Revisa los resultados.');
if (skipScanCompleteToastOnce) {
skipScanCompleteToastOnce = false;
} else {
toast.success('Escaneo completado. Revisa los resultados.');
}
isUploading = false;
} else if (res.data?.status === 'failed' || res.data?.status === 'FAILURE') {
const errRaw = res.data.error;
@@ -591,8 +944,10 @@
if (parsedCommit) {
commitResults = parsedCommit;
showResultModal = true;
finalizeCommitAndClearPending();
toast.success('Importación completada. Revisa el listado de registros.');
isUploading = false;
clearCsvImportSession();
currentJobId = null;
return;
}
@@ -611,8 +966,13 @@
}
if (parsedScan) {
scanResults = parsedScan;
pushScanToPendingLocal(parsedScan);
showResultModal = true;
toast.success('Escaneo completado. Revisa los resultados.');
if (skipScanCompleteToastOnce) {
skipScanCompleteToastOnce = false;
} else {
toast.success('Escaneo completado. Revisa los resultados.');
}
isUploading = false;
} else {
// Mensaje parece resultado de escaneo pero no se pudo parsear (p. ej. repr Python) → no asustar con error
@@ -620,8 +980,10 @@
typeof errRaw === 'string' &&
(errRaw.includes('waiting_confirmation') || (errRaw.includes('total_rows') && errRaw.includes('job_id')));
if (looksLikeScanInError) {
removePendingLinkedToCurrentFlow();
toast.info('El escaneo terminó. Si no ves el modal, revisa el listado de registros.');
isUploading = false;
clearCsvImportSession();
currentJobId = null;
return;
}
@@ -645,6 +1007,8 @@
toast.error('Error en el procesamiento: ' + errText);
}
isUploading = false;
removePendingLinkedToCurrentFlow();
clearCsvImportSession();
currentJobId = null;
scanResults = null;
commitResults = null;
@@ -666,6 +1030,7 @@
} else {
toast.warning(backendMessage || `Solo se insertaron ${inserted} de ${inserted + totalSkipped} registros.`);
}
finalizeCommitAndClearPending();
isUploading = false;
} else if (res.data?.status === 'finished') {
commitResults = res.data;
@@ -689,6 +1054,7 @@
} else {
toast.error('No se insertaron registros. Revisa los errores a continuación.');
}
finalizeCommitAndClearPending();
isUploading = false;
} else {
// Continue polling
@@ -708,8 +1074,9 @@
<div class="flex flex-col flex-1 min-h-0 -m-4 overflow-hidden">
<!-- Single scroll: content scrolls here; padding at bottom reserves space for fixed params bar -->
<div class="flex-1 min-h-0 overflow-y-auto p-4 md:p-8 space-y-4">
<div class="flex items-center gap-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-lg font-semibold md:text-2xl">Importación Masiva de Datos (CSV)</h1>
<CsvPendingImportsSheet companyId={companyStore.activeCompany?.id} onResume={resumePendingImport} />
</div>
<Tabs.Root bind:value={activeTab} class="w-full">
@@ -758,6 +1125,49 @@
<div class="h-[var(--csv-params-bar-height,6rem)] shrink-0" aria-hidden="true"></div>
</div>
{#if isUploading && !showResultModal}
<div
class="fixed inset-0 z-50 flex flex-col items-center justify-center gap-3 bg-background/80 px-6 backdrop-blur-sm"
role="status"
aria-live="polite"
aria-busy="true"
>
{#if csvResumeOverlayHint}
<p class="max-w-md text-center text-xs text-muted-foreground">
Reanudando la importación guardada en esta pestaña…
</p>
{/if}
{#if currentImportLabel}
<p class="max-w-md text-center text-xs font-medium text-foreground">{currentImportLabel}</p>
{/if}
<div class="flex w-full max-w-md items-start justify-between gap-3">
<p class="flex-1 text-left text-sm font-medium text-foreground">{csvProgressStepTitle}</p>
{#if csvProgressPercentText}
<span class="shrink-0 tabular-nums text-sm font-semibold text-foreground" aria-hidden="true">
{csvProgressPercentText}
</span>
{/if}
</div>
{#if csvProgressDetailLine}
<p class="max-w-md text-center text-xs text-muted-foreground">{csvProgressDetailLine}</p>
{/if}
<div class="w-full max-w-md">
{#if csvProgressBarIndeterminate}
<div class="relative h-4 w-full overflow-hidden rounded-full bg-secondary">
<div class="csv-upload-indeterminate-bar absolute top-0 h-full w-2/5 rounded-full bg-primary" />
</div>
{:else}
<Progress
value={csvProgressBarValue}
max={100}
class="h-4"
aria-valuetext={csvProgressAriaValueText}
/>
{/if}
</div>
</div>
{/if}
<!-- Fixed params bar (always visible on this view) -->
<CsvParamsBar bind:globalSettings {activeTab} bind:tabSettingsValues={allSettings[activeTab]} />
</div>
@@ -801,6 +1211,9 @@
if (!currentJobId) return;
try {
isUploading = true;
csvProgressPhase = 'commit';
scanProgressCurrent = 0;
scanProgressTotal = 0;
const res = useCustomsBrokerImport
? await api.customsBrokerImports.commit(currentJobId)
: useClientProviderImport
@@ -830,6 +1243,7 @@
: await api.imports.commit(currentJobId, activeModelTarget || '');
if (res.data?.commit_job_id) {
currentJobId = res.data.commit_job_id;
persistCsvJobFromState();
pollStatus();
}
} catch (err) {
@@ -838,12 +1252,14 @@
}
}}
onCancel={() => {
clearCsvImportSession();
currentJobId = null;
scanResults = null;
commitResults = null;
showResultModal = false;
}}
onClose={() => {
clearCsvImportSession();
currentJobId = null;
scanResults = null;
commitResults = null;
@@ -851,3 +1267,17 @@
}}
/>
{/if}
<style>
@keyframes csv-upload-indeterminate {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(400%);
}
}
.csv-upload-indeterminate-bar {
animation: csv-upload-indeterminate 1.2s ease-in-out infinite;
}
</style>

View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Ejecuta una pasada del janitor de layouts CSV (tarea Celery cleanup_orphan_layout_imports).
# Requiere stack levantado: docker compose up -d worker valkey postgres-a76
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
CONTAINER="${CELERY_WORKER_CONTAINER:-worker}"
docker compose exec -T "$CONTAINER" celery -A core.celery_app call cleanup_orphan_layout_imports