feat(validation): implement comprehensive validation for item creation and updates

This commit is contained in:
Galindo97
2026-01-20 12:53:08 -06:00
parent ba353493d3
commit dd6c832674
6 changed files with 611 additions and 16 deletions

View File

@@ -0,0 +1,47 @@
from typing import Optional, Union
from sqlalchemy.orm import Session
from core.exceptions import ErrorCollector
from api.v1.modules.a76.items.line_items.schemas import LineItemCreate, LineItemUpdate
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.general_catalogs.company.models import Company
# Helper function para limpiar strings (equivalente a Clip())
def clean_str(value: Optional[str]) -> Optional[str]:
if value is None or value == "":
return None
return value.strip()
def validate_common(
db: Session,
item_data: Union[LineItemCreate, LineItemUpdate],
tenant_id: int,
company_id: int,
errors: ErrorCollector,
) -> None:
"""
Validaciones comunes para creación y actualización de items/partidas.
Args:
db: Sesión de base de datos
item_data: Datos del item a validar
tenant_id: ID del tenant
company_id: ID de la compañía
errors: Colector de errores
Returns:
None (acumula errores en errors)
"""
# NOTA: LineItem no tiene invoice_id directamente, sino item_id
# La validación de invoice se hace a nivel de Item en el service
# Aquí podríamos validar otros aspectos comunes del LineItem
# TODO: Agregar más validaciones comunes según sea necesario
# Por ejemplo:
# - Validar formatos de campos
# - Validar rangos de valores numéricos
# - Validar relaciones con otras entidades
pass

View File

@@ -0,0 +1,201 @@
from typing import Optional
from sqlalchemy.orm import Session
from core.exceptions import ErrorCollector
from api.v1.modules.a76.items.line_items.schemas import LineItemCreate
# Helper function para limpiar strings (equivalente a Clip())
def clean_str(value: Optional[str]) -> Optional[str]:
if value is None or value == "":
return None
return value.strip()
def validate_create(
db: Session,
item_data: LineItemCreate,
tenant_id: int,
company_id: int,
errors: ErrorCollector,
) -> None:
"""
Valida y procesa la creación de una nueva partida/item de importación temporal.
Args:
db: Sesión de base de datos
item_data: Datos del item a validar (modificado in-place)
tenant_id: ID del tenant
company_id: ID de la compañía
errors: Colector de errores
Returns:
None (modifica item_data in-place y acumula errores en errors)
"""
# Primero ejecutar validaciones comunes
from .common import validate_common
validate_common(db, item_data, tenant_id, company_id, errors)
# ====================================================================
# CAMPOS OBLIGATORIOS
# ====================================================================
# CAMPO OBLIGATORIO: Número de línea
if item_data.line_number is None or item_data.line_number <= 0:
errors.add_error(
field="line_number",
message="El número de línea es obligatorio y debe ser mayor a 0",
solution=None,
code="REQUIRED"
)
# CAMPO OBLIGATORIO: Part number (Número de parte)
if not item_data.part_number_id:
errors.add_error(
field="part_number_id",
message="El número de parte es obligatorio",
solution=None,
code="REQUIRED"
)
# CAMPO OBLIGATORIO: Class (Clasificación arancelaria)
if not item_data.class_id:
errors.add_error(
field="class_id",
message="La clasificación arancelaria (fracción) es obligatoria",
solution=None,
code="REQUIRED"
)
# CAMPO OBLIGATORIO: Unit of measure (Unidad de medida)
if not item_data.unit_of_measure:
errors.add_error(
field="unit_of_measure",
message="La unidad de medida es obligatoria",
solution=None,
code="REQUIRED"
)
# ====================================================================
# VALIDACIONES DE DATOS ANIDADOS OBLIGATORIOS
# ====================================================================
# FINANCIAL: Debe tener datos financieros con al menos un costo unitario
if not item_data.financial:
errors.add_error(
field="financial",
message="Los datos financieros son obligatorios",
solution=None,
code="REQUIRED"
)
else:
# Al menos debe tener un costo unitario (USD o MXN)
has_cost = (
item_data.financial.unit_cost_usd is not None or
item_data.financial.unit_cost_mxn is not None or
item_data.financial.unit_cost_capture is not None
)
if not has_cost:
errors.add_error(
field="financial.unit_cost",
message="Debe proporcionar al menos un costo unitario (USD, MXN o captura)",
solution=None,
code="REQUIRED"
)
# QUANTITY: Debe tener datos de cantidad
if not item_data.quantity:
errors.add_error(
field="quantity",
message="Los datos de cantidad son obligatorios",
solution=None,
code="REQUIRED"
)
else:
if item_data.quantity.quantity_uma is None or item_data.quantity.quantity_uma <= 0:
errors.add_error(
field="quantity.quantity_uma",
message="La cantidad UMA es obligatoria y debe ser mayor a 0",
solution=None,
code="REQUIRED"
)
# DESCRIPTION: Debe tener descripción
if not item_data.description:
errors.add_error(
field="description",
message="La descripción es obligatoria",
solution=None,
code="REQUIRED"
)
else:
if not item_data.description.description_spanish or item_data.description.description_spanish.strip() == "":
errors.add_error(
field="description.description_spanish",
message="La descripción en español es obligatoria",
solution=None,
code="REQUIRED"
)
# ====================================================================
# VALIDACIONES DE FORMATO Y LONGITUD
# ====================================================================
# Validar y limpiar campos de texto
# Número de parte
if item_data.part_number_id:
# El part_number_id debe ser numérico (FK a parts table)
pass # Ya validado por Pydantic
# Validar UMA key (máximo 2 caracteres)
if item_data.uma_key:
if len(item_data.uma_key) > 2:
errors.add_error(
field="uma_key",
message="La clave UMA no puede tener más de 2 caracteres",
solution=None,
code="INVALID_LENGTH",
value=item_data.uma_key
)
# Validar número de permiso (máximo 20 caracteres)
if item_data.permit_number:
if len(item_data.permit_number) > 20:
errors.add_error(
field="permit_number",
message="El número de permiso no puede tener más de 20 caracteres",
solution=None,
code="INVALID_LENGTH",
value=item_data.permit_number
)
# Validar montos numéricos
if item_data.igi_amount is not None:
if item_data.igi_amount < 0:
errors.add_error(
field="igi_amount",
message="El monto IGI no puede ser negativo",
solution=None,
code="INVALID_VALUE",
value=str(item_data.igi_amount)
)
# Validar que si tiene certificado, venga el número
if item_data.has_certificate and not item_data.certificate_number:
errors.add_error(
field="certificate_number",
message="Si tiene certificado, debe proporcionar el número de certificado",
solution=None,
code="REQUIRED_FIELD"
)
# Validar que si tiene código FDA, venga la clave
if item_data.has_fda_code and not item_data.fda_key:
errors.add_error(
field="fda_key",
message="Si tiene código FDA, debe proporcionar la clave FDA",
solution=None,
code="REQUIRED_FIELD"
)

