feature/csv-clarion-exchange-type
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.');
|
||||
|
||||
Reference in New Issue
Block a user