feature/csv-correccion-clientes
This commit is contained in:
@@ -7,7 +7,7 @@ from typing import List, Optional
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, validate_access_to_resource
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
|
||||
from .models import ClientOrProviderEnum
|
||||
@@ -35,6 +35,9 @@ async def get_clients_and_providers(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
name: Optional[str] = Query(None, description="Filter by name (contains)"),
|
||||
rfc: Optional[str] = Query(None, description="Filter by RFC/TAX-ID (contains)"),
|
||||
short_name: Optional[str] = Query(
|
||||
None, description="Filter by short name / clave (exact match, case-insensitive)"
|
||||
),
|
||||
type: Optional[ClientOrProviderEnum] = Query(
|
||||
None, description="Type of entity (client or provider)"
|
||||
),
|
||||
@@ -68,6 +71,10 @@ async def get_clients_and_providers(
|
||||
if rfc:
|
||||
query = query.filter(ClientProvider.rfc.ilike(f"%{rfc.strip()}%"))
|
||||
|
||||
if short_name:
|
||||
sn = short_name.strip().upper()
|
||||
query = query.filter(func.upper(ClientProvider.short_name) == sn)
|
||||
|
||||
if type is not None:
|
||||
# Include 'both' type when filtering by client or provider
|
||||
query = query.filter(
|
||||
|
||||
@@ -8,6 +8,12 @@ from typing import Dict, Any, Optional
|
||||
|
||||
from api.v1.modules.a76.clients_and_providers.models import ClientOrProviderEnum
|
||||
|
||||
|
||||
def short_name_import_key(raw: Optional[str]) -> str:
|
||||
"""Clave estable para upsert CSV y validación ACT (strip + upper)."""
|
||||
return (raw or "").strip().upper()
|
||||
|
||||
|
||||
RFC_MAX = 30
|
||||
|
||||
# Formato RFC México: 3-4 letras, 6 dígitos, 3 homoclave. TAX-ID: 2 dígitos, guión, resto. Ej: 12-3456789.
|
||||
|
||||
@@ -91,6 +91,10 @@ def _get_redis():
|
||||
async def upload_import_file(
|
||||
file: UploadFile = File(...),
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
actualizar: bool = Query(
|
||||
False,
|
||||
description="Modo Agregar/Actualizar (ACT); si True, validación parcial para claves existentes en catálogo",
|
||||
),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
@@ -114,6 +118,7 @@ async def upload_import_file(
|
||||
"company_id": company_id,
|
||||
"user_id": current_user.get("id"),
|
||||
"template_id": "client_providers",
|
||||
"actualizar": actualizar,
|
||||
}
|
||||
|
||||
try:
|
||||
|
||||
@@ -19,6 +19,7 @@ from ..common import csv_reader as common_csv_reader
|
||||
from .template_config import row_from_template
|
||||
from .validators import validate_row_client_provider
|
||||
from .common.mappers import row_to_client_provider_data
|
||||
from .common.common_validators import short_name_import_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -64,8 +65,9 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str,
|
||||
)
|
||||
.all()
|
||||
):
|
||||
if (cp.short_name or "").strip():
|
||||
existing_short_names.add((cp.short_name or "").strip())
|
||||
sn = short_name_import_key(cp.short_name)
|
||||
if sn:
|
||||
existing_short_names.add(sn)
|
||||
except Exception as e:
|
||||
logger.warning("CP import: could not load existing short_names for ACT: %s", e)
|
||||
|
||||
@@ -159,8 +161,9 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
|
||||
)
|
||||
.all()
|
||||
):
|
||||
if (cp.short_name or "").strip():
|
||||
existing_short_names.add((cp.short_name or "").strip())
|
||||
sn = short_name_import_key(cp.short_name)
|
||||
if sn:
|
||||
existing_short_names.add(sn)
|
||||
except Exception as e:
|
||||
logger.warning("CP commit: could not load existing short_names for ACT: %s", e)
|
||||
|
||||
@@ -178,7 +181,6 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
|
||||
|
||||
try:
|
||||
with CoreSessionLocal() as session:
|
||||
existing_by_rfc: Dict[str, ClientProvider] = {}
|
||||
existing_by_short_name: Dict[str, ClientProvider] = {}
|
||||
for cp in (
|
||||
session.query(ClientProvider)
|
||||
@@ -188,10 +190,7 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
|
||||
)
|
||||
.all()
|
||||
):
|
||||
rfc_key = (cp.rfc or "").strip()
|
||||
if rfc_key:
|
||||
existing_by_rfc[rfc_key] = cp
|
||||
sn_key = (cp.short_name or "").strip()
|
||||
sn_key = short_name_import_key(cp.short_name)
|
||||
if sn_key:
|
||||
existing_by_short_name[sn_key] = cp
|
||||
|
||||
@@ -227,30 +226,22 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
|
||||
}
|
||||
)
|
||||
continue
|
||||
if actualizar:
|
||||
short_name_key = (cp_data.get("short_name") or "").strip()
|
||||
if not short_name_key:
|
||||
skipped_invalid += 1
|
||||
skipped_details.append(
|
||||
{
|
||||
"line": i,
|
||||
"reason": "Clave (SHORT_NAME) requerida en modo Actualizar",
|
||||
"solution": "Capturar en la columna SHORT_NAME la clave requerida para modo Actualizar.",
|
||||
}
|
||||
)
|
||||
continue
|
||||
existing = existing_by_short_name.get(short_name_key)
|
||||
if not existing:
|
||||
skipped_invalid += 1
|
||||
skipped_details.append(
|
||||
{
|
||||
"line": i,
|
||||
"reason": "Clave no existe en catálogo",
|
||||
"solution": "Capturar en la columna SHORT_NAME una clave que exista en el catálogo.",
|
||||
}
|
||||
)
|
||||
continue
|
||||
# Merge: fill from existing when csv value is empty
|
||||
|
||||
sn_key = short_name_import_key(cp_data.get("short_name"))
|
||||
if not sn_key:
|
||||
skipped_invalid += 1
|
||||
skipped_details.append(
|
||||
{
|
||||
"line": i,
|
||||
"reason": "Clave (SHORT_NAME) requerida",
|
||||
"solution": "Capturar en la columna SHORT_NAME la clave del cliente/proveedor.",
|
||||
}
|
||||
)
|
||||
continue
|
||||
cp_data["short_name"] = sn_key
|
||||
|
||||
existing = existing_by_short_name.get(sn_key)
|
||||
if existing:
|
||||
for k, v in cp_data.items():
|
||||
if k in ("tenant_id", "company_id"):
|
||||
continue
|
||||
@@ -263,7 +254,6 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
|
||||
setattr(existing, k, v)
|
||||
session.add(existing)
|
||||
updated_count += 1
|
||||
# Update address if present
|
||||
if address_data and existing.address:
|
||||
addr = existing.address
|
||||
for k, v in address_data.items():
|
||||
@@ -288,7 +278,6 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
|
||||
fax_number=address_data.get("fax_number"),
|
||||
)
|
||||
session.add(addr)
|
||||
# Update or create programs
|
||||
if programs_data:
|
||||
prog = session.query(ClientProviderPrograms).filter(
|
||||
ClientProviderPrograms.client_id == existing.id,
|
||||
@@ -307,100 +296,37 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
|
||||
)
|
||||
session.add(prog)
|
||||
else:
|
||||
# Alta / Reemplazar: key por RFC
|
||||
if not cp_data.get("rfc"):
|
||||
skipped_invalid += 1
|
||||
skipped_details.append(
|
||||
{
|
||||
"line": i,
|
||||
"reason": "RFC requerido",
|
||||
"solution": "Capturar en la columna RFC el RFC requerido.",
|
||||
}
|
||||
new_cp = ClientProvider(**cp_data)
|
||||
session.add(new_cp)
|
||||
session.flush()
|
||||
existing_by_short_name[sn_key] = new_cp
|
||||
inserted_count += 1
|
||||
if address_data:
|
||||
addr = ClientProviderAddress(
|
||||
client_id=new_cp.id,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
streets=address_data.get("streets"),
|
||||
postal_code=address_data.get("postal_code"),
|
||||
city=address_data.get("city"),
|
||||
state=address_data.get("state"),
|
||||
country=address_data.get("country"),
|
||||
phone=address_data.get("phone"),
|
||||
email=address_data.get("email"),
|
||||
contact=address_data.get("contact"),
|
||||
exterior_number=address_data.get("exterior_number"),
|
||||
neighborhood=address_data.get("neighborhood"),
|
||||
fax_number=address_data.get("fax_number"),
|
||||
)
|
||||
continue
|
||||
rfc = cp_data["rfc"]
|
||||
existing = existing_by_rfc.get(rfc)
|
||||
if existing:
|
||||
for k, v in cp_data.items():
|
||||
if k not in ("tenant_id", "company_id", "rfc"):
|
||||
setattr(existing, k, v)
|
||||
session.add(existing)
|
||||
updated_count += 1
|
||||
if address_data and existing.address:
|
||||
addr = existing.address
|
||||
for k, v in address_data.items():
|
||||
if k not in ("tenant_id", "company_id") and v is not None:
|
||||
setattr(addr, k, v)
|
||||
session.add(addr)
|
||||
elif address_data:
|
||||
addr = ClientProviderAddress(
|
||||
client_id=existing.id,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
streets=address_data.get("streets"),
|
||||
postal_code=address_data.get("postal_code"),
|
||||
city=address_data.get("city"),
|
||||
state=address_data.get("state"),
|
||||
country=address_data.get("country"),
|
||||
phone=address_data.get("phone"),
|
||||
email=address_data.get("email"),
|
||||
contact=address_data.get("contact"),
|
||||
exterior_number=address_data.get("exterior_number"),
|
||||
neighborhood=address_data.get("neighborhood"),
|
||||
fax_number=address_data.get("fax_number"),
|
||||
)
|
||||
session.add(addr)
|
||||
if programs_data:
|
||||
prog = session.query(ClientProviderPrograms).filter(
|
||||
ClientProviderPrograms.client_id == existing.id,
|
||||
).first()
|
||||
if prog:
|
||||
for k, v in programs_data.items():
|
||||
if v is not None:
|
||||
setattr(prog, k, v)
|
||||
session.add(prog)
|
||||
else:
|
||||
prog = ClientProviderPrograms(
|
||||
client_id=existing.id,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
**{k: v for k, v in programs_data.items() if v is not None},
|
||||
)
|
||||
session.add(prog)
|
||||
else:
|
||||
new_cp = ClientProvider(**cp_data)
|
||||
session.add(new_cp)
|
||||
session.flush()
|
||||
existing_by_rfc[rfc] = new_cp
|
||||
if (new_cp.short_name or "").strip():
|
||||
existing_by_short_name[(new_cp.short_name or "").strip()] = new_cp
|
||||
inserted_count += 1
|
||||
if address_data:
|
||||
addr = ClientProviderAddress(
|
||||
client_id=new_cp.id,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
streets=address_data.get("streets"),
|
||||
postal_code=address_data.get("postal_code"),
|
||||
city=address_data.get("city"),
|
||||
state=address_data.get("state"),
|
||||
country=address_data.get("country"),
|
||||
phone=address_data.get("phone"),
|
||||
email=address_data.get("email"),
|
||||
contact=address_data.get("contact"),
|
||||
exterior_number=address_data.get("exterior_number"),
|
||||
neighborhood=address_data.get("neighborhood"),
|
||||
fax_number=address_data.get("fax_number"),
|
||||
)
|
||||
session.add(addr)
|
||||
if programs_data:
|
||||
prog = ClientProviderPrograms(
|
||||
client_id=new_cp.id,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
**{k: v for k, v in programs_data.items() if v is not None},
|
||||
)
|
||||
session.add(prog)
|
||||
session.add(addr)
|
||||
if programs_data:
|
||||
prog = ClientProviderPrograms(
|
||||
client_id=new_cp.id,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
**{k: v for k, v in programs_data.items() if v is not None},
|
||||
)
|
||||
session.add(prog)
|
||||
|
||||
try:
|
||||
session.commit()
|
||||
|
||||
@@ -24,6 +24,7 @@ from ..common.common_validators import (
|
||||
check_es_empresa_certificada_registro,
|
||||
check_transformador_submaq,
|
||||
check_desfase,
|
||||
short_name_import_key,
|
||||
)
|
||||
|
||||
|
||||
@@ -36,8 +37,6 @@ MSG_CLAVE_VACIA = (
|
||||
MSG_CLAVE_VACIA_SOLUCION = (
|
||||
"Capturar una Clave de Cliente/Proveedor nueva o existente a la cual desee agregar, remplazar o actualizar campos."
|
||||
)
|
||||
MSG_CLAVE_NO_EXISTE = "Error: (Col.C) Clave de Proveedor/Cliente no existe."
|
||||
MSG_CLAVE_NO_EXISTE_SOLUCION = "La clave debe existir en el catálogo cuando el modo es Actualizar."
|
||||
|
||||
|
||||
def validate_row_desfase(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
|
||||
@@ -91,7 +90,6 @@ def valida_toda_cliente_o_prov(
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
VALIDA_TODA: obligatorios Col A (Procedencia), Col D (Nombre) cuando no es ACT.
|
||||
Si modo ACT y clave no existe → error se devuelve antes (en validate_row_client_provider).
|
||||
Luego ejecuta VALIDACIONES_CLIENTE_O_PROV.
|
||||
"""
|
||||
campos_oblig = []
|
||||
@@ -129,9 +127,8 @@ def validate_row_client_provider(
|
||||
Valida una fila de CSV de clientes y proveedores.
|
||||
- Desfase (COL_EXTRA) primero.
|
||||
- Clave vacía → error.
|
||||
- Si actualizar y clave no existe en existing_short_names → error "Clave no existe".
|
||||
- Si actualizar y clave existe → VALIDA_PARCIAL (solo validaciones comunes).
|
||||
- Si no actualizar o clave no existe → VALIDA_TODA (A y D obligatorios cuando aplique, luego comunes).
|
||||
- Si actualizar y clave existe en catálogo → VALIDA_PARCIAL (solo validaciones comunes).
|
||||
- Si no actualizar, o actualizar con clave nueva → VALIDA_TODA (A y D obligatorios cuando aplique, luego comunes).
|
||||
Además se validan RFC (requerido max 30), longitudes NOMBRE/SHORT_NAME/CURP para compatibilidad.
|
||||
"""
|
||||
errors: List[Dict[str, Any]] = []
|
||||
@@ -143,17 +140,9 @@ def validate_row_client_provider(
|
||||
if err:
|
||||
errors.append(err)
|
||||
|
||||
short_name = (row.get("SHORT_NAME") or "").strip()
|
||||
sn_key = short_name_import_key(row.get("SHORT_NAME"))
|
||||
existing = existing_short_names or set()
|
||||
use_partial = actualizar and short_name in existing
|
||||
|
||||
if actualizar and short_name and short_name not in existing:
|
||||
errors.append({
|
||||
"line": line_num,
|
||||
"col": "SHORT_NAME",
|
||||
"msg": MSG_CLAVE_NO_EXISTE,
|
||||
"solution": MSG_CLAVE_NO_EXISTE_SOLUCION,
|
||||
})
|
||||
use_partial = actualizar and sn_key in existing
|
||||
|
||||
if use_partial:
|
||||
errors.extend(valida_parcial_cliente_o_prov(row, line_num))
|
||||
|
||||
Reference in New Issue
Block a user