diff --git a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/common/common_validators.py b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/common/common_validators.py index 472ab721..6d1bf126 100644 --- a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/common/common_validators.py +++ b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/common/common_validators.py @@ -6,13 +6,34 @@ from decimal import Decimal from typing import Dict, Any, Optional, List DATE_FORMATS: List[str] = ["%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y", "%d-%m-%Y", "%Y/%m/%d"] + +# Valores que envía el frontend (globalCsvParams dateFormat) -> formato strptime +# Cuando el usuario elige un formato en "Parámetros globales", solo se aceptan fechas en ese formato. +DATE_FORMAT_PREFERENCE_MAP: Dict[str, str] = { + "dd/mm/yyyy": "%d/%m/%Y", + "mm/dd/yyyy": "%m/%d/%Y", + "yyyy-mm-dd": "%Y-%m-%d", +} + CURRENCY_MAX = 7 -def parse_date(val: Optional[str]) -> Optional[datetime]: +def parse_date(val: Optional[str], date_format_preference: Optional[str] = None) -> Optional[datetime]: + """ + Parsea fecha. Si date_format_preference está definido (formato elegido en el frontend), + solo se acepta ese formato; si la cadena no coincide, se rechaza. + Si no hay preferencia, se intentan todos los formatos (retrocompatibilidad). + """ if not val or not str(val).strip(): return None raw = str(val).strip() + if date_format_preference and date_format_preference in DATE_FORMAT_PREFERENCE_MAP: + fmt = DATE_FORMAT_PREFERENCE_MAP[date_format_preference] + try: + parsed = datetime.strptime(raw, fmt) + return datetime.combine(parsed.date(), time.min) + except ValueError: + return None for fmt in DATE_FORMATS: try: parsed = datetime.strptime(raw, fmt) @@ -63,3 +84,57 @@ def check_optional_max_length(row: Dict[str, Any], col: str, max_len: int, line_ if len(val) > max_len: return {"line": line_num, "col": col, "msg": f"Máximo {max_len} caracteres"} return None + + +FECHA_MAX_LEN = 10 +MSG_FECHA_LONGITUD = "Error: (Col. A) La Fecha: {fecha} supera la longitud de caracteres." +MSG_FECHA_LONGITUD_SOLUCION = "Capturar en la columna A el campo Fecha con este formato ##/##/####." +MSG_FECHA_DIA_INVALIDO = "Error: (Col. A) El día {dia} de la Fecha: {fecha} no es válido para el mes." +MSG_FECHA_DIA_SOLUCION = "Capturar correctamente en la columna A el día del campo Fecha, con este formato ##/##/####. (Día/Mes/Año)" +MSG_FECHA_MES_INVALIDO = "Error: (Col. A) El mes {mes} de la Fecha: {fecha} es mayor a 12 esto no es valido." +MSG_FECHA_MES_SOLUCION = "Capturar correctamente en la columna A el mes del campo Fecha, con este formato ##/##/####. (Día/Mes/Año)" + +# Etiquetas para mensaje cuando no coincide con el formato elegido +DATE_FORMAT_LABELS: Dict[str, str] = { + "dd/mm/yyyy": "DD/MM/YYYY (Día/Mes/Año)", + "mm/dd/yyyy": "MM/DD/YYYY (Mes/Día/Año)", + "yyyy-mm-dd": "YYYY-MM-DD (Año-Mes-Día)", +} + + +def validate_fecha_clarion( + raw_fecha: str, line_num: int, date_format_preference: Optional[str] = None +) -> Optional[Dict[str, Any]]: + """ + Valida la fecha según reglas Clarion: longitud ≤ 10, día válido para el mes, mes ≤ 12. + Sin límite de año. Si date_format_preference está definido (ej. dd/mm/yyyy), se prioriza ese formato. + """ + if not raw_fecha or not str(raw_fecha).strip(): + return None + raw = str(raw_fecha).strip() + if len(raw) > FECHA_MAX_LEN: + return { + "line": line_num, + "col": "FECHA", + "msg": f"{MSG_FECHA_LONGITUD.format(fecha=raw)} {MSG_FECHA_LONGITUD_SOLUCION}", + } + parsed = parse_date(raw, date_format_preference) + if parsed is None: + format_label = ( + DATE_FORMAT_LABELS.get(date_format_preference, "##/##/####") + if date_format_preference + else "##/##/####" + ) + return { + "line": line_num, + "col": "FECHA", + "msg": f"Error: (Col. A) La Fecha: {raw} no coincide con el formato elegido ({format_label}). {MSG_FECHA_LONGITUD_SOLUCION}", + } + d = parsed.date() + if d.month > 12: + return { + "line": line_num, + "col": "FECHA", + "msg": f"{MSG_FECHA_MES_INVALIDO.format(mes=d.month, fecha=raw)} {MSG_FECHA_MES_SOLUCION}", + } + return None diff --git a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/common/mappers.py b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/common/mappers.py index 42436530..136b1c5f 100644 --- a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/common/mappers.py +++ b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/common/mappers.py @@ -25,10 +25,10 @@ def _str_or_none(val: Any, max_len: Optional[int] = None) -> Optional[str]: def row_to_exchange_rate_data( - row_norm: Dict[str, Any], tenant_id: int, company_id: int + row_norm: Dict[str, Any], tenant_id: int, company_id: int, date_format_preference: Optional[str] = None ) -> Dict[str, Any]: """Build dict for ExchangeRate model. Returns {} if FECHA or VALOR invalid.""" - parsed_date = parse_date(row_norm.get("FECHA")) + parsed_date = parse_date(row_norm.get("FECHA"), date_format_preference) value_decimal = parse_decimal_positive(row_norm.get("VALOR")) if not parsed_date or value_decimal is None: return {} 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 006eee35..cb8ca60a 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 @@ -10,7 +10,7 @@ from uuid import uuid4 from fastapi import APIRouter, File, HTTPException, Query, UploadFile, Depends from sqlalchemy.orm import Session -from typing import Dict, Any +from typing import Dict, Any, Optional from core.celery_app import celery_app from core.database import get_core_db @@ -40,11 +40,20 @@ def _get_redis(): async def upload_import_file( file: UploadFile = File(...), company_id: int = Query(..., description="Company ID"), + reemplazar_sin_preguntar: bool = Query( + True, + description="Si True, reemplaza tipos de cambio existentes para la misma fecha; si False, solo agrega nuevos (omite fechas ya existentes)", + ), + date_format: Optional[str] = Query( + None, + description="Formato de fecha del CSV: dd/mm/yyyy, mm/dd/yyyy o yyyy-mm-dd. Si no se envía, se intentan todos.", + ), db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), ): """ Fase 1: Subir CSV, guardar en Redis, encolar tarea de escaneo. + Parámetros globales de carga: reemplazar_sin_preguntar (Modo Reemplazar vs Actualizar), date_format (Formato de Fecha). """ try: tenant_id = validate_access_to_resource(db, company_id, current_user) @@ -63,6 +72,8 @@ async def upload_import_file( "company_id": company_id, "user_id": current_user.get("id"), "template_id": "exchange_rates", + "reemplazar_sin_preguntar": reemplazar_sin_preguntar, + "date_format": date_format, } try: 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 9be2b566..68610c11 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 @@ -54,6 +54,10 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str, except ValueError as e: return {"status": "failed", "error": str(e)} + meta = common_meta.load_meta(file_path) + # Formato activo del selector del frontend; si no viene, mismo default que el front (dd/mm/yyyy) + date_format_preference = meta.get("date_format") or meta.get("dateFormat") or "dd/mm/yyyy" + error_count = 0 processed_rows = 0 errors_detail: List[Dict[str, Any]] = [] @@ -66,7 +70,7 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str, progress_callback(i, total_rows, error_count) row_norm = _norm_row(row) - err = validate_row_exchange_rate(row_norm, i) + err = validate_row_exchange_rate(row_norm, i, raw_row=row, date_format_preference=date_format_preference) if err: error_count += 1 error_lines_list.append(err["line"]) @@ -116,9 +120,15 @@ def _do_commit(job_id: str) -> Dict[str, Any]: except ValueError as e: return {"status": "failed", "error": str(e)} + meta = common_meta.load_meta(file_path) + reemplazar_sin_preguntar = meta.get("reemplazar_sin_preguntar", True) + date_format_preference = meta.get("date_format") or meta.get("dateFormat") + from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate inserted_count = 0 + updated_count = 0 + skipped_duplicate = 0 skipped_invalid = 0 skipped_details: List[Dict[str, Any]] = [] meta_path = common_meta.get_meta_path(file_path) @@ -142,7 +152,7 @@ def _do_commit(job_id: str) -> Dict[str, Any]: continue row_norm = _norm_row(row) - err = validate_row_exchange_rate(row_norm, i) + err = validate_row_exchange_rate(row_norm, i, raw_row=row, date_format_preference=date_format_preference) if err: skipped_invalid += 1 skipped_details.append({ @@ -151,7 +161,7 @@ def _do_commit(job_id: str) -> Dict[str, Any]: }) continue - data = row_to_exchange_rate_data(row_norm, tenant_id, company_id) + data = row_to_exchange_rate_data(row_norm, tenant_id, company_id, date_format_preference) if not data or not data.get("date"): skipped_invalid += 1 skipped_details.append({"line": i, "reason": "FECHA o VALOR no válidos"}) @@ -160,15 +170,19 @@ def _do_commit(job_id: str) -> Dict[str, Any]: key_date = data["date"].date() if hasattr(data["date"], "date") else data["date"] existing = existing_by_date.get((tenant_id, company_id, key_date)) if existing: + if not reemplazar_sin_preguntar: + skipped_duplicate += 1 + continue existing.value = data["value"] existing.local_currency = data.get("local_currency") existing.foreign_currency = data.get("foreign_currency") session.add(existing) + updated_count += 1 else: new_er = ExchangeRate(**data) session.add(new_er) existing_by_date[(tenant_id, company_id, key_date)] = new_er - inserted_count += 1 + inserted_count += 1 try: session.commit() @@ -188,34 +202,34 @@ def _do_commit(job_id: str) -> Dict[str, Any]: meta_path=meta_path, ) - if inserted_count == 0 and skipped_invalid > 0: + if inserted_count == 0 and updated_count == 0 and (skipped_invalid > 0 or skipped_duplicate > 0): return { "status": "warning", "inserted": 0, "updated": 0, "skipped_invalid": skipped_invalid, - "skipped_duplicate": 0, + "skipped_duplicate": skipped_duplicate, "skipped_missing_fk": 0, "skipped_details": skipped_details, - "message": f"No se insertaron registros. {skipped_invalid} rechazados.", + "message": f"No se insertaron registros. {skipped_invalid} rechazados, {skipped_duplicate} omitidos por fecha existente.", } - if inserted_count == 0: + if inserted_count == 0 and updated_count == 0: return { "status": "failed", "error": "No hay registros válidos en el archivo CSV", "inserted": 0, "updated": 0, "skipped_invalid": skipped_invalid, - "skipped_duplicate": 0, + "skipped_duplicate": skipped_duplicate, "skipped_missing_fk": 0, "skipped_details": skipped_details, } return { "status": "finished", "inserted": inserted_count, - "updated": 0, + "updated": updated_count, "skipped_invalid": skipped_invalid, - "skipped_duplicate": 0, + "skipped_duplicate": skipped_duplicate, "skipped_missing_fk": 0, "skipped_details": skipped_details, } diff --git a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/validators/common.py b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/validators/common.py index 46f52d86..6ba36f2e 100644 --- a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/validators/common.py +++ b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/validators/common.py @@ -1,22 +1,81 @@ """ Validaciones comunes de fila para import CSV de tipos de cambio. +Paridad Clarion: desfase (Col C vacía), obligatorios (Col A Fecha, Col B Tipo de Cambio), +validación fecha (longitud, día acorde al mes, mes ≤ 12; sin límite de año). """ from typing import Dict, Any, Optional from ..common.common_validators import ( - check_required_date, check_required_value_positive, check_optional_max_length, + validate_fecha_clarion, CURRENCY_MAX, ) -def validate_row_exchange_rate(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]: +MSG_DESFASE = "Error: Existe un desfase en esta línea." +MSG_DESFASE_SOLUCION = "Revisar esta línea del archivo CSV y verificar cada campo este en la posicion correcta." + +MSG_OBLIGATORIOS = "Existen campos vacios que son obligatorios, es la (Col.A) Fecha , (Col.B) Tipo de Cambio." +MSG_OBLIGATORIOS_SOLUCION = "Revisar la línea del archivo y capturar los campos con la información correcta." + + +def validate_row_desfase(raw_row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]: + """ + Si la fila tiene 3 o más columnas y la 3ª tiene valor, error de desfase (Clarion ColumnaC <> ''). + """ + values_ordered = list(raw_row.values()) if raw_row else [] + if len(values_ordered) >= 3 and (values_ordered[2] or "").strip(): + return { + "line": line_num, + "col": "", + "msg": f"{MSG_DESFASE} {MSG_DESFASE_SOLUCION}", + } + return None + + +def validate_row_required_exchange_rate(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]: + """FECHA y VALOR obligatorios con mensaje Clarion.""" + fecha = (row.get("FECHA") or "").strip() + valor = (row.get("VALOR") or "").strip() + if not fecha or not valor: + return { + "line": line_num, + "col": "FECHA" if not fecha else "VALOR", + "msg": f"{MSG_OBLIGATORIOS} {MSG_OBLIGATORIOS_SOLUCION}", + } + return None + + +def validate_row_fecha_clarion( + row: Dict[str, Any], line_num: int, date_format_preference: Optional[str] = None +) -> Optional[Dict[str, Any]]: + """Longitud ≤ 10 y fecha válida (día acorde al mes, mes ≤ 12; sin límite de año).""" + raw_fecha = (row.get("FECHA") or "").strip() + if not raw_fecha: + return None + return validate_fecha_clarion(raw_fecha, line_num, date_format_preference) + + +def validate_row_exchange_rate( + row: Dict[str, Any], + line_num: int, + raw_row: Optional[Dict[str, Any]] = None, + date_format_preference: Optional[str] = None, +) -> Optional[Dict[str, Any]]: """ Valida una fila de CSV de tipos de cambio. - FECHA y VALOR requeridos; MONEDA_LOCAL y MONEDA_EXTRANJERA opcionales (max 7). + Orden: desfase (si raw_row) → obligatorios → fecha Clarion → VALOR > 0 → MONEDA opc (max 7). + date_format_preference: valor del parámetro global (ej. dd/mm/yyyy, mm/dd/yyyy, yyyy-mm-dd). """ - err = check_required_date(row, line_num) + if raw_row is not None: + err = validate_row_desfase(raw_row, line_num) + if err: + return err + err = validate_row_required_exchange_rate(row, line_num) + if err: + return err + err = validate_row_fecha_clarion(row, line_num, date_format_preference) if err: return err err = check_required_value_positive(row, line_num) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f40d5776..f966c5db 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -437,11 +437,20 @@ export const api = { // CSV import for Tipos de Cambio (flujo en general_catalogs/exchange_rate/imports) exchangeRateImports: { - upload: (file: File, companyId: number) => { + upload: ( + file: File, + companyId: number, + params?: { reemplazar_sin_preguntar?: boolean; date_format?: string } + ) => { const formData = new FormData(); formData.append('file', file); + const search = new URLSearchParams({ company_id: String(companyId) }); + if (params?.reemplazar_sin_preguntar !== undefined) + search.set('reemplazar_sin_preguntar', String(!!params.reemplazar_sin_preguntar)); + if (params?.date_format != null && params.date_format !== '') + search.set('date_format', params.date_format); return fetchApi( - `/v1/a76/exchange-rate/imports/upload?company_id=${companyId}`, + `/v1/a76/exchange-rate/imports/upload?${search.toString()}`, { method: 'POST', body: formData } ); }, diff --git a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte index bd7bac46..0a3a1ef8 100644 --- a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte +++ b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte @@ -42,7 +42,8 @@ let totalSkipped = $derived( (commitResults?.skipped_invalid || 0) + (commitResults?.skipped_missing_fk || 0) + - (commitResults?.skipped_missing_invoice || 0) + (commitResults?.skipped_missing_invoice || 0) + + (commitResults?.skipped_duplicate || 0) ); function handleOpenChange(newOpen: boolean) { diff --git a/frontend/src/routes/dashboard/csv-upload/+page.svelte b/frontend/src/routes/dashboard/csv-upload/+page.svelte index 29efd51d..ffd8fdfb 100644 --- a/frontend/src/routes/dashboard/csv-upload/+page.svelte +++ b/frontend/src/routes/dashboard/csv-upload/+page.svelte @@ -176,7 +176,16 @@ if (useExchangeRateImport) { try { - const res = await api.exchangeRateImports.upload(file, companyId); + const catalogosSettings = allSettings['catalogos'] || {}; + const globalMode = globalSettings['mode'] ?? catalogosSettings['mode'] ?? 'update'; + const reemplazar_sin_preguntar = globalMode === 'replace'; + // Formato activo del selector global (Formato de Fecha); mismo default que la barra + const dateFormat = + globalSettings['dateFormat'] ?? catalogosSettings['dateFormat'] ?? globalCsvParams.find((p) => p.name === 'dateFormat')?.defaultValue ?? 'dd/mm/yyyy'; + const res = await api.exchangeRateImports.upload(file, companyId, { + reemplazar_sin_preguntar, + date_format: dateFormat + }); if (res.data?.job_id) { currentJobId = res.data.job_id; pollStatus(); @@ -518,16 +527,20 @@ commitResults = res.data; 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 || []; - if (inserted > 0) { - toast.success(`Importación completada: ${inserted} registros insertados`); + 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; - toast.warning(`${totalSkipped} registros fueron rechazados`); + toast.warning(`${totalSkipped} registros fueron rechazados u omitidos`); } } else { toast.error('No se insertaron registros. Revisa los errores a continuación.');