feature/csv-clarion-exchange-type

This commit is contained in:
hreyes
2026-03-04 15:50:30 -07:00
parent afc0a35f1c
commit e486fd750f
8 changed files with 208 additions and 26 deletions

View File

@@ -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

View File

@@ -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 {}

View File

@@ -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:

View File

@@ -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,
}

View File

@@ -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)

View File

@@ -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 }
);
},

View File

@@ -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) {

View File

@@ -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.');