diff --git a/backend/api/v1/modules/a76/layouts_csv/boms/routes.py b/backend/api/v1/modules/a76/layouts_csv/boms/routes.py index f99183d5..f7d46af7 100644 --- a/backend/api/v1/modules/a76/layouts_csv/boms/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/boms/routes.py @@ -28,6 +28,7 @@ from .tasks import ( ) from ..common.error_csv import download_scan_errors_csv_stream from ..common.track_commit_dispatch import dispatch_tracked_layouts_csv_commit +from ..common.responses import normalize_commit_status_payload router = APIRouter() logger = logging.getLogger(__name__) @@ -127,12 +128,12 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} result = getattr(task_result, "result", None) if isinstance(result, dict) and result.get("status") in ("finished", "warning"): - return result + return normalize_commit_status_payload(result) logger.warning("BOMs import task %s failed: state=%s", job_id, task_result.state) err_msg = None diff --git a/backend/api/v1/modules/a76/layouts_csv/boms/tasks.py b/backend/api/v1/modules/a76/layouts_csv/boms/tasks.py index 99e9c6d4..9f249de4 100644 --- a/backend/api/v1/modules/a76/layouts_csv/boms/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/boms/tasks.py @@ -62,8 +62,7 @@ def scan_file(self, job_id: str, config: str = None): try: with open(error_path, "w", encoding="utf-8") as f_err: for i, row in common_csv.iter_csv_rows(file_path): - if i % 500 == 0: - self.update_state( + self.update_state( state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}, ) diff --git a/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/routes.py b/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/routes.py index 9f8a4ccd..d8612d2e 100644 --- a/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/routes.py @@ -22,6 +22,7 @@ from ..common.track_commit_dispatch import dispatch_tracked_layouts_csv_commit from .schemas import ImportJobResponse, CommitRequest from .tasks import scan_file, insert_valid_rows, JOB_TYPE, CRREG_IMPORT_REDIS_TTL from ..common import storage as common_storage +from ..common.responses import normalize_commit_status_payload from ..common.error_csv import download_scan_errors_csv_stream router = APIRouter() @@ -133,11 +134,11 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} if isinstance(getattr(task_result, "result", None), dict) and task_result.result.get("status") in ("finished", "warning"): - return task_result.result + return normalize_commit_status_payload(task_result.result) logger.warning("Cambio régimen/Regularización import task %s failed: state=%s", job_id, task_result.state) err_msg = None diff --git a/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/tasks.py b/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/tasks.py index c06ce2b9..a0a6c545 100644 --- a/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/tasks.py @@ -75,8 +75,7 @@ def scan_file(self, job_id: str, model_target: str, config: str = None): try: for i, row in common_csv_reader.iter_csv_rows(file_path, fieldnames=None): - if i % 500 == 0: - on_progress(i, total_rows) + on_progress(i, total_rows) _norm_row(row, template_id) processed_rows += 1 except Exception as e: diff --git a/backend/api/v1/modules/a76/layouts_csv/classes/routes.py b/backend/api/v1/modules/a76/layouts_csv/classes/routes.py index 53464802..0d711a72 100644 --- a/backend/api/v1/modules/a76/layouts_csv/classes/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/classes/routes.py @@ -19,6 +19,7 @@ from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch from ..common.track_commit_dispatch import dispatch_tracked_layouts_csv_commit +from ..common.responses import normalize_commit_status_payload from .schemas import ImportJobResponse from .tasks import ( scan_file, @@ -133,12 +134,12 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} result = getattr(task_result, "result", None) if isinstance(result, dict) and result.get("status") in ("finished", "warning"): - return result + return normalize_commit_status_payload(result) # Si Celery devolvió el resultado como string (p. ej. JSON), parsear y devolver como scan si aplica if isinstance(result, str): @@ -150,7 +151,7 @@ async def get_import_status(job_id: str): ): return parsed if isinstance(parsed, dict) and parsed.get("status") in ("finished", "warning"): - return parsed + return normalize_commit_status_payload(parsed) except (json.JSONDecodeError, TypeError): pass diff --git a/backend/api/v1/modules/a76/layouts_csv/classes/tasks.py b/backend/api/v1/modules/a76/layouts_csv/classes/tasks.py index a455ce8b..1d1abb43 100644 --- a/backend/api/v1/modules/a76/layouts_csv/classes/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/classes/tasks.py @@ -84,8 +84,7 @@ def scan_file(self, job_id: str, config: str = None): try: with open(error_path, "w", encoding="utf-8") as f_err: for i, row in common_csv.iter_csv_rows(file_path, fieldnames=fieldnames): - if i % 500 == 0: - self.update_state( + self.update_state( state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}, ) diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py index fcc092dd..a9f14507 100644 --- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py @@ -19,6 +19,7 @@ from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch from ..common.track_commit_dispatch import dispatch_tracked_layouts_csv_commit +from ..common.responses import normalize_commit_status_payload from .schemas import ImportJobResponse from .tasks import ( scan_file, @@ -135,13 +136,13 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} # A veces Celery tiene el result disponible pero state aún no es SUCCESS; si el result es éxito, devolverlo result = getattr(task_result, "result", None) if isinstance(result, dict) and result.get("status") in ("finished", "warning"): - return result + return normalize_commit_status_payload(result) logger.warning("CP import task %s failed: state=%s", job_id, task_result.state) err_msg = None diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/tasks.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/tasks.py index 21cb2e46..411bcc42 100644 --- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/tasks.py @@ -77,7 +77,7 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str, try: with open(error_path, "w", encoding="utf-8") as f_err: for i, row in common_csv_reader.iter_csv_rows(file_path): - if progress_callback and i % 500 == 0: + if progress_callback: progress_callback(i, total_rows, error_count) row_norm = row_from_template(row, common_normalize.normalize_header) diff --git a/backend/api/v1/modules/a76/layouts_csv/common/responses.py b/backend/api/v1/modules/a76/layouts_csv/common/responses.py index 848a2a3c..cac67a28 100644 --- a/backend/api/v1/modules/a76/layouts_csv/common/responses.py +++ b/backend/api/v1/modules/a76/layouts_csv/common/responses.py @@ -72,3 +72,36 @@ def commit_result( if error: out["error"] = error return out + + +def normalize_commit_status_payload(d: Dict[str, Any]) -> Dict[str, Any]: + """ + Homogeneiza respuestas de commit CSV (finished / warning) al contrato usado por pedimentos: + inserted, updated, skipped_*, skipped_details, skipped_summary, critical_reference_gaps, + reference_state_ready. No modifica waiting_confirmation ni otros status. + """ + if not isinstance(d, dict): + return d + st = d.get("status") + if st not in ("finished", "warning"): + return d + out = dict(d) + out.setdefault("inserted", 0) + out.setdefault("updated", 0) + out.setdefault("skipped_invalid", 0) + out.setdefault("skipped_missing_fk", 0) + out.setdefault("skipped_duplicate", 0) + out.setdefault("skipped_missing_invoice", 0) + details = out.get("skipped_details") + if not isinstance(details, list): + out["skipped_details"] = [] + else: + out["skipped_details"] = details + out.setdefault("critical_reference_gaps", 0) + if "reference_state_ready" not in out: + out["reference_state_ready"] = out.get("critical_reference_gaps", 0) == 0 + if "skipped_summary" not in out: + out["skipped_summary"] = ( + _summarize_reasons(out["skipped_details"], "reason") if out["skipped_details"] else [] + ) + return out diff --git a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/routes.py b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/routes.py index f0cebaaf..dc17a477 100644 --- a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/routes.py @@ -19,6 +19,7 @@ from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch from ..common.track_commit_dispatch import dispatch_tracked_layouts_csv_commit +from ..common.responses import normalize_commit_status_payload from .schemas import ImportJobResponse from .tasks import ( scan_file, @@ -135,13 +136,13 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} # A veces Celery tiene el result disponible pero state aún no es SUCCESS; si el result es éxito, devolverlo result = getattr(task_result, "result", None) if isinstance(result, dict) and result.get("status") in ("finished", "warning"): - return result + return normalize_commit_status_payload(result) logger.warning("CB import task %s failed: state=%s", job_id, task_result.state) err_msg = None diff --git a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/tasks.py b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/tasks.py index 1ddb3e51..0c13b14f 100644 --- a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/tasks.py @@ -88,7 +88,7 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str, fieldnames=CUSTOMS_BROKERS_FIELDNAMES_ORDER, headerless_first_cell_values=CUSTOMS_BROKERS_HEADERLESS_FIRST_CELL, ): - if progress_callback and i % 500 == 0: + if progress_callback: progress_callback(i, total_rows, error_count) row_norm = row_from_template(row, common_normalize.normalize_header) diff --git a/backend/api/v1/modules/a76/layouts_csv/drivers/routes.py b/backend/api/v1/modules/a76/layouts_csv/drivers/routes.py index 4a155a6c..bb118f17 100644 --- a/backend/api/v1/modules/a76/layouts_csv/drivers/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/drivers/routes.py @@ -28,6 +28,7 @@ from .tasks import ( DRV_IMPORT_REDIS_TTL, ) from ..common.error_csv import download_scan_errors_csv_stream +from ..common.responses import normalize_commit_status_payload router = APIRouter() logger = logging.getLogger(__name__) @@ -113,6 +114,8 @@ async def get_import_status(job_id: str): raw = r.get(f"{DRV_IMPORT_STATUS_PREFIX}{job_id}") if raw: data = json.loads(raw.decode("utf-8")) + if isinstance(data, dict) and data.get("status") in ("finished", "warning"): + return normalize_commit_status_payload(data) return data except Exception as e: logger.debug(f"Drivers import: could not read status from Redis: {e}") @@ -131,12 +134,12 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} result = getattr(task_result, "result", None) if isinstance(result, dict) and result.get("status") in ("finished", "warning"): - return result + return normalize_commit_status_payload(result) logger.warning("Drivers import task %s failed: state=%s", job_id, task_result.state) err_msg = None diff --git a/backend/api/v1/modules/a76/layouts_csv/drivers/tasks.py b/backend/api/v1/modules/a76/layouts_csv/drivers/tasks.py index f81a341b..c03e88a1 100644 --- a/backend/api/v1/modules/a76/layouts_csv/drivers/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/drivers/tasks.py @@ -135,7 +135,7 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str, dict_reader = csv.DictReader(f_in, fieldnames=headers, dialect=dialect) for i, row in enumerate(dict_reader, start=1): - if progress_callback and i % 500 == 0: + if progress_callback: progress_callback(i, total_rows, error_count) row_norm = row_from_template(row, common_normalize.normalize_header) diff --git a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/routes.py b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/routes.py index db570733..4887092f 100644 --- a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/routes.py @@ -19,6 +19,7 @@ from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch from ..common.track_commit_dispatch import dispatch_tracked_layouts_csv_commit +from ..common.responses import normalize_commit_status_payload from .schemas import ImportJobResponse from .tasks import ( scan_file, @@ -146,12 +147,12 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} result = getattr(task_result, "result", None) if isinstance(result, dict) and result.get("status") in ("finished", "warning"): - return result + return normalize_commit_status_payload(result) logger.warning("ER import task %s failed: state=%s", job_id, task_result.state) err_msg = None diff --git a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/tasks.py b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/tasks.py index c8f5521e..3ebf4bbd 100644 --- a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/tasks.py @@ -66,7 +66,7 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str, try: with open(error_path, "w", encoding="utf-8") as f_err: for i, row in common_csv_reader.iter_csv_rows(file_path): - if progress_callback and i % 500 == 0: + if progress_callback: progress_callback(i, total_rows, error_count) row_norm = _norm_row(row) diff --git a/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py b/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py index dca65485..49eba2b8 100644 --- a/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py @@ -25,6 +25,7 @@ from .schemas import ImportJobResponse, CommitRequest from .tasks import scan_file, insert_valid_rows, JOB_TYPE, EXP_IMPORT_REDIS_TTL from ..common import storage as common_storage from ..common.track_commit_dispatch import dispatch_tracked_layouts_csv_commit +from ..common.responses import normalize_commit_status_payload router = APIRouter() logger = logging.getLogger(__name__) @@ -138,11 +139,11 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} if isinstance(getattr(task_result, "result", None), dict) and task_result.result.get("status") in ("finished", "warning"): - return task_result.result + return normalize_commit_status_payload(task_result.result) logger.warning("Exportación import task %s failed: state=%s", job_id, task_result.state) err_msg = None diff --git a/backend/api/v1/modules/a76/layouts_csv/exportacion/tasks.py b/backend/api/v1/modules/a76/layouts_csv/exportacion/tasks.py index 2895bf40..bbd06283 100644 --- a/backend/api/v1/modules/a76/layouts_csv/exportacion/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/exportacion/tasks.py @@ -74,8 +74,7 @@ def scan_file(self, job_id: str, model_target: str, config: str = None): processed_rows = 0 try: for i, row in common_csv_reader.iter_csv_rows(file_path, fieldnames=None): - if i % 500 == 0: - self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows}) + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows}) _norm_row(row, template_id) processed_rows += 1 except Exception as e: diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py b/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py index c9ac5285..0ae9556f 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py @@ -27,6 +27,7 @@ from .tasks import ( ) from .schemas import ImportJobResponse, ImportJobStatus, CommitRequest from ..common import storage as common_storage +from ..common.responses import normalize_commit_status_payload from ..common.track_commit_dispatch import dispatch_tracked_layouts_csv_commit router = APIRouter() @@ -150,7 +151,7 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} # FAILURE: obtener mensaje real (traceback, result o get(propagate=False)) logger.warning("Import task %s failed: state=%s", job_id, task_result.state) diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py b/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py index f2fda307..c364981e 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py @@ -628,8 +628,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = errors_detail = [] error_lines_list: List[int] = [] for i, row in enumerate(reader, start=1): - if i % 1000 == 0: - self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": len(error_lines_list)}) + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": len(error_lines_list)}) row_norm = row_from_template(row, "imp_def_series", normalize_header) warnings_list: List[Dict[str, Any]] = [] row_errors = validate_row_series_impo_def( @@ -822,8 +821,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = errors_detail = [] error_lines_list: List[int] = [] for i, row in enumerate(reader, start=1): - if i % 1000 == 0: - self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": len(error_lines_list)}) + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": len(error_lines_list)}) row_norm = row_from_template(row, "exp_def_series", normalize_header) warnings_list: List[Dict[str, Any]] = [] row_errors = validate_row_series_expo( @@ -1004,8 +1002,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = errors_detail = [] error_lines_list: List[int] = [] for i, row in enumerate(reader, start=1): - if i % 1000 == 0: - self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": len(error_lines_list)}) + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": len(error_lines_list)}) row_norm = row_from_template(row, "cmex_series", normalize_header) warnings_list: List[Dict[str, Any]] = [] row_errors = validate_row_series_impo_def( @@ -1159,8 +1156,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = errors_detail: List[Dict[str, Any]] = [] error_lines_list: List[int] = [] for i, row in enumerate(reader, start=1): - if i % 1000 == 0: - self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": len(error_lines_list)}) + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": len(error_lines_list)}) row_norm = row_from_template(row, "imp_temp_series", normalize_header) warnings_list: List[Dict[str, Any]] = [] row_errors = validate_row_series_impo_temp( @@ -1495,8 +1491,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = with open(error_path, "w", encoding="utf-8") as f_err: for i, row in enumerate(rows_list, start=1): - if i % 1000 == 0: - self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) row_norm = row_from_template(row, "imp_temp_details", normalize_header) row_errors = validate_row_partidas_impo_temp( row_norm, @@ -1806,8 +1801,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = with open(error_path, "w", encoding="utf-8") as f_err: for i, row in enumerate(rows_list, start=1): - if i % 1000 == 0: - self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) row_norm = row_from_template(row, "imp_def_details", normalize_header) row_errors = validate_row_partidas_impo_def( row_norm, @@ -2373,8 +2367,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = with open(error_path, "w", encoding="utf-8") as f_err: for i, row in enumerate(rows_list, start=1): - if i % 1000 == 0: - self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) row_norm = row_from_template(row, "cmex_details", normalize_header) row_errors = validate_row_partidas_impo_def( row_norm, @@ -2831,8 +2824,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = + "\n" ) for i, row in enumerate(rows_list, start=1): - if i % 1000 == 0: - self.update_state( + self.update_state( state="PROGRESS", meta={ "current": i, @@ -3205,8 +3197,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = with open(error_path, "w", encoding="utf-8") as f_err: for i, row in enumerate(rows_list, start=1): - if i % 1000 == 0: - self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) row_norm = row_from_template(row, "imp_def_header", normalize_header) warnings_row = [] row_errors = validate_row_encabezados_impo_def( @@ -3582,8 +3573,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = with open(error_path, "w", encoding="utf-8") as f_err: for i, row in enumerate(rows_list, start=1): - if i % 1000 == 0: - self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) row_norm = row_from_template(row, "exp_def_header", normalize_header) warnings_row = [] row_errors = validate_row_encabezados_expo( @@ -3798,8 +3788,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = with CoreSessionLocal() as session: with open(error_path, "w", encoding="utf-8") as f_err: for i, row in enumerate(rows_list, start=1): - if i % 1000 == 0: - self.update_state( + self.update_state( state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}, ) @@ -3949,12 +3938,11 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = for i, row in enumerate(reader, start=1): # Check for Progress Update - if i % 1000 == 0: - self.update_state(state='PROGRESS', meta={ - 'current': i, - 'total': total_rows, - 'errors': error_count - }) + self.update_state(state='PROGRESS', meta={ + 'current': i, + 'total': total_rows, + 'errors': error_count + }) # Solo columnas de la plantilla (respetar plantilla tal cual) row_norm = row_from_template(row, template_id, normalize_header) diff --git a/backend/api/v1/modules/a76/layouts_csv/parts/routes.py b/backend/api/v1/modules/a76/layouts_csv/parts/routes.py index 5184073e..6e1fb792 100644 --- a/backend/api/v1/modules/a76/layouts_csv/parts/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/parts/routes.py @@ -28,6 +28,7 @@ from .tasks import ( ) from ..common.error_csv import download_scan_errors_csv_stream from ..common.track_commit_dispatch import dispatch_tracked_layouts_csv_commit +from ..common.responses import normalize_commit_status_payload router = APIRouter() logger = logging.getLogger(__name__) @@ -131,12 +132,12 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} result = getattr(task_result, "result", None) if isinstance(result, dict) and result.get("status") in ("finished", "warning"): - return result + return normalize_commit_status_payload(result) # Si Celery devolvió el resultado como string (p. ej. JSON), parsear y devolver como scan si aplica if isinstance(result, str): @@ -148,7 +149,7 @@ async def get_import_status(job_id: str): ): return parsed if isinstance(parsed, dict) and parsed.get("status") in ("finished", "warning"): - return parsed + return normalize_commit_status_payload(parsed) except (json.JSONDecodeError, TypeError): pass diff --git a/backend/api/v1/modules/a76/layouts_csv/parts/tasks.py b/backend/api/v1/modules/a76/layouts_csv/parts/tasks.py index 4c3088ed..6d2fe8c2 100644 --- a/backend/api/v1/modules/a76/layouts_csv/parts/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/parts/tasks.py @@ -94,8 +94,7 @@ def scan_file(self, job_id: str, config: str = None): try: with open(error_path, "w", encoding="utf-8") as f_err: for i, row in common_csv.iter_csv_rows(file_path, fieldnames=fieldnames): - if i % 500 == 0: - self.update_state( + self.update_state( state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}, ) diff --git a/backend/api/v1/modules/a76/layouts_csv/pedmientos/routes.py b/backend/api/v1/modules/a76/layouts_csv/pedmientos/routes.py index eda3ea86..69e26d65 100644 --- a/backend/api/v1/modules/a76/layouts_csv/pedmientos/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/pedmientos/routes.py @@ -19,6 +19,7 @@ from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch from ..common.track_commit_dispatch import dispatch_tracked_layouts_csv_commit +from ..common.responses import normalize_commit_status_payload from .schemas import ImportJobResponse from .tasks import ( scan_file, @@ -143,12 +144,12 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} result = getattr(task_result, "result", None) if isinstance(result, dict) and result.get("status") in ("finished", "warning"): - return result + return normalize_commit_status_payload(result) logger.warning("Pedimentos import task %s failed: state=%s", job_id, task_result.state) err_msg = None diff --git a/backend/api/v1/modules/a76/layouts_csv/pedmientos/tasks.py b/backend/api/v1/modules/a76/layouts_csv/pedmientos/tasks.py index 1ac7aa76..5bd482a9 100644 --- a/backend/api/v1/modules/a76/layouts_csv/pedmientos/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/pedmientos/tasks.py @@ -99,7 +99,7 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str, try: with open(error_path, "w", encoding="utf-8") as f_err: for i, row in common_csv_reader.iter_csv_rows(file_path, fieldnames=fieldnames): - if progress_callback and i % 500 == 0: + if progress_callback: progress_callback(i, total_rows, error_count) row_norm = _norm_row(row) diff --git a/backend/api/v1/modules/a76/layouts_csv/trailers/routes.py b/backend/api/v1/modules/a76/layouts_csv/trailers/routes.py index f1543f78..73547beb 100644 --- a/backend/api/v1/modules/a76/layouts_csv/trailers/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/trailers/routes.py @@ -30,6 +30,7 @@ from .tasks import ( TRL_IMPORT_REDIS_TTL, ) from ..common.error_csv import download_scan_errors_csv_stream +from ..common.responses import normalize_commit_status_payload router = APIRouter() logger = logging.getLogger(__name__) @@ -132,6 +133,8 @@ async def get_import_status(job_id: str): raw = r.get(f"{TRL_IMPORT_STATUS_PREFIX}{job_id}") if raw: data = json.loads(raw.decode("utf-8")) + if isinstance(data, dict) and data.get("status") in ("finished", "warning"): + return normalize_commit_status_payload(data) return data except Exception as e: logger.debug(f"Trailers import: could not read status from Redis: {e}") @@ -150,12 +153,12 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} result = getattr(task_result, "result", None) if isinstance(result, dict) and result.get("status") in ("finished", "warning"): - return result + return normalize_commit_status_payload(result) logger.warning("Trailers import task %s failed: state=%s", job_id, task_result.state) err_msg = None diff --git a/backend/api/v1/modules/a76/layouts_csv/trailers/tasks.py b/backend/api/v1/modules/a76/layouts_csv/trailers/tasks.py index a920076b..78a3ec24 100644 --- a/backend/api/v1/modules/a76/layouts_csv/trailers/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/trailers/tasks.py @@ -121,7 +121,7 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str, dict_reader = csv.DictReader(f_in, fieldnames=headers, dialect=dialect) for i, row in enumerate(dict_reader, start=1): - if progress_callback and i % 500 == 0: + if progress_callback: progress_callback(i, total_rows, error_count) row_norm = row_from_template(row, common_normalize.normalize_header) diff --git a/backend/api/v1/modules/a76/layouts_csv/transportistas/routes.py b/backend/api/v1/modules/a76/layouts_csv/transportistas/routes.py index 18671ddb..518b3d3f 100644 --- a/backend/api/v1/modules/a76/layouts_csv/transportistas/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/transportistas/routes.py @@ -30,6 +30,7 @@ from .tasks import ( TRP_IMPORT_REDIS_TTL, ) from ..common.error_csv import download_scan_errors_csv_stream +from ..common.responses import normalize_commit_status_payload router = APIRouter() logger = logging.getLogger(__name__) @@ -132,6 +133,8 @@ async def get_import_status(job_id: str): raw = r.get(f"{TRP_IMPORT_STATUS_PREFIX}{job_id}") if raw: data = json.loads(raw.decode("utf-8")) + if isinstance(data, dict) and data.get("status") in ("finished", "warning"): + return normalize_commit_status_payload(data) return data except Exception as e: logger.debug("Transportistas import: could not read status from Redis: %s", e) @@ -150,12 +153,12 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} result = getattr(task_result, "result", None) if isinstance(result, dict) and result.get("status") in ("finished", "warning"): - return result + return normalize_commit_status_payload(result) logger.warning("Transportistas import task %s failed: state=%s", job_id, task_result.state) err_msg = None diff --git a/backend/api/v1/modules/a76/layouts_csv/transportistas/tasks.py b/backend/api/v1/modules/a76/layouts_csv/transportistas/tasks.py index a1c9ac00..09664a24 100644 --- a/backend/api/v1/modules/a76/layouts_csv/transportistas/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/transportistas/tasks.py @@ -79,7 +79,7 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str, try: with open(error_path, "w", encoding="utf-8") as f_err: for i, row in common_csv_reader.iter_csv_rows(file_path): - if progress_callback and i % 500 == 0: + if progress_callback: progress_callback(i, total_rows, error_count) row_norm = row_from_template(row, common_normalize.normalize_header) diff --git a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/routes.py b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/routes.py index afb8c3bb..b35846f5 100644 --- a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/routes.py @@ -19,6 +19,7 @@ from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch from ..common.track_commit_dispatch import dispatch_tracked_layouts_csv_commit +from ..common.responses import normalize_commit_status_payload from .schemas import ImportJobResponse from .tasks import ( scan_file, @@ -138,12 +139,12 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} result = getattr(task_result, "result", None) if isinstance(result, dict) and result.get("status") in ("finished", "warning"): - return result + return normalize_commit_status_payload(result) logger.warning("FA import task %s failed: state=%s", job_id, task_result.state) err_msg = None diff --git a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/tasks.py b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/tasks.py index 1924e229..06d8d5f2 100644 --- a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/tasks.py @@ -68,7 +68,7 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str, try: with open(error_path, "w", encoding="utf-8") as f_err: for i, row in common_csv_reader.iter_csv_rows(file_path): - if progress_callback and i % 500 == 0: + if progress_callback: progress_callback(i, total_rows, error_count) warn = validate_row_desfase_fa(row, i) diff --git a/backend/api/v1/modules/a76/layouts_csv/vehicles/routes.py b/backend/api/v1/modules/a76/layouts_csv/vehicles/routes.py index da98d135..95539a68 100644 --- a/backend/api/v1/modules/a76/layouts_csv/vehicles/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/vehicles/routes.py @@ -30,6 +30,7 @@ from .tasks import ( VEHL_IMPORT_REDIS_TTL, ) from ..common.error_csv import download_scan_errors_csv_stream +from ..common.responses import normalize_commit_status_payload router = APIRouter() logger = logging.getLogger(__name__) @@ -131,6 +132,8 @@ async def get_import_status(job_id: str): raw = r.get(f"{VEHL_IMPORT_STATUS_PREFIX}{job_id}") if raw: data = json.loads(raw.decode("utf-8")) + if isinstance(data, dict) and data.get("status") in ("finished", "warning"): + return normalize_commit_status_payload(data) return data except Exception as e: logger.debug(f"Vehicles import: could not read status from Redis: {e}") @@ -149,12 +152,12 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} result = getattr(task_result, "result", None) if isinstance(result, dict) and result.get("status") in ("finished", "warning"): - return result + return normalize_commit_status_payload(result) logger.warning("Vehicles import task %s failed: state=%s", job_id, task_result.state) err_msg = None diff --git a/backend/api/v1/modules/a76/layouts_csv/vehicles/tasks.py b/backend/api/v1/modules/a76/layouts_csv/vehicles/tasks.py index 9bebb21a..ad9608ed 100644 --- a/backend/api/v1/modules/a76/layouts_csv/vehicles/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/vehicles/tasks.py @@ -92,7 +92,7 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str, try: with open(error_path, "w", encoding="utf-8") as f_err: for i, row in common_csv_reader.iter_csv_rows(file_path): - if progress_callback and i % 500 == 0: + if progress_callback: progress_callback(i, total_rows, error_count) row_norm = row_from_template(row, common_normalize.normalize_header) diff --git a/frontend/src/lib/components/dashboard/csv-upload/CsvParamsBar.svelte b/frontend/src/lib/components/dashboard/csv-upload/CsvParamsBar.svelte index 45c640e8..38278510 100644 --- a/frontend/src/lib/components/dashboard/csv-upload/CsvParamsBar.svelte +++ b/frontend/src/lib/components/dashboard/csv-upload/CsvParamsBar.svelte @@ -2,15 +2,19 @@ import { Label } from '$lib/components/ui/label/index.js'; import { Settings2 } from 'lucide-svelte'; import { globalCsvParams, tabSettings, type CsvUploadField } from '$lib/config/csv-upload'; + import { cn } from '$lib/utils'; let { globalSettings = $bindable(), activeTab, - tabSettingsValues = $bindable() + tabSettingsValues = $bindable(), + embedded = false }: { globalSettings: Record; activeTab: string; tabSettingsValues: Record; + /** Si true, el padre aporta el contenedor fijo (p. ej. pie apilado con progreso CSV). */ + embedded?: boolean; } = $props(); const globalParamNames = $derived(new Set(globalCsvParams.map((p) => p.name))); @@ -27,7 +31,12 @@
diff --git a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte index 05a98de3..dd342907 100644 --- a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte +++ b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte @@ -11,6 +11,11 @@ } from 'lucide-svelte'; import { tick } from 'svelte'; import { api } from '$lib/api'; + import { + totalSkippedFromCommit, + criticalReferenceGaps, + referenceStateReady + } from '$lib/csv-import-commit-metrics'; let { open = $bindable(false), @@ -50,29 +55,33 @@ let isPending = $derived(!!scanResults && !commitResults); let isFinished = $derived(!!commitResults); - // Derived metrics for UI logic - let hasErrors = $derived( - scanResults?.error_count > 0 || - commitResults?.skipped_invalid > 0 || - commitResults?.skipped_missing_fk > 0 - ); - let scanErrorsShown = $derived(Array.isArray(scanResults?.errors) ? scanResults.errors.length : 0); let scanErrorsTotal = $derived(scanResults?.error_count || 0); let scanErrorsTruncated = $derived(scanErrorsShown > 0 && scanErrorsShown < scanErrorsTotal); - let totalSkipped = $derived( - (commitResults?.skipped_invalid || 0) + - (commitResults?.skipped_missing_fk || 0) + - (commitResults?.skipped_missing_invoice || 0) + - (commitResults?.skipped_duplicate || 0) + let scanErrorSummary = $derived( + Array.isArray(scanResults?.error_summary) ? scanResults.error_summary : [] ); - let insertedCount = $derived(commitResults?.inserted || 0); - let updatedCount = $derived(commitResults?.updated || 0); - // Algunos módulos usan `inserted` solo para altas y `updated` para modo "actualizar". - // Para la UI, reflejamos "cambios positivos" como insertados + actualizados. - let insertedOrUpdatedCount = $derived(insertedCount + updatedCount); + let totalSkipped = $derived(totalSkippedFromCommit(commitResults)); + let refGaps = $derived(criticalReferenceGaps(commitResults)); + let refReady = $derived(referenceStateReady(commitResults)); + + let insertedCount = $derived(Number(commitResults?.inserted) || 0); + let updatedCount = $derived(Number(commitResults?.updated) || 0); + + /** Cabecera: errores de escaneo (solo pendiente) o observaciones de commit. */ + let hasErrors = $derived( + (isPending && (scanResults?.error_count ?? 0) > 0) || + (isFinished && + (totalSkipped > 0 || + refGaps > 0 || + commitResults?.status === 'warning')) + ); + + let commitSkippedSummary = $derived( + Array.isArray(commitResults?.skipped_summary) ? commitResults.skipped_summary : [] + ); function handleOpenChange(newOpen: boolean) { if (!newOpen) { @@ -298,6 +307,24 @@
+ {#if scanResults.message} +

+ {scanResults.message} +

+ {/if} + {#if scanErrorSummary.length > 0} +
+ {#each scanErrorSummary as item} + + {(item as { reason?: string; count?: number }).reason ?? '—'} · {(item as { count?: number }) + .count ?? 0} + + {/each} +
+ {/if} + {#if scanResults.error_count > 0}
{#if isFinished}
- -
- +
-
- +
+ Insertados
- {insertedOrUpdatedCount} - {#if updatedCount > 0} -

- {insertedCount} insertados, {updatedCount} actualizados -

- {/if} + {insertedCount}
- -
-
- - + + Actualizados +
+ {updatedCount} +
+
+
+ + Rechazados
{totalSkipped} {#if totalSkipped > 0 && commitResults.skipped_details && commitResults.skipped_details.length > 0} -

+

Revisa el detalle por línea en la tabla inferior.

{/if}
+
+ {#if refGaps > 0} + +
+

Brechas de referencia (FK / catálogos)

+

+ Hay {refGaps} brecha(s) crítica(s) de referencia. Revisa catálogos y el detalle de + filas rechazadas antes de reintentar. +

+
+ {:else} + +
+

Estado de referencias

+

+ {refReady + ? 'Referencias listas para operar (sin brechas críticas reportadas).' + : 'Sin brechas numéricas; revisa el mensaje del servidor si aplica.'} +

+
+ {/if} +
+ + {#if commitResults.message} +

+ {commitResults.message} +

+ {/if} + {#if commitSkippedSummary.length > 0} +
+

+ Resumen de motivos de rechazo +

+
+ {#each commitSkippedSummary as item} + + {(item as { reason?: string; count?: number }).reason ?? '—'} · {(item as { + count?: number; + }).count ?? 0} + + {/each} +
+
+ {/if} + {#if commitResults.skipped_details && commitResults.skipped_details.length > 0}
@@ -466,7 +545,7 @@ {detail.invoice || '-'} - {detail.reason} + {detail.reason ?? '-'} {detail.solution || '-'} {/each} diff --git a/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte b/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte index 35d1f218..0c9bdaaa 100644 --- a/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte +++ b/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte @@ -8,12 +8,17 @@ let { items, - onUpload + onUpload, + busy = false }: { items: CsvUploadItem[]; onUpload: (file: File, config: CsvUploadItem) => void; + /** Bloquea nuevas cargas (p. ej. importación en curso sin overlay). */ + busy?: boolean; } = $props(); + const gridLocked = $derived(busy); + let dragOverId = $state(null); // Group items @@ -34,7 +39,7 @@ }); function handleDragEnter(e: DragEvent, id: string, disabled?: boolean) { - if (disabled) return; + if (disabled || gridLocked) return; e.preventDefault(); e.stopPropagation(); dragOverId = id; @@ -47,7 +52,7 @@ } function handleDragOver(e: DragEvent, disabled?: boolean) { - if (disabled) return; + if (disabled || gridLocked) return; e.preventDefault(); e.stopPropagation(); dragOverId = null; @@ -65,7 +70,7 @@ } function handleDrop(e: DragEvent, item: CsvUploadItem) { - if (item.disabled) return; + if (item.disabled || gridLocked) return; e.preventDefault(); e.stopPropagation(); dragOverId = null; @@ -76,12 +81,13 @@ } function handleClick(id: string, disabled?: boolean) { - if (disabled) return; + if (disabled || gridLocked) return; const input = document.getElementById(`file-input-${id}`) as HTMLInputElement; if (input) input.click(); } function handleFileChange(e: Event, item: CsvUploadItem) { + if (gridLocked) return; const target = e.target as HTMLInputElement; if (target.files && target.files.length > 0) { validateAndUpload(target.files[0], item); @@ -90,7 +96,7 @@ } async function handleContextMenu(e: MouseEvent, item: CsvUploadItem) { - if (item.disabled) { + if (item.disabled || gridLocked) { e.preventDefault(); return; } @@ -117,25 +123,30 @@ } -
+
{#if groupedItems.ungrouped.length > 0}
{#each groupedItems.ungrouped as item}
handleDragEnter(e, item.id, item.disabled)} + ondragenter={(e) => handleDragEnter(e, item.id, item.disabled || gridLocked)} ondragleave={handleDragLeave} - ondragover={(e) => handleDragOver(e, item.disabled)} + ondragover={(e) => handleDragOver(e, item.disabled || gridLocked)} ondrop={(e) => handleDrop(e, item)} oncontextmenu={(e) => handleContextMenu(e, item)} role="button" - tabindex={item.disabled ? -1 : 0} - onclick={() => handleClick(item.id, item.disabled)} - onkeydown={(e) => !item.disabled && e.key === 'Enter' && handleClick(item.id)} + tabindex={item.disabled || gridLocked ? -1 : 0} + onclick={() => handleClick(item.id, item.disabled || gridLocked)} + onkeydown={(e) => + !item.disabled && !gridLocked && e.key === 'Enter' && handleClick(item.id)} > handleFileChange(e, item)} - disabled={item.disabled} + disabled={item.disabled || gridLocked} /> - {#if !item.disabled && dragOverId === item.id} + {#if !item.disabled && !gridLocked && dragOverId === item.id}
@@ -177,7 +188,7 @@
@@ -201,18 +212,19 @@
handleDragEnter(e, item.id, item.disabled)} + ondragenter={(e) => handleDragEnter(e, item.id, item.disabled || gridLocked)} ondragleave={handleDragLeave} - ondragover={(e) => handleDragOver(e, item.disabled)} + ondragover={(e) => handleDragOver(e, item.disabled || gridLocked)} ondrop={(e) => handleDrop(e, item)} oncontextmenu={(e) => handleContextMenu(e, item)} role="button" - tabindex={item.disabled ? -1 : 0} - onclick={() => handleClick(item.id, item.disabled)} - onkeydown={(e) => !item.disabled && e.key === 'Enter' && handleClick(item.id)} + tabindex={item.disabled || gridLocked ? -1 : 0} + onclick={() => handleClick(item.id, item.disabled || gridLocked)} + onkeydown={(e) => + !item.disabled && !gridLocked && e.key === 'Enter' && handleClick(item.id)} > handleFileChange(e, item)} - disabled={item.disabled} + disabled={item.disabled || gridLocked} /> - {#if !item.disabled && dragOverId === item.id} + {#if !item.disabled && !gridLocked && dragOverId === item.id}
@@ -256,7 +268,7 @@
diff --git a/frontend/src/lib/csv-import-commit-metrics.ts b/frontend/src/lib/csv-import-commit-metrics.ts new file mode 100644 index 00000000..0eccf8db --- /dev/null +++ b/frontend/src/lib/csv-import-commit-metrics.ts @@ -0,0 +1,36 @@ +/** + * Métricas homogéneas post-commit CSV (contrato tipo pedimentos + extras p. ej. facturas). + */ +export function totalSkippedFromCommit(cr: Record | null | undefined): number { + if (!cr || typeof cr !== 'object') return 0; + const n = (k: string) => { + const v = (cr as Record)[k]; + if (typeof v === 'number' && Number.isFinite(v)) return v; + if (typeof v === 'string' && v.trim() !== '') return parseInt(v, 10) || 0; + return 0; + }; + return ( + n('skipped_invalid') + + n('skipped_missing_fk') + + n('skipped_missing_invoice') + + n('skipped_duplicate') + ); +} + +export function criticalReferenceGaps(cr: Record | null | undefined): number { + if (!cr || typeof cr !== 'object') return 0; + const v = (cr as Record).critical_reference_gaps; + if (typeof v === 'number' && Number.isFinite(v)) return v; + return 0; +} + +export function referenceStateReady(cr: Record | null | undefined): boolean { + if (!cr || typeof cr !== 'object') return true; + if ('reference_state_ready' in cr) { + const v = (cr as Record).reference_state_ready; + if (typeof v === 'boolean') return v; + if (v === 'True' || v === 'true') return true; + if (v === 'False' || v === 'false') return false; + } + return criticalReferenceGaps(cr) === 0; +} diff --git a/frontend/src/lib/csv-upload-row-count.ts b/frontend/src/lib/csv-upload-row-count.ts new file mode 100644 index 00000000..0fb17515 --- /dev/null +++ b/frontend/src/lib/csv-upload-row-count.ts @@ -0,0 +1,15 @@ +/** + * Cuenta filas de datos (excluye 1 línea de encabezado) en un CSV local. + * Asume primera línea no vacía = encabezado. + */ +export async function countCsvDataRows(file: File): Promise { + const text = await file.text(); + if (!text.trim()) return 0; + const lines = text.split(/\r\n|\r|\n/); + let nonEmpty = 0; + for (const line of lines) { + if (line.trim().length > 0) nonEmpty += 1; + } + if (nonEmpty <= 1) return 0; + return nonEmpty - 1; +} diff --git a/frontend/src/routes/dashboard/csv-upload/+page.svelte b/frontend/src/routes/dashboard/csv-upload/+page.svelte index 26cdc4bc..4fef2855 100644 --- a/frontend/src/routes/dashboard/csv-upload/+page.svelte +++ b/frontend/src/routes/dashboard/csv-upload/+page.svelte @@ -32,6 +32,8 @@ type CsvImportPendingEntry } from '$lib/csv-import-pending'; import { fetchCsvImportStatus } from '$lib/csv-import-status-api'; + import { totalSkippedFromCommit } from '$lib/csv-import-commit-metrics'; + import { countCsvDataRows } from '$lib/csv-upload-row-count'; import CsvPendingImportsSheet from '$lib/components/dashboard/csv-upload/CsvPendingImportsSheet.svelte'; /** Intenta extraer un objeto tipo scan desde string tipo repr de Python. */ @@ -113,6 +115,24 @@ ? safeInt(skippedDuplicateMatch[1]) : 0; + const skippedMissingInvMatch = + s.match(/['"]skipped_missing_invoice['"]:\s*['"]?(\d+)['"]?/) || + s.match(/['"]skipped_missing_invoice['"]:\s*([^,}]+)/); + const skipped_missing_invoice = skippedMissingInvMatch + ? safeInt(skippedMissingInvMatch[1]) + : 0; + + const gapsMatch = + s.match(/['"]critical_reference_gaps['"]:\s*['"]?(\d+)['"]?/) || + s.match(/['"]critical_reference_gaps['"]:\s*([^,}]+)/); + const critical_reference_gaps = gapsMatch ? safeInt(gapsMatch[1]) : 0; + + const refReadyMatch = s.match(/['"]reference_state_ready['"]:\s*(True|False|true|false)/); + let reference_state_ready = true; + if (refReadyMatch) { + reference_state_ready = refReadyMatch[1].toLowerCase() === 'true'; + } + // Detalle opcional: lista de dicts con {line, reason, solution?} const skipped_details: Array<{ line: number; reason: string; solution?: string }> = []; const detailRegex = @@ -132,11 +152,19 @@ const message = messageMatch ? unescapePy(messageMatch[1]) : undefined; // Si no hay contadores en el texto, no es un commit. - if (inserted === 0 && updated === 0 && skipped_invalid === 0 && skipped_missing_fk === 0 && skipped_duplicate === 0) { + if ( + inserted === 0 && + updated === 0 && + skipped_invalid === 0 && + skipped_missing_fk === 0 && + skipped_duplicate === 0 && + skipped_missing_invoice === 0 && + critical_reference_gaps === 0 + ) { return null; } - // Mínimo requerido por el modal + // Mínimo requerido por el modal (contrato tipo pedimentos) return { status, inserted, @@ -144,6 +172,9 @@ skipped_invalid, skipped_missing_fk, skipped_duplicate, + skipped_missing_invoice, + critical_reference_gaps, + reference_state_ready, skipped_details, ...(message ? { message } : {}) }; @@ -198,58 +229,85 @@ /** Job id del escaneo listo para confirmar (para quitar de pendientes al hacer commit). */ let scanPhaseJobId = $state(null); + /** + * Total de filas de datos conocido para la barra (máx. entre conteo local, total del poll Celery y total_rows del escaneo). + * No se reinicia mientras el modal de resultados siga abierto, para poder usar el mismo total en el commit. + */ + let csvImportRowTotal = $state(0); + const csvProgressStepTitle = $derived.by(() => { switch (csvProgressPhase) { case 'upload': - return 'Paso 1 de 2 — Subiendo el archivo'; + return 'Subiendo archivo CSV'; case 'scan': - return 'Paso 2 de 2 — Escaneando en el servidor'; + return 'Validando registros en el servidor'; case 'commit': - return 'Finalizando importación'; + return 'Grabando registros en base de datos'; default: return ''; } }); + /** Denominador único: filas de la tarea (registros del CSV / total reportado por el worker). */ + const csvImportProgressDenom = $derived.by(() => { + const d = Math.max(csvImportRowTotal || 0, scanProgressTotal || 0); + return d > 0 ? d : 1; + }); + const csvProgressDetailLine = $derived.by(() => { + const denom = csvImportProgressDenom; switch (csvProgressPhase) { case 'upload': - return ''; + if (csvImportRowTotal > 0) { + return `Archivo: ~${csvImportRowTotal} fila(s) de datos — subiendo (aún no se validan registros en servidor)…`; + } + return uploadLengthComputable + ? 'Subiendo archivo…' + : 'Subiendo archivo (tamaño desconocido en el navegador)…'; case 'scan': - return scanProgressTotal > 0 - ? `Filas procesadas: ${scanProgressCurrent} / ${scanProgressTotal}` - : 'Preparando resultados…'; + return `Registros procesados: ${Math.min(scanProgressCurrent, denom)} / ${denom}`; case 'commit': - return 'Escribiendo registros en base de datos…'; + return scanProgressTotal > 0 + ? `Registros grabados: ${Math.min(scanProgressCurrent, denom)} / ${denom}` + : `Grabando en base de datos… (${Math.min(scanProgressCurrent, denom)} / ${denom} según último total conocido)`; default: return ''; } }); - const csvProgressBarIndeterminate = $derived( - csvProgressPhase === 'commit' || - (csvProgressPhase === 'scan' && scanProgressTotal <= 0) || - (csvProgressPhase === 'upload' && !uploadLengthComputable) + /** Porcentaje según filas procesadas / total de filas (sin tramos artificiales por etapa). */ + const csvCombinedProgressPct = $derived.by(() => { + if (!isUploading || csvProgressPhase === 'idle') return 0; + if (csvProgressPhase === 'upload') { + return 0; + } + const denom = csvImportProgressDenom; + const numer = Math.min(Math.max(0, scanProgressCurrent), denom); + return Math.min(100, Math.round((100 * numer) / denom)); + }); + + /** + * Commit: muchas tareas no publican PROGRESS por fila; si current sigue en 0, barra indeterminada. + * Cuando Celery envía current > 0, la barra pasa a reflejar registros reales. + */ + const csvFooterProgressIndeterminate = $derived( + isUploading && !showResultModal && csvProgressPhase === 'commit' && scanProgressCurrent <= 0 ); - const csvProgressBarValue = $derived( - csvProgressPhase === 'upload' - ? uploadProgressPct - : csvProgressPhase === 'scan' && scanProgressTotal > 0 - ? Math.min(100, Math.round((scanProgressCurrent / scanProgressTotal) * 100)) - : 0 + const csvFooterPercentLabel = $derived( + csvFooterProgressIndeterminate + ? null + : csvProgressPhase === 'upload' + ? '0%' + : `${csvCombinedProgressPct}%` ); - const csvProgressPercentText = $derived( - csvProgressBarIndeterminate ? null : `${csvProgressBarValue}%` - ); - - const csvProgressAriaValueText = $derived( - csvProgressPercentText - ? `${csvProgressStepTitle}, ${csvProgressPercentText}` + const csvFooterAriaValueText = $derived( + csvFooterProgressIndeterminate + ? `${csvProgressStepTitle}. ${csvProgressDetailLine || 'En proceso…'}` : csvProgressDetailLine - ? `${csvProgressStepTitle}. ${csvProgressDetailLine}` - : csvProgressStepTitle + ? `${csvProgressStepTitle}, ${csvFooterPercentLabel}. ${csvProgressDetailLine}` + : `${csvProgressStepTitle}, ${csvFooterPercentLabel}` ); $effect(() => { @@ -257,8 +315,12 @@ csvProgressPhase = 'idle'; uploadProgressPct = 0; uploadLengthComputable = false; - scanProgressCurrent = 0; - scanProgressTotal = 0; + // Con modal abierto (escaneo listo o resultado de commit) conservar totales para la siguiente fase o la UI. + if (!showResultModal) { + scanProgressCurrent = 0; + scanProgressTotal = 0; + csvImportRowTotal = 0; + } currentImportLabel = null; csvResumeOverlayHint = false; scanPhaseJobId = null; @@ -523,6 +585,12 @@ uploadLengthComputable = false; scanProgressCurrent = 0; scanProgressTotal = 0; + csvImportRowTotal = 0; + try { + csvImportRowTotal = await countCsvDataRows(file); + } catch (e) { + console.warn('csv-upload: no se pudo contar filas del archivo', e); + } csvResumeOverlayHint = false; skipScanCompleteToastOnce = false; scanPhaseJobId = null; @@ -914,13 +982,20 @@ 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; + if (typeof t === 'number') { + scanProgressTotal = t; + csvImportRowTotal = Math.max(csvImportRowTotal, 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) { + const tr = (res.data as { total_rows?: unknown }).total_rows; + if (typeof tr === 'number' && tr > 0) { + csvImportRowTotal = Math.max(csvImportRowTotal, tr); + } scanResults = res.data; pushScanToPendingLocal(res.data as Record); showResultModal = true; @@ -965,6 +1040,10 @@ } } if (parsedScan) { + const tr = parsedScan.total_rows; + if (typeof tr === 'number' && tr > 0) { + csvImportRowTotal = Math.max(csvImportRowTotal, tr); + } scanResults = parsedScan; pushScanToPendingLocal(parsedScan); showResultModal = true; @@ -1019,16 +1098,21 @@ commitResults = res.data; showResultModal = true; const inserted = res.data?.inserted || 0; - const skippedInvalid = res.data?.skipped_invalid || 0; - const skippedFk = res.data?.skipped_missing_fk || 0; - const skippedDup = res.data?.skipped_duplicate || 0; - const totalSkipped = skippedInvalid + skippedFk + skippedDup; + const updated = res.data?.updated || 0; + const totalSkipped = totalSkippedFromCommit(res.data as Record); const backendMessage = res.data?.message; + const totalOk = inserted + updated; - if (inserted === 0) { - toast.error(backendMessage || `No se insertaron registros. ${totalSkipped} fueron rechazados.`); + if (totalOk === 0) { + toast.error( + backendMessage || + `No se insertaron ni actualizaron registros. ${totalSkipped} fueron rechazados.` + ); } else { - toast.warning(backendMessage || `Solo se insertaron ${inserted} de ${inserted + totalSkipped} registros.`); + toast.warning( + backendMessage || + `Se aplicaron ${totalOk} registros (${inserted} insertados, ${updated} actualizados). ${totalSkipped} rechazados.` + ); } finalizeCommitAndClearPending(); isUploading = false; @@ -1037,18 +1121,14 @@ showResultModal = true; const inserted = res.data?.inserted || 0; const updated = res.data?.updated || 0; - const skippedInvalid = res.data?.skipped_invalid || 0; - const skippedFk = res.data?.skipped_missing_fk || 0; - const skippedDup = res.data?.skipped_duplicate || 0; - const skippedDetails = res.data?.skipped_details || []; + const totalSkipped = totalSkippedFromCommit(res.data as Record); if (inserted > 0 || updated > 0) { const parts = []; if (inserted > 0) parts.push(`${inserted} insertados`); if (updated > 0) parts.push(`${updated} actualizados`); toast.success(`Importación completada: ${parts.join(', ')}`); - if (skippedInvalid > 0 || skippedFk > 0 || skippedDup > 0) { - const totalSkipped = skippedInvalid + skippedFk + skippedDup; + if (totalSkipped > 0) { toast.warning(`${totalSkipped} registros fueron rechazados u omitidos`); } } else { @@ -1059,14 +1139,14 @@ } else { // Continue polling console.log('Status not final, polling again in 2s...', res.data?.status); - setTimeout(pollStatus, 2000); + setTimeout(pollStatus, 800); } } catch (e) { console.error('Poll exception', e); // Retry on network error? Or fail? // For now, let's keep retrying a few times or hard fail. // Let's just log and retry. - setTimeout(pollStatus, 2000); + setTimeout(pollStatus, 800); } } @@ -1093,7 +1173,11 @@

Catálogos Generales

- + @@ -1101,7 +1185,11 @@

Logística y Transporte

- +
@@ -1109,67 +1197,93 @@

Operaciones de Importación

- +

Operaciones de Exportación

- +
- - + +
- {#if isUploading && !showResultModal} -
- {#if csvResumeOverlayHint} -

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

- {/if} - {#if currentImportLabel} -

{currentImportLabel}

- {/if} -
-

{csvProgressStepTitle}

- {#if csvProgressPercentText} - - {/if} -
- {#if csvProgressDetailLine} -

{csvProgressDetailLine}

- {/if} -
- {#if csvProgressBarIndeterminate} -
-
+ +
+ {#if isUploading && !showResultModal} +
+
+ {#if csvResumeOverlayHint} +

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

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

{currentImportLabel}

+ {/if} +
+

+ {csvProgressStepTitle} +

+ {#if csvFooterPercentLabel} + + {/if}
- {:else} - - {/if} + {#if csvProgressDetailLine} +

{csvProgressDetailLine}

+ {/if} +
+ {#if csvFooterProgressIndeterminate} +
+
+
+ {:else} + + {/if} +
+
-
- {/if} - - - + {/if} + +
{#if scanResults || commitResults} @@ -1213,7 +1327,6 @@ isUploading = true; csvProgressPhase = 'commit'; scanProgressCurrent = 0; - scanProgressTotal = 0; const res = useCustomsBrokerImport ? await api.customsBrokerImports.commit(currentJobId) : useClientProviderImport