View File

@@ -0,0 +1,116 @@
from typing import Optional
from sqlalchemy.orm import Session
from core.exceptions import ErrorCollector
from api.v1.modules.a76.items.line_items.schemas import LineItemUpdate
from api.v1.modules.a76.items.line_items.models import LineItem
# Helper function para limpiar strings (equivalente a Clip())
def clean_str(value: Optional[str]) -> Optional[str]:
if value is None or value == "":
return None
return value.strip()
def validate_update(
db: Session,
item_data: LineItemUpdate,
existing_item: LineItem,
errors: ErrorCollector,
) -> None:
"""
Valida y procesa la actualización parcial de una partida/item de importación temporal.
Lógica: Si un campo viene con valor, se limpia/valida.
Si no, se mantiene el valor existente del item.
Args:
db: Sesión de base de datos
item_data: Datos del item a validar/actualizar (modificado in-place)
existing_item: Item existente en la base de datos
errors: Colector de errores
Returns:
None (modifica item_data in-place y acumula errores en errors)
"""
# Primero ejecutar validaciones comunes
# from .common import validate_common
# validate_common(db, item_data, errors)
# Número de línea/partida
if item_data.line_number is not None:
if item_data.line_number <= 0:
errors.add_error(
field="line_number",
message="El número de línea debe ser mayor a 0",
code="INVALID_VALUE",
value=str(item_data.line_number)
)
else:
item_data.line_number = existing_item.line_number
# Validar UMA key si viene
if item_data.uma_key is not None:
if item_data.uma_key and len(item_data.uma_key) > 2:
errors.add_error(
field="uma_key",
message="La clave UMA no puede tener más de 2 caracteres",
code="INVALID_LENGTH",
value=item_data.uma_key
)
else:
item_data.uma_key = existing_item.uma_key
# Validar número de permiso si viene
if item_data.permit_number is not None:
if item_data.permit_number and len(item_data.permit_number) > 20:
errors.add_error(
field="permit_number",
message="El número de permiso no puede tener más de 20 caracteres",
code="INVALID_LENGTH",
value=item_data.permit_number
)
else:
item_data.permit_number = existing_item.permit_number
# Validar monto IGI si viene
if item_data.igi_amount is not None:
if item_data.igi_amount < 0:
errors.add_error(
field="igi_amount",
message="El monto IGI no puede ser negativo",
code="INVALID_VALUE",
value=str(item_data.igi_amount)
)
else:
item_data.igi_amount = existing_item.igi_amount
# Validar certificado si viene
if item_data.has_certificate is not None:
if item_data.has_certificate:
# Si se marca que tiene certificado, debe venir el número o ya existir
cert_num = item_data.certificate_number if item_data.certificate_number is not None else existing_item.certificate_number
if not cert_num:
errors.add_error(
field="certificate_number",
message="Si tiene certificado, debe proporcionar el número de certificado",
code="REQUIRED_FIELD"
)
else:
item_data.has_certificate = existing_item.has_certificate
# Validar código FDA si viene
if item_data.has_fda_code is not None:
if item_data.has_fda_code:
# Si se marca que tiene código FDA, debe venir la clave o ya existir
fda_key = item_data.fda_key if item_data.fda_key is not None else existing_item.fda_key
if not fda_key:
errors.add_error(
field="fda_key",
message="Si tiene código FDA, debe proporcionar la clave FDA",
code="REQUIRED_FIELD"
)
else:
item_data.has_fda_code = existing_item.has_fda_code

