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 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"] 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 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(): if not val or not str(val).strip():
return None return None
raw = str(val).strip() 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: for fmt in DATE_FORMATS:
try: try:
parsed = datetime.strptime(raw, fmt) 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: if len(val) > max_len:
return {"line": line_num, "col": col, "msg": f"Máximo {max_len} caracteres"} return {"line": line_num, "col": col, "msg": f"Máximo {max_len} caracteres"}
return None 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( 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]: ) -> Dict[str, Any]:
"""Build dict for ExchangeRate model. Returns {} if FECHA or VALOR invalid.""" """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")) value_decimal = parse_decimal_positive(row_norm.get("VALOR"))
if not parsed_date or value_decimal is None: if not parsed_date or value_decimal is None:
return {} return {}

View File

@@ -10,7 +10,7 @@ from uuid import uuid4
from fastapi import APIRouter, File, HTTPException, Query, UploadFile, Depends from fastapi import APIRouter, File, HTTPException, Query, UploadFile, Depends
from sqlalchemy.orm import Session 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.celery_app import celery_app
from core.database import get_core_db from core.database import get_core_db
@@ -40,11 +40,20 @@ def _get_redis():
async def upload_import_file( async def upload_import_file(
file: UploadFile = File(...), file: UploadFile = File(...),
company_id: int = Query(..., description="Company ID"), 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), db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user), current_user: Dict[str, Any] = Depends(get_current_user),
): ):
""" """
Fase 1: Subir CSV, guardar en Redis, encolar tarea de escaneo. 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: try:
tenant_id = validate_access_to_resource(db, company_id, current_user) tenant_id = validate_access_to_resource(db, company_id, current_user)
@@ -63,6 +72,8 @@ async def upload_import_file(
"company_id": company_id, "company_id": company_id,
"user_id": current_user.get("id"), "user_id": current_user.get("id"),
"template_id": "exchange_rates", "template_id": "exchange_rates",
"reemplazar_sin_preguntar": reemplazar_sin_preguntar,
"date_format": date_format,
} }
try: try:

View File

@@ -54,6 +54,10 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str,
except ValueError as e: except ValueError as e:
return {"status": "failed", "error": str(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 error_count = 0
processed_rows = 0 processed_rows = 0
errors_detail: List[Dict[str, Any]] = [] 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) progress_callback(i, total_rows, error_count)
row_norm = _norm_row(row) 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: if err:
error_count += 1 error_count += 1
error_lines_list.append(err["line"]) error_lines_list.append(err["line"])
@@ -116,9 +120,15 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
except ValueError as e: except ValueError as e:
return {"status": "failed", "error": str(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 from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate
inserted_count = 0 inserted_count = 0
updated_count = 0
skipped_duplicate = 0
skipped_invalid = 0 skipped_invalid = 0
skipped_details: List[Dict[str, Any]] = [] skipped_details: List[Dict[str, Any]] = []
meta_path = common_meta.get_meta_path(file_path) meta_path = common_meta.get_meta_path(file_path)
@@ -142,7 +152,7 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
continue continue
row_norm = _norm_row(row) 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: if err:
skipped_invalid += 1 skipped_invalid += 1
skipped_details.append({ skipped_details.append({
@@ -151,7 +161,7 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
}) })
continue 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"): if not data or not data.get("date"):
skipped_invalid += 1 skipped_invalid += 1
skipped_details.append({"line": i, "reason": "FECHA o VALOR no válidos"}) 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"] key_date = data["date"].date() if hasattr(data["date"], "date") else data["date"]
existing = existing_by_date.get((tenant_id, company_id, key_date)) existing = existing_by_date.get((tenant_id, company_id, key_date))
if existing: if existing:
if not reemplazar_sin_preguntar:
skipped_duplicate += 1
continue
existing.value = data["value"] existing.value = data["value"]
existing.local_currency = data.get("local_currency") existing.local_currency = data.get("local_currency")
existing.foreign_currency = data.get("foreign_currency") existing.foreign_currency = data.get("foreign_currency")
session.add(existing) session.add(existing)
updated_count += 1
else: else:
new_er = ExchangeRate(**data) new_er = ExchangeRate(**data)
session.add(new_er) session.add(new_er)
existing_by_date[(tenant_id, company_id, key_date)] = new_er existing_by_date[(tenant_id, company_id, key_date)] = new_er
inserted_count += 1 inserted_count += 1
try: try:
session.commit() session.commit()
@@ -188,34 +202,34 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
meta_path=meta_path, 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 { return {
"status": "warning", "status": "warning",
"inserted": 0, "inserted": 0,
"updated": 0, "updated": 0,
"skipped_invalid": skipped_invalid, "skipped_invalid": skipped_invalid,
"skipped_duplicate": 0, "skipped_duplicate": skipped_duplicate,
"skipped_missing_fk": 0, "skipped_missing_fk": 0,
"skipped_details": skipped_details, "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 { return {
"status": "failed", "status": "failed",
"error": "No hay registros válidos en el archivo CSV", "error": "No hay registros válidos en el archivo CSV",
"inserted": 0, "inserted": 0,
"updated": 0, "updated": 0,
"skipped_invalid": skipped_invalid, "skipped_invalid": skipped_invalid,
"skipped_duplicate": 0, "skipped_duplicate": skipped_duplicate,
"skipped_missing_fk": 0, "skipped_missing_fk": 0,
"skipped_details": skipped_details, "skipped_details": skipped_details,
} }
return { return {
"status": "finished", "status": "finished",
"inserted": inserted_count, "inserted": inserted_count,
"updated": 0, "updated": updated_count,
"skipped_invalid": skipped_invalid, "skipped_invalid": skipped_invalid,
"skipped_duplicate": 0, "skipped_duplicate": skipped_duplicate,
"skipped_missing_fk": 0, "skipped_missing_fk": 0,
"skipped_details": skipped_details, "skipped_details": skipped_details,
} }

View File

@@ -1,22 +1,81 @@
""" """
Validaciones comunes de fila para import CSV de tipos de cambio. 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 typing import Dict, Any, Optional
from ..common.common_validators import ( from ..common.common_validators import (
check_required_date,
check_required_value_positive, check_required_value_positive,
check_optional_max_length, check_optional_max_length,
validate_fecha_clarion,
CURRENCY_MAX, 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. 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: if err:
return err return err
err = check_required_value_positive(row, line_num) 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) // CSV import for Tipos de Cambio (flujo en general_catalogs/exchange_rate/imports)
exchangeRateImports: { exchangeRateImports: {
upload: (file: File, companyId: number) => { upload: (
file: File,
companyId: number,
params?: { reemplazar_sin_preguntar?: boolean; date_format?: string }
) => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); 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( return fetchApi(
`/v1/a76/exchange-rate/imports/upload?company_id=${companyId}`, `/v1/a76/exchange-rate/imports/upload?${search.toString()}`,
{ method: 'POST', body: formData } { method: 'POST', body: formData }
); );
}, },

View File

@@ -42,7 +42,8 @@
let totalSkipped = $derived( let totalSkipped = $derived(
(commitResults?.skipped_invalid || 0) + (commitResults?.skipped_invalid || 0) +
(commitResults?.skipped_missing_fk || 0) + (commitResults?.skipped_missing_fk || 0) +
(commitResults?.skipped_missing_invoice || 0) (commitResults?.skipped_missing_invoice || 0) +
(commitResults?.skipped_duplicate || 0)
); );
function handleOpenChange(newOpen: boolean) { function handleOpenChange(newOpen: boolean) {

View File

@@ -176,7 +176,16 @@
if (useExchangeRateImport) { if (useExchangeRateImport) {
try { 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) { if (res.data?.job_id) {
currentJobId = res.data.job_id; currentJobId = res.data.job_id;
pollStatus(); pollStatus();
@@ -518,16 +527,20 @@
commitResults = res.data; commitResults = res.data;
showResultModal = true; showResultModal = true;
const inserted = res.data?.inserted || 0; const inserted = res.data?.inserted || 0;
const updated = res.data?.updated || 0;
const skippedInvalid = res.data?.skipped_invalid || 0; const skippedInvalid = res.data?.skipped_invalid || 0;
const skippedFk = res.data?.skipped_missing_fk || 0; const skippedFk = res.data?.skipped_missing_fk || 0;
const skippedDup = res.data?.skipped_duplicate || 0; const skippedDup = res.data?.skipped_duplicate || 0;
const skippedDetails = res.data?.skipped_details || []; const skippedDetails = res.data?.skipped_details || [];
if (inserted > 0) { if (inserted > 0 || updated > 0) {
toast.success(`Importación completada: ${inserted} registros insertados`); 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) { if (skippedInvalid > 0 || skippedFk > 0 || skippedDup > 0) {
const totalSkipped = skippedInvalid + skippedFk + skippedDup; const totalSkipped = skippedInvalid + skippedFk + skippedDup;
toast.warning(`${totalSkipped} registros fueron rechazados`); toast.warning(`${totalSkipped} registros fueron rechazados u omitidos`);
} }
} else { } else {
toast.error('No se insertaron registros. Revisa los errores a continuación.'); toast.error('No se insertaron registros. Revisa los errores a continuación.');