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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
172
backend/api/v1/modules/a76/layouts_csv/common/victor.py
Normal file
172
backend/api/v1/modules/a76/layouts_csv/common/victor.py
Normal 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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
151
frontend/src/lib/csv-import-pending.ts
Normal file
151
frontend/src/lib/csv-import-pending.ts
Normal 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);
|
||||
}
|
||||
131
frontend/src/lib/csv-import-session.ts
Normal file
131
frontend/src/lib/csv-import-session.ts
Normal 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 {
|
||||
//
|
||||
}
|
||||
}
|
||||
49
frontend/src/lib/csv-import-status-api.ts
Normal file
49
frontend/src/lib/csv-import-status-api.ts
Normal 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';
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
8
scripts/run_layout_import_janitor.sh
Normal file
8
scripts/run_layout_import_janitor.sh
Normal 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
|
||||
Reference in New Issue
Block a user