View File

@@ -179,5 +179,5 @@ class LineItem(Base, TenantScopedMixin, TimestampMixin):
foreign_keys=[unit_of_measure], viewonly=True
)
fa_data: Mapped[Optional["FaLineItem"]] = relationship(
"FaLineItem", back_populates="master_info", uselist=False
"FaLineItem", back_populates="master_info", cascade="all, delete-orphan", uselist=False
)

View File

@@ -16,6 +16,10 @@ from sqlalchemy import and_, or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload
from core.exceptions import ErrorCollector
from .imports.temporary.validators.create import validate_create
from .imports.temporary.validators.update import validate_update
from api.v1.modules.a76.items.line_items.schemas import LineItemCreate, LineItemUpdate
from .schemas import ItemCreate, ItemUpdate
@@ -27,6 +31,9 @@ from .line_descriptions.models import LineDescription
from .line_references.models import LineReference
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
from .models import Item
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.general_catalogs.company.models import Company
logger = logging.getLogger(__name__)
@@ -151,6 +158,91 @@ class ItemService:
company_id: int,
) -> Item:
"""Create a new item with all related nested data (multiple lines)"""
# Validaciones con ErrorCollector
errors = ErrorCollector()
# Validar que la factura exista y no esté actualizada (si viene invoice_id)
if item_data.invoice_id:
invoice = db.query(InvoiceHeader).filter(
InvoiceHeader.id == item_data.invoice_id,
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id
).first()
if not invoice:
errors.add_error(
field="invoice_id",
message="La factura especificada no existe",
code="NOT_FOUND",
value=str(item_data.invoice_id)
)
else:
# Validar que la factura no esté actualizada (excepto RFCs especiales)
company = db.query(Company).filter(Company.id == company_id).first()
if company:
rfc_excepciones = ['TPI121217SF6', 'TCI170502858']
if company.rfc not in rfc_excepciones:
if invoice.is_updated:
errors.add_error(
field="invoice_id",
message="La factura está actualizada y no puede ser modificada",
code="INVOICE_UPDATED",
value=str(item_data.invoice_id)
)
# Validar cada line item que se va a crear
if item_data.lines:
for idx, line_data in enumerate(item_data.lines):
# Convertir a LineItemCreate para validar
line_create = LineItemCreate(**line_data.model_dump())
validate_create(db, line_create, tenant_id, company_id, errors)
# Validaciones adicionales específicas del negocio
# Validar apóstrofes en número de parte
if line_data.part_number_id and "'" in str(line_data.part_number_id):
errors.add_error(
field=f"lines[{idx}].part_number_id",
message=f"Advertencia: El Número de Parte contiene apóstrofes y serán omitidos",
code="WARNING_APOSTROPHE"
)
# Validar tipo de partida
if hasattr(line_data, 'item_type'):
tipo_partida = line_data.item_type
if tipo_partida and tipo_partida not in ['N', 'S']:
errors.add_error(
field=f"lines[{idx}].item_type",
message=f"Tipo de partida debe ser 'N' (Normal) o 'S' (Subpartida), recibido: '{tipo_partida}'",
code="INVALID_ITEM_TYPE",
value=str(tipo_partida)
)
# Si es subpartida (S), debe tener partida principal
if tipo_partida == 'S':
if not hasattr(line_data, 'main_line_id') or not line_data.main_line_id:
errors.add_error(
field=f"lines[{idx}].main_line_id",
message="Las subpartidas (tipo 'S') deben tener una partida principal",
code="MISSING_MAIN_LINE"
)
# Validar que el line_number sea consecutivo (si se especifica)
if hasattr(line_data, 'line_number') and line_data.line_number:
expected_line = idx + 1
if line_data.line_number != expected_line:
errors.add_error(
field=f"lines[{idx}].line_number",
message=f"Número de línea esperado: {expected_line}, recibido: {line_data.line_number}",
code="INVALID_LINE_SEQUENCE",
value=str(line_data.line_number)
)
# Si hay errores, lanzar excepción ANTES de intentar crear
errors.raise_if_errors("Error al crear el item")
try:
# Extract lines data
lines_data = item_data.lines or []
@@ -231,7 +323,7 @@ class ItemService:
# Create FA data if provided
if fa_data:
fa_dict = fa_data.model_dump()
fa_dict = fa_data.model_dump(exclude={"line_item_id"}) # Exclude line_item_id from DTO
fa_dict["id"] = db_line.id # FA table uses same ID as line item
fa_dict["tenant_id"] = tenant_id
fa_dict["company_id"] = company_id
@@ -263,11 +355,84 @@ class ItemService:
company_id: int,
) -> Item:
"""Update an item and optionally its nested data (multiple lines)"""
# Get existing item
db_item = ItemService.get_by_id(db, item_id, tenant_id, company_id)
if not db_item:
raise HTTPException(status_code=404, detail="Item not found")
# Validaciones con ErrorCollector
errors = ErrorCollector()
# Validar cada line item que se va a actualizar
if item_data.lines:
for idx, line_data in enumerate(item_data.lines):
# Si el line tiene ID, es actualización; si no, es creación
if hasattr(line_data, 'id') and line_data.id:
# Buscar el line item existente
existing_line = next(
(line for line in db_item.lines if line.id == line_data.id),
None
)
if existing_line:
# Convertir a LineItemUpdate para validar
line_update = LineItemUpdate(**line_data.model_dump())
validate_update(db, line_update, existing_line, errors)
else:
# Es un nuevo line item, validar como creación
line_create = LineItemCreate(**line_data.model_dump())
validate_create(db, line_create, tenant_id, company_id, errors)
# Validaciones adicionales específicas del negocio
# (Aplican tanto para crear como actualizar)
# Validar apóstrofes en número de parte
if line_data.part_number_id and "'" in str(line_data.part_number_id):
errors.add_error(
field=f"lines[{idx}].part_number_id",
message=f"Advertencia: El Número de Parte contiene apóstrofes y serán omitidos",
solution=None,
code="WARNING_APOSTROPHE"
)
# Validar tipo de partida
if hasattr(line_data, 'item_type'):
tipo_partida = line_data.item_type
if tipo_partida and tipo_partida not in ['N', 'S']:
errors.add_error(
field=f"lines[{idx}].item_type",
message=f"Tipo de partida debe ser 'N' (Normal) o 'S' (Subpartida), recibido: '{tipo_partida}'",
solution=None,
code="INVALID_ITEM_TYPE",
value=str(tipo_partida)
)
# Si es subpartida (S), debe tener partida principal
if tipo_partida == 'S':
if not hasattr(line_data, 'main_line_id') or not line_data.main_line_id:
errors.add_error(
field=f"lines[{idx}].main_line_id",
message="Las subpartidas (tipo 'S') deben tener una partida principal",
solution=None,
code="MISSING_MAIN_LINE"
)
# Validar que el line_number sea consecutivo (si se especifica)
if hasattr(line_data, 'line_number') and line_data.line_number:
expected_line = idx + 1
if line_data.line_number != expected_line:
errors.add_error(
field=f"lines[{idx}].line_number",
message=f"Número de línea esperado: {expected_line}, recibido: {line_data.line_number}",
solution=None,
code="INVALID_LINE_SEQUENCE",
value=str(line_data.line_number)
)
# Si hay errores, lanzar excepción ANTES de actualizar
errors.raise_if_errors("Error al actualizar el item")
try:
# Get existing item
db_item = ItemService.get_by_id(db, item_id, tenant_id, company_id)
if not db_item:
raise HTTPException(status_code=404, detail="Item not found")
# Extract lines data
lines_data = item_data.lines
@@ -343,7 +508,7 @@ class ItemService:
# Create FA data if provided
if fa_data is not None:
fa_dict = fa_data.model_dump(exclude_unset=True)
fa_dict = fa_data.model_dump(exclude_unset=True, exclude={"line_item_id"})
fa_dict["id"] = db_line.id # FA table uses same ID as line item
fa_dict["tenant_id"] = tenant_id
fa_dict["company_id"] = company_id