Merge pull request 'fix/agentes_aduanales_regex' (#190) from fix/agentes_aduanales_regex into development

Reviewed-on: ADUANASOFT/anexo76#190
This commit is contained in:
2026-03-06 15:52:48 +00:00
7 changed files with 420 additions and 83 deletions

View File

@@ -12,16 +12,16 @@ class CustomsBrokerBaseDTO(BaseModel):
postal_code: Optional[str] = None
city: Optional[str] = None
state: Optional[str] = None
phone: Optional[str] = None
phone: Optional[str] = Field(None, pattern=r"^$|^[\d\s\-\+\(\)]+$")
fax: Optional[str] = None
email: Optional[str] = None
email: Optional[str] = Field(None, pattern=r"^$|^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
country: Optional[str] = None
tax_id: Optional[str] = None
personal_id: Optional[str] = None
tax_id: Optional[str] = Field(None, pattern=r"^$|^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$")
personal_id: Optional[str] = Field(None, pattern=r"^$|^[A-Z][AEIOUX][A-Z]{2}\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])[HM](AS|BC|BS|CC|CS|CH|CL|CM|DF|DG|GT|GR|HG|JC|MC|MN|MS|NT|NL|OC|PL|QT|QR|SP|SL|SR|TC|TS|TL|VZ|YN|ZS|NE)[B-DF-HJ-NP-TV-Z]{3}[0-9A-Z]\d$")
position: Optional[str] = None
license: Optional[str] = Field(None, max_length=4, pattern=r"^\d*$")
license: Optional[str] = Field(None, max_length=4, pattern=r"^$|^[0-9]*[1-9][0-9]*$")
company: Optional[str] = None
contact: Optional[str] = None
contact: Optional[str] = Field(None, pattern=r"^$|^[a-zA-Z0-9\sñÑáéíóúÁÉÍÓÚ\-\.,]+$")
class CustomsBrokerCreateDTO(CustomsBrokerBaseDTO):
@@ -58,16 +58,16 @@ class CustomsBrokerDTO(BaseModel):
postal_code: Optional[str] = None
city: Optional[str] = None
state: Optional[str] = None
phone: Optional[str] = None
phone: Optional[str] = Field(None, pattern=r"^$|^[\d\s\-\+\(\)]+$")
fax: Optional[str] = None
email: Optional[str] = None
email: Optional[str] = Field(None, pattern=r"^$|^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
country: Optional[str] = None
tax_id: Optional[str] = None
personal_id: Optional[str] = None
tax_id: Optional[str] = Field(None, pattern=r"^$|^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$")
personal_id: Optional[str] = Field(None, pattern=r"^$|^[A-Z][AEIOUX][A-Z]{2}\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])[HM](AS|BC|BS|CC|CS|CH|CL|CM|DF|DG|GT|GR|HG|JC|MC|MN|MS|NT|NL|OC|PL|QT|QR|SP|SL|SR|TC|TS|TL|VZ|YN|ZS|NE)[B-DF-HJ-NP-TV-Z]{3}[0-9A-Z]\d$")
position: Optional[str] = None
license: Optional[str] = Field(None, max_length=4, pattern=r"^\d*$")
license: Optional[str] = Field(None, max_length=4, pattern=r"^$|^[0-9]*[1-9][0-9]*$")
company: Optional[str] = None
contact: Optional[str] = None
contact: Optional[str] = Field(None, pattern=r"^$|^[a-zA-Z0-9\sñÑáéíóúÁÉÍÓÚ\-\.,]+$")
tenant_id: str
company_id: str
@@ -109,14 +109,14 @@ class CustomsBrokerPersonnelDTO(BaseModel):
broker_key: str = Field(..., max_length=5, pattern=r"^[a-zA-Z0-9]+$")
line: int
name: Optional[str] = None
tax_id: Optional[str] = None
personal_id: Optional[str] = None
tax_id: Optional[str] = Field(None, pattern=r"^$|^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$")
personal_id: Optional[str] = Field(None, pattern=r"^$|^[A-Z][AEIOUX][A-Z]{2}\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])[HM](AS|BC|BS|CC|CS|CH|CL|CM|DF|DG|GT|GR|HG|JC|MC|MN|MS|NT|NL|OC|PL|QT|QR|SP|SL|SR|TC|TS|TL|VZ|YN|ZS|NE)[B-DF-HJ-NP-TV-Z]{3}[0-9A-Z]\d$")
position: Optional[str] = None
license: Optional[str] = Field(None, max_length=4, pattern=r"^\d*$")
license: Optional[str] = Field(None, max_length=4, pattern=r"^$|^[0-9]*[1-9][0-9]*$")
first_name: Optional[str] = None
last_name: Optional[str] = None
middle_name: Optional[str] = None
email: Optional[str] = None
email: Optional[str] = Field(None, pattern=r"^$|^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
tenant_id: Optional[int] = None
company_id: Optional[int] = None

View File

@@ -1,4 +1,5 @@
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from . import dto, models
@@ -45,9 +46,13 @@ class CustomsBrokerService:
new_broker = models.CustomsBroker(**broker_dict)
db.add(new_broker)
db.commit()
db.refresh(new_broker)
return new_broker
try:
db.commit()
db.refresh(new_broker)
return new_broker
except IntegrityError:
db.rollback()
raise ValueError("La clave del agente ya existe o hay datos duplicados.")
@staticmethod
def update(db: Session, broker_key: str, tenant_id: int, broker_data: dto.CustomsBrokerUpdateDTO, company_id: int):
@@ -56,8 +61,12 @@ class CustomsBrokerService:
if broker:
for key, value in broker_data.model_dump(exclude_unset=True).items():
setattr(broker, key, value)
db.commit()
db.refresh(broker)
try:
db.commit()
db.refresh(broker)
except IntegrityError:
db.rollback()
raise ValueError("Los datos duplicados no pueden ser guardados o hay un conflicto de integridad.")
return broker
@staticmethod

View File

@@ -8,7 +8,9 @@ from typing import Any, Dict
from fastapi import Request, status, HTTPException
from fastapi.responses import JSONResponse
from fastapi.encoders import jsonable_encoder
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from pydantic import ValidationError
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from .config import settings
@@ -57,20 +59,69 @@ async def base_exception_handler(
return response
# Mapa de campos técnicos a nombres legibles en español
_FIELD_LABELS: Dict[str, str] = {
"broker_key": "Clave del Agente",
"license": "Patente",
"tax_id": "RFC",
"personal_id": "CURP",
"email": "Correo Electrónico",
"phone": "Teléfono",
"fax": "Fax",
"contact": "Nombre de Contacto",
"name": "Nombre / Razón Social",
"address": "Dirección",
"postal_code": "Código Postal",
"city": "Ciudad",
"state": "Estado",
"country": "País",
}
_FIELD_PATTERN_MESSAGES: Dict[str, str] = {
"broker_key": "La Clave del Agente solo puede contener letras y números (máx. 5 caracteres).",
"license": "La Patente debe ser un número entre 1 y 9999 (no puede ser 0 ni contener letras).",
"tax_id": "El RFC no tiene el formato correcto. Ejemplo válido: XAXX010101000.",
"personal_id": "La CURP no tiene el formato correcto. Debe tener 18 caracteres alfanuméricos.",
"email": "El correo electrónico no tiene un formato válido. Ejemplo: usuario@dominio.com.",
"phone": "El teléfono solo puede contener dígitos, espacios y los símbolos: +, -, (, ).",
"contact": "El nombre de contacto contiene caracteres no permitidos. Use solo letras, números y puntuación básica.",
}
def _friendly_message(field_key: str, error_type: str) -> str:
"""Devuelve un mensaje de error legible en español según el campo y tipo de error."""
if error_type in ("string_pattern_mismatch", "value_error"):
return _FIELD_PATTERN_MESSAGES.get(
field_key,
f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' contiene un valor con formato inválido.",
)
if error_type == "string_too_long":
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' excede la longitud máxima permitida."
if error_type == "string_too_short":
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' es demasiado corto."
if error_type in ("missing", "value_error.missing"):
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' es obligatorio."
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' contiene un valor inválido."
async def validation_exception_handler(
request: Request,
exc: RequestValidationError,
) -> JSONResponse:
"""
Manejador para errores de validación de Pydantic/FastAPI
Manejador para errores de validación de Pydantic/FastAPI.
Devuelve mensajes legibles en español.
"""
errors = []
for error in exc.errors():
field = ".".join(str(loc) for loc in error["loc"] if loc != "body")
loc_parts = [str(loc) for loc in error["loc"] if loc != "body"]
field = ".".join(loc_parts)
field_key = loc_parts[-1] if loc_parts else ""
errors.append(
{
"field": field,
"message": error["msg"],
"message": _friendly_message(field_key, error["type"]),
"type": error["type"],
}
)
@@ -80,11 +131,63 @@ async def validation_exception_handler(
extra={"errors": errors},
)
summary = (
errors[0]["message"]
if len(errors) == 1
else f"Hay {len(errors)} errores de validación: " + " | ".join(e["message"] for e in errors)
)
response = JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={
"error": "VALIDATION_ERROR",
"message": "Error de validación en los datos recibidos",
"message": summary,
"status_code": status.HTTP_422_UNPROCESSABLE_ENTITY,
"errors": errors,
},
)
for k, v in _cors_headers(request).items():
response.headers[k] = v
return response
async def inner_validation_exception_handler(
request: Request,
exc: ValidationError,
) -> JSONResponse:
"""
Manejador para errores de validación de Pydantic lanzados internamente (como en tenant_crud_routes).
"""
errors = []
for error in exc.errors():
loc_parts = [str(loc) for loc in error["loc"] if loc != "body"]
field = ".".join(loc_parts)
field_key = loc_parts[-1] if loc_parts else ""
errors.append(
{
"field": field,
"message": _friendly_message(field_key, error["type"]),
"type": error["type"],
}
)
logger.warning(
f"Inner Validation Error en {request.url.path}",
extra={"errors": errors},
)
summary = (
errors[0]["message"]
if len(errors) == 1
else f"Hay {len(errors)} errores de validación: " + " | ".join(e["message"] for e in errors)
)
response = JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={
"error": "VALIDATION_ERROR",
"message": summary,
"status_code": status.HTTP_422_UNPROCESSABLE_ENTITY,
"errors": errors,
},
@@ -221,6 +324,7 @@ def register_exception_handlers(app) -> None:
app.add_exception_handler(BaseAPIException, base_exception_handler)
app.add_exception_handler(HTTPException, http_exception_handler)
app.add_exception_handler(RequestValidationError, validation_exception_handler)
app.add_exception_handler(ValidationError, inner_validation_exception_handler)
app.add_exception_handler(IntegrityError, integrity_error_handler)
app.add_exception_handler(SQLAlchemyError, sqlalchemy_error_handler)
app.add_exception_handler(Exception, general_exception_handler)

View File

@@ -153,36 +153,11 @@ def _cors_headers_for_request(request: Request):
return {}
# Add validation error handler
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
logger.error(
f"Validation error for {request.method} {request.url.path}: {exc.errors()}"
)
logger.error(f"Request body: {await request.body()}")
response = JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": exc.errors(), "body": exc.body},
)
for k, v in _cors_headers_for_request(request).items():
response.headers[k] = v
return response
# Add HTTP exception handler
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
logger.error(
f"HTTP {exc.status_code} for {request.method} {request.url.path}: {exc.detail}"
)
response = JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
)
for k, v in _cors_headers_for_request(request).items():
response.headers[k] = v
return response
def run_migrations():
subprocess.run(["alembic", "upgrade", "head"], check=True)