diff --git a/backend/api/v1/modules/a76/layouts_csv/boms/routes.py b/backend/api/v1/modules/a76/layouts_csv/boms/routes.py index 3d33f075..f99183d5 100644 --- a/backend/api/v1/modules/a76/layouts_csv/boms/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/boms/routes.py @@ -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", diff --git a/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/routes.py b/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/routes.py index e646e107..9f8a4ccd 100644 --- a/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/routes.py @@ -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", diff --git a/backend/api/v1/modules/a76/layouts_csv/classes/routes.py b/backend/api/v1/modules/a76/layouts_csv/classes/routes.py index 24801833..53464802 100644 --- a/backend/api/v1/modules/a76/layouts_csv/classes/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/classes/routes.py @@ -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", diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py index 59c99ef7..fcc092dd 100644 --- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py @@ -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", diff --git a/backend/api/v1/modules/a76/layouts_csv/common/import_redis_keys.py b/backend/api/v1/modules/a76/layouts_csv/common/import_redis_keys.py new file mode 100644 index 00000000..8d763d1d --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/common/import_redis_keys.py @@ -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 diff --git a/backend/api/v1/modules/a76/layouts_csv/common/track_commit_dispatch.py b/backend/api/v1/modules/a76/layouts_csv/common/track_commit_dispatch.py index b8479a0a..296906c4 100644 --- a/backend/api/v1/modules/a76/layouts_csv/common/track_commit_dispatch.py +++ b/backend/api/v1/modules/a76/layouts_csv/common/track_commit_dispatch.py @@ -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 diff --git a/backend/api/v1/modules/a76/layouts_csv/common/victor.py b/backend/api/v1/modules/a76/layouts_csv/common/victor.py new file mode 100644 index 00000000..eab7d2bf --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/common/victor.py @@ -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() diff --git a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/routes.py b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/routes.py index ab884351..f0cebaaf 100644 --- a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/routes.py @@ -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", diff --git a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/routes.py b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/routes.py index ff55abf5..db570733 100644 --- a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/routes.py @@ -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", diff --git a/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py b/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py index 82c2af93..dca65485 100644 --- a/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py @@ -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", diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py b/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py index 18b39eee..c9ac5285 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py @@ -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", diff --git a/backend/api/v1/modules/a76/layouts_csv/parts/routes.py b/backend/api/v1/modules/a76/layouts_csv/parts/routes.py index d4a5af18..5184073e 100644 --- a/backend/api/v1/modules/a76/layouts_csv/parts/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/parts/routes.py @@ -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", diff --git a/backend/api/v1/modules/a76/layouts_csv/pedmientos/routes.py b/backend/api/v1/modules/a76/layouts_csv/pedmientos/routes.py index 36f601fe..eda3ea86 100644 --- a/backend/api/v1/modules/a76/layouts_csv/pedmientos/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/pedmientos/routes.py @@ -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", diff --git a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/routes.py b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/routes.py index 322ca183..afb8c3bb 100644 --- a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/routes.py @@ -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", diff --git a/backend/api/v1/modules/core/tasks_tracking/routes.py b/backend/api/v1/modules/core/tasks_tracking/routes.py index 57395ea4..c7141e69 100644 --- a/backend/api/v1/modules/core/tasks_tracking/routes.py +++ b/backend/api/v1/modules/core/tasks_tracking/routes.py @@ -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, diff --git a/backend/api/v1/modules/core/tasks_tracking/schemas.py b/backend/api/v1/modules/core/tasks_tracking/schemas.py index 9af50cb4..e672dd1f 100644 --- a/backend/api/v1/modules/core/tasks_tracking/schemas.py +++ b/backend/api/v1/modules/core/tasks_tracking/schemas.py @@ -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 diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index 7137ff77..d7cae18a 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -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__": diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 938efb43..e823b73e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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: diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a6df4b43..42b5290a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -243,6 +243,193 @@ async function fetchApi( } } +/** Opciones para subidas CSV (FormData) con progreso de red. */ +export type CsvFormDataUploadOptions = { + onUploadProgress?: (e: { loaded: number; total: number }) => void; +}; + +/** + * POST multipart/form-data con XMLHttpRequest para exponer progreso de subida. + * Misma semántica de auth/401/403/422 que fetchApi. + */ +async function fetchApiFormDataPost( + endpoint: string, + formData: FormData, + opts: CsvFormDataUploadOptions & { retryCount?: number } = {} +): Promise> { + const retryCount = opts.retryCount ?? 0; + + if (isRefreshing && retryCount === 0) { + return new Promise((resolve) => { + subscribeTokenRefresh(() => { + resolve(fetchApiFormDataPost(endpoint, formData, { ...opts, retryCount: 1 })); + }); + }); + } + + return new Promise((resolve) => { + const token = getToken(); + const xhr = new XMLHttpRequest(); + xhr.open('POST', `${API_BASE_URL}${endpoint}`); + xhr.withCredentials = true; + if (token) { + xhr.setRequestHeader('Authorization', `Bearer ${token}`); + } + + xhr.upload.onprogress = (ev) => { + if (!opts.onUploadProgress) return; + if (ev.lengthComputable) { + opts.onUploadProgress({ loaded: ev.loaded, total: ev.total }); + } else { + opts.onUploadProgress({ loaded: ev.loaded, total: 0 }); + } + }; + + xhr.onload = () => { + void (async () => { + const status = xhr.status; + let data: any = null; + if (xhr.responseText) { + try { + data = JSON.parse(xhr.responseText) as any; + } catch { + data = null; + } + } + + if ((status === 401 || status === 403) && !endpoint.includes('/auth/refresh') && retryCount === 0) { + if (status === 403) { + if (browser) { + toast.error('No tienes permisos para realizar esta acción', { + duration: 4000, + description: 'Contacta a tu administrador si crees que esto es un error' + }); + } + resolve({ + error: data?.detail || 'No tienes permisos para realizar esta acción', + status: 403 + }); + return; + } + + isRefreshing = true; + try { + const newToken = await refreshToken(); + if (newToken) { + onTokenRefreshed(newToken); + isRefreshing = false; + resolve(await fetchApiFormDataPost(endpoint, formData, { ...opts, retryCount: 1 })); + } else { + console.error('❌ [API] No se pudo refrescar el token'); + isRefreshing = false; + resolve({ + error: 'Sesión expirada. Por favor, inicia sesión nuevamente.', + status: 401 + }); + } + } catch (refreshError) { + console.error('❌ [API] Error al refrescar:', refreshError); + isRefreshing = false; + resolve({ + error: 'Error al refrescar la sesión', + status: 401 + }); + } + return; + } + + if (status === 204) { + resolve({ + data: null as T, + status + }); + return; + } + + if (status === 0) { + resolve({ + error: 'Error de conexión con el servidor', + status: 0 + }); + return; + } + + if (status < 200 || status >= 300) { + if (status === 422 && data) { + if (data.errors && Array.isArray(data.errors)) { + resolve({ + error: data.message || 'Error de validación', + validationErrors: data.errors, + 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 { const token = getToken(); const headers: Record = { @@ -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`), diff --git a/frontend/src/lib/api/dashboard/a76/tasks.ts b/frontend/src/lib/api/dashboard/a76/tasks.ts index 1bd7b194..0242ffb3 100644 --- a/frontend/src/lib/api/dashboard/a76/tasks.ts +++ b/frontend/src/lib/api/dashboard/a76/tasks.ts @@ -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; diff --git a/frontend/src/lib/components/dashboard/csv-upload/CsvPendingImportsSheet.svelte b/frontend/src/lib/components/dashboard/csv-upload/CsvPendingImportsSheet.svelte new file mode 100644 index 00000000..5ffb0f1a --- /dev/null +++ b/frontend/src/lib/components/dashboard/csv-upload/CsvPendingImportsSheet.svelte @@ -0,0 +1,215 @@ + + + + + + + + Importaciones pendientes de confirmar + + Escaneos listos para insertar en base de datos. Si el trabajo ya expiró en el servidor, desaparecerán al + actualizar. + + +
+ +
+
+ {#if rows.length === 0 && !refreshing} +

No hay importaciones pendientes para esta empresa.

+ {:else if rows.length === 0 && refreshing} +

Comprobando con el servidor…

+ {:else} +
    + {#each rows as row (row.jobId)} +
  • +
    + {row.label || profileLabel(row.profile)} +
    +
    {profileLabel(row.profile)}
    + {#if row.totalRows != null || row.validRows != null} +
    + {#if row.totalRows != null} + Total filas: {row.totalRows} + {/if} + {#if row.validRows != null} + Válidas: {row.validRows} + {/if} +
    + {/if} +
    {row.jobId}
    +
    + + +
    +
  • + {/each} +
+ {/if} +
+
+
diff --git a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte index 154e6acb..05a98de3 100644 --- a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte +++ b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte @@ -480,32 +480,58 @@ -
- {#if isPending} - - - {:else if isFinished} - +
+ {#if isPending && isUploading} +
+

Importando registros…

+
+
+
+
{/if} +
+ {#if isPending} + + + {:else if isFinished} + + {/if} +
+ + diff --git a/frontend/src/lib/components/ui/progress/progress.svelte b/frontend/src/lib/components/ui/progress/progress.svelte index 1a45e279..9e6fac9c 100644 --- a/frontend/src/lib/components/ui/progress/progress.svelte +++ b/frontend/src/lib/components/ui/progress/progress.svelte @@ -21,7 +21,7 @@ {...$$restProps} >
diff --git a/frontend/src/lib/csv-import-pending.ts b/frontend/src/lib/csv-import-pending.ts new file mode 100644 index 00000000..fee48340 --- /dev/null +++ b/frontend/src/lib/csv-import-pending.ts @@ -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; + 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; + 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 & { 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 +): 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); +} diff --git a/frontend/src/lib/csv-import-session.ts b/frontend/src/lib/csv-import-session.ts new file mode 100644 index 00000000..b895ca85 --- /dev/null +++ b/frontend/src/lib/csv-import-session.ts @@ -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; + 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 { + // + } +} diff --git a/frontend/src/lib/csv-import-status-api.ts b/frontend/src/lib/csv-import-status-api.ts new file mode 100644 index 00000000..6dd46f68 --- /dev/null +++ b/frontend/src/lib/csv-import-status-api.ts @@ -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 { + 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; + if (d.status === 'waiting_confirmation') return true; + return typeof d.job_id === 'string' && typeof d.total_rows === 'number'; +} diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 3d9820a7..12e28e3f 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -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(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 @@
diff --git a/frontend/src/routes/dashboard/audit_logs/tasks-tab.svelte b/frontend/src/routes/dashboard/audit_logs/tasks-tab.svelte index 43c94c52..957e4c1e 100644 --- a/frontend/src/routes/dashboard/audit_logs/tasks-tab.svelte +++ b/frontend/src/routes/dashboard/audit_logs/tasks-tab.svelte @@ -182,7 +182,6 @@ Task ID Tipo - Empresa Estado Progreso Reintentos @@ -192,11 +191,11 @@ {#if tasks.length === 0 && !loading} - Sin tareas registradas + Sin tareas registradas {:else if tasks.length === 0 && loading} - Cargando... + Cargando... {:else} {#each tasks as task} @@ -208,7 +207,6 @@ > {task.task_id} {task.task_group} / {task.task_name} - {task.company_id ?? '—'} {statusLabel(task.status)} + 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 | 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('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(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(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) { + 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(); + if (currentJobId) ids.add(currentJobId); + if (scanPhaseJobId) ids.add(scanPhaseJobId); + for (const id of ids) removeCsvImportPending(id); + } + + function resumePendingImport(entry: CsvImportPendingEntry, scanPayload: Record) { + 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 = {}; 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); 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 @@
-
+

Importación Masiva de Datos (CSV)

+
@@ -758,6 +1125,49 @@
+ {#if isUploading && !showResultModal} +
+ {#if csvResumeOverlayHint} +

+ Reanudando la importación guardada en esta pestaña… +

+ {/if} + {#if currentImportLabel} +

{currentImportLabel}

+ {/if} +
+

{csvProgressStepTitle}

+ {#if csvProgressPercentText} + + {/if} +
+ {#if csvProgressDetailLine} +

{csvProgressDetailLine}

+ {/if} +
+ {#if csvProgressBarIndeterminate} +
+
+
+ {:else} + + {/if} +
+
+ {/if} +
@@ -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} + + diff --git a/scripts/run_layout_import_janitor.sh b/scripts/run_layout_import_janitor.sh new file mode 100644 index 00000000..f4c99089 --- /dev/null +++ b/scripts/run_layout_import_janitor.sh @@ -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