feat(validation): enhance item validation and update UI components for required fields
This commit is contained in:
@@ -60,7 +60,7 @@ class UnitOfMeasureCustoms(Base, TimestampMixin):
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(Integer, nullable=False) # CLAVE
|
||||
code: Mapped[str] = mapped_column(String(10), nullable=False) # CLAVE
|
||||
description: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
|
||||
|
||||
|
||||
@@ -1,47 +1,158 @@
|
||||
from typing import Optional, Union
|
||||
"""
|
||||
Funciones helper compartidas para validaciones de items.
|
||||
"""
|
||||
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
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# 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(
|
||||
def validate_catalog_reference(
|
||||
db: Session,
|
||||
item_data: Union[LineItemCreate, LineItemUpdate],
|
||||
model_class,
|
||||
id_value: Optional[int],
|
||||
field_name: str,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
company_id: Optional[int],
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
error_message: str = None
|
||||
) -> bool:
|
||||
"""
|
||||
Validaciones comunes para creación y actualización de items/partidas.
|
||||
Valida que una referencia a catálogo exista en la base de datos.
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos
|
||||
item_data: Datos del item a validar
|
||||
model_class: Clase del modelo SQLAlchemy a consultar
|
||||
id_value: ID a validar
|
||||
field_name: Nombre del campo para el error
|
||||
tenant_id: ID del tenant
|
||||
company_id: ID de la compañía
|
||||
company_id: ID de la compañía (opcional)
|
||||
errors: Colector de errores
|
||||
|
||||
error_message: Mensaje personalizado de error
|
||||
|
||||
Returns:
|
||||
None (acumula errores en errors)
|
||||
True si existe, False si no
|
||||
"""
|
||||
if not id_value:
|
||||
return False
|
||||
|
||||
# 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
|
||||
query = db.query(model_class).filter(
|
||||
model_class.id == id_value,
|
||||
model_class.tenant_id == tenant_id
|
||||
)
|
||||
|
||||
# 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
|
||||
# Agregar filtro de company_id si el modelo lo tiene y se proporciona
|
||||
if company_id and hasattr(model_class, 'company_id'):
|
||||
query = query.filter(model_class.company_id == company_id)
|
||||
|
||||
exists = query.first() is not None
|
||||
|
||||
if not exists:
|
||||
msg = error_message or f"El valor {id_value} no existe en el catálogo"
|
||||
errors.add_error(
|
||||
field=field_name,
|
||||
message=msg,
|
||||
solution="Selecciona un valor válido del catálogo",
|
||||
code="NOT_FOUND"
|
||||
)
|
||||
|
||||
return exists
|
||||
|
||||
|
||||
def validate_positive_value(
|
||||
value: Optional[float],
|
||||
field_name: str,
|
||||
errors: ErrorCollector,
|
||||
required: bool = True,
|
||||
allow_zero: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
Valida que un valor numérico sea positivo.
|
||||
|
||||
Args:
|
||||
value: Valor a validar
|
||||
field_name: Nombre del campo para el error
|
||||
errors: Colector de errores
|
||||
required: Si el campo es obligatorio
|
||||
allow_zero: Si se permite el valor cero
|
||||
|
||||
Returns:
|
||||
True si es válido, False si no
|
||||
"""
|
||||
if value is None:
|
||||
if required:
|
||||
errors.add_error(
|
||||
field=field_name,
|
||||
message=f"El campo {field_name} es obligatorio",
|
||||
solution="Proporciona un valor válido",
|
||||
code="REQUIRED"
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
if allow_zero and value == 0:
|
||||
return True
|
||||
|
||||
if value <= 0:
|
||||
errors.add_error(
|
||||
field=field_name,
|
||||
message=f"El campo {field_name} debe ser mayor a cero",
|
||||
solution="Proporciona un valor positivo",
|
||||
code="INVALID_VALUE"
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def normalize_yes_no_value(value: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
Normaliza valores SI/NO a formato estándar.
|
||||
|
||||
Args:
|
||||
value: Valor a normalizar (SI, NO, S, N)
|
||||
|
||||
Returns:
|
||||
'SI' o 'NO', o None si el valor es None
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
|
||||
val = value.upper().strip()
|
||||
if val in ['SI', 'S']:
|
||||
return 'SI'
|
||||
elif val in ['NO', 'N']:
|
||||
return 'NO'
|
||||
|
||||
return value # Retornar original si no coincide
|
||||
|
||||
|
||||
def validate_string_not_empty(
|
||||
value: Optional[str],
|
||||
field_name: str,
|
||||
errors: ErrorCollector,
|
||||
required: bool = True
|
||||
) -> bool:
|
||||
"""
|
||||
Valida que un string no esté vacío.
|
||||
|
||||
Args:
|
||||
value: Valor a validar
|
||||
field_name: Nombre del campo para el error
|
||||
errors: Colector de errores
|
||||
required: Si el campo es obligatorio
|
||||
|
||||
Returns:
|
||||
True si es válido, False si no
|
||||
"""
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
if required:
|
||||
errors.add_error(
|
||||
field=field_name,
|
||||
message=f"El campo {field_name} no puede estar vacío",
|
||||
solution="Proporciona un valor válido",
|
||||
code="REQUIRED"
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
@@ -1,201 +1,118 @@
|
||||
from typing import Optional
|
||||
"""
|
||||
Validaciones para creación de items vía API.
|
||||
"""
|
||||
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()
|
||||
from .common import validate_string_not_empty, validate_positive_value
|
||||
|
||||
|
||||
def validate_create(
|
||||
db: Session,
|
||||
item_data: LineItemCreate,
|
||||
line: LineItemCreate,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
errors: ErrorCollector,
|
||||
errors: ErrorCollector
|
||||
) -> None:
|
||||
"""
|
||||
Valida y procesa la creación de una nueva partida/item de importación temporal.
|
||||
Validaciones para crear LineItems vía API (actualmente en uso).
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos
|
||||
item_data: Datos del item a validar (modificado in-place)
|
||||
line: Datos del line item
|
||||
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:
|
||||
# 1. Validar line_number
|
||||
if not line.line_number:
|
||||
errors.add_error(
|
||||
field="line_number",
|
||||
message="El número de línea es obligatorio y debe ser mayor a 0",
|
||||
solution=None,
|
||||
message="El número de línea es obligatorio",
|
||||
solution="Proporciona un número de línea válido",
|
||||
code="REQUIRED"
|
||||
)
|
||||
|
||||
# CAMPO OBLIGATORIO: Part number (Número de parte)
|
||||
if not item_data.part_number_id:
|
||||
# 2. Validar part_number_id
|
||||
if not line.part_number_id:
|
||||
errors.add_error(
|
||||
field="part_number_id",
|
||||
message="El número de parte es obligatorio",
|
||||
solution=None,
|
||||
message="Part Number ID es obligatorio",
|
||||
solution="Selecciona un número de parte válido del catálogo",
|
||||
code="REQUIRED"
|
||||
)
|
||||
|
||||
# CAMPO OBLIGATORIO: Class (Clasificación arancelaria)
|
||||
if not item_data.class_id:
|
||||
# 3. Validar class_id
|
||||
if not line.class_id:
|
||||
errors.add_error(
|
||||
field="class_id",
|
||||
message="La clasificación arancelaria (fracción) es obligatoria",
|
||||
solution=None,
|
||||
message="Clase (ID) es obligatorio",
|
||||
solution="Selecciona una clasificación válida del catálogo",
|
||||
code="REQUIRED"
|
||||
)
|
||||
|
||||
# CAMPO OBLIGATORIO: Unit of measure (Unidad de medida)
|
||||
if not item_data.unit_of_measure:
|
||||
# 4. Validar unit_of_measure
|
||||
if not line.unit_of_measure:
|
||||
errors.add_error(
|
||||
field="unit_of_measure",
|
||||
message="La unidad de medida es obligatoria",
|
||||
solution=None,
|
||||
message="U.M. es obligatorio",
|
||||
solution="Proporciona una unidad de medida válida",
|
||||
code="REQUIRED"
|
||||
)
|
||||
|
||||
# ====================================================================
|
||||
# VALIDACIONES DE DATOS ANIDADOS OBLIGATORIOS
|
||||
# ====================================================================
|
||||
|
||||
# FINANCIAL: Debe tener datos financieros con al menos un costo unitario
|
||||
if not item_data.financial:
|
||||
# 5. Validar quantity.quantity
|
||||
if not line.quantity:
|
||||
errors.add_error(
|
||||
field="financial",
|
||||
message="Los datos financieros son obligatorios",
|
||||
solution=None,
|
||||
field="quantity",
|
||||
message="Quantity es obligatorio",
|
||||
solution="Proporciona una cantidad válida",
|
||||
code="REQUIRED"
|
||||
)
|
||||
else:
|
||||
# Validar con nombre amigable
|
||||
if line.quantity.quantity is None or line.quantity.quantity <= 0:
|
||||
errors.add_error(
|
||||
field="quantity.quantity",
|
||||
message="Quantity debe ser mayor a cero",
|
||||
solution="Proporciona una cantidad válida",
|
||||
code="INVALID_VALUE" if line.quantity.quantity is not None else "REQUIRED"
|
||||
)
|
||||
|
||||
# 6. Validar financial.unit_cost
|
||||
if not line.financial:
|
||||
errors.add_error(
|
||||
field="financial",
|
||||
message="Unit Cost es obligatorio",
|
||||
solution="Proporciona el costo unitario del item",
|
||||
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
|
||||
line.financial.unit_cost_usd or
|
||||
line.financial.unit_cost_mxn or
|
||||
line.financial.unit_cost_capture
|
||||
)
|
||||
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,
|
||||
message="Unit Cost es obligatorio",
|
||||
solution="Proporciona al menos un costo unitario (USD, MXN o captura)",
|
||||
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() == "":
|
||||
# 7. Validar description.description_spanish
|
||||
if line.description:
|
||||
if not line.description.description_spanish or not line.description.description_spanish.strip():
|
||||
errors.add_error(
|
||||
field="description.description_spanish",
|
||||
message="La descripción en español es obligatoria",
|
||||
solution=None,
|
||||
message="Description in Spanish es obligatorio",
|
||||
solution="Proporciona una descripción del item en español",
|
||||
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:
|
||||
else:
|
||||
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"
|
||||
field="description.description_spanish",
|
||||
message="Description in Spanish es obligatorio",
|
||||
solution="Proporciona una descripción del item en español",
|
||||
code="REQUIRED"
|
||||
)
|
||||
@@ -1,116 +1,231 @@
|
||||
from typing import Optional
|
||||
"""
|
||||
Validaciones para actualización de items vía API.
|
||||
"""
|
||||
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()
|
||||
from .common import validate_string_not_empty, validate_positive_value
|
||||
|
||||
|
||||
def validate_update(
|
||||
db: Session,
|
||||
item_data: LineItemUpdate,
|
||||
existing_item: LineItem,
|
||||
line: LineItemUpdate,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
errors: ErrorCollector,
|
||||
invoice_id: int = None
|
||||
) -> 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.
|
||||
Validaciones para actualizar LineItems vía API.
|
||||
Incluye todas las validaciones de negocio de Clarion.
|
||||
|
||||
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
|
||||
line: Datos del line item a actualizar
|
||||
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)
|
||||
invoice_id: ID de la factura asociada (opcional, para validar subpartidas)
|
||||
"""
|
||||
# 1. Validar line_number si se proporciona
|
||||
if line.line_number is not None and not line.line_number:
|
||||
errors.add_error(
|
||||
field="line_number",
|
||||
message="El número de línea no puede estar vacío",
|
||||
solution="Proporciona un número de línea válido",
|
||||
code="REQUIRED"
|
||||
)
|
||||
|
||||
# Primero ejecutar validaciones comunes
|
||||
# from .common import validate_common
|
||||
# validate_common(db, item_data, errors)
|
||||
# 2. Validar part_number_id si se proporciona
|
||||
if line.part_number_id is not None and not line.part_number_id:
|
||||
errors.add_error(
|
||||
field="part_number_id",
|
||||
message="Part Number ID no puede estar vacío",
|
||||
solution="Selecciona un número de parte válido del catálogo",
|
||||
code="REQUIRED"
|
||||
)
|
||||
|
||||
# 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
|
||||
# 3. Validar class_id si se proporciona
|
||||
if line.class_id is not None and not line.class_id:
|
||||
errors.add_error(
|
||||
field="class_id",
|
||||
message="Clase (ID) no puede estar vacío",
|
||||
solution="Selecciona una clasificación válida del catálogo",
|
||||
code="REQUIRED"
|
||||
)
|
||||
|
||||
# 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
|
||||
# 4. Validar unit_of_measure si se proporciona
|
||||
if line.unit_of_measure is not None and not line.unit_of_measure:
|
||||
errors.add_error(
|
||||
field="unit_of_measure",
|
||||
message="U.M. no puede estar vacío",
|
||||
solution="Proporciona una unidad de medida válida",
|
||||
code="REQUIRED"
|
||||
)
|
||||
|
||||
# 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:
|
||||
# 5. Validar cantidad si se proporciona
|
||||
if line.quantity:
|
||||
# Si se proporciona el objeto quantity, validar que quantity.quantity sea válido
|
||||
if line.quantity.quantity is not None:
|
||||
if line.quantity.quantity <= 0:
|
||||
errors.add_error(
|
||||
field="certificate_number",
|
||||
message="Si tiene certificado, debe proporcionar el número de certificado",
|
||||
code="REQUIRED_FIELD"
|
||||
field="quantity.quantity",
|
||||
message="Quantity debe ser mayor a cero",
|
||||
solution="Proporciona una cantidad válida",
|
||||
code="INVALID_VALUE"
|
||||
)
|
||||
else:
|
||||
item_data.has_certificate = existing_item.has_certificate
|
||||
else:
|
||||
# Si se proporciona quantity pero quantity.quantity es None, es requerido
|
||||
errors.add_error(
|
||||
field="quantity.quantity",
|
||||
message="Quantity es obligatorio",
|
||||
solution="Proporciona una cantidad mayor a 0",
|
||||
code="REQUIRED"
|
||||
)
|
||||
|
||||
# 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:
|
||||
# 6. Validar peso neto si se proporciona
|
||||
if line.quantity and line.quantity.net_weight is not None:
|
||||
if line.quantity.net_weight <= 0:
|
||||
errors.add_error(
|
||||
field="quantity.net_weight",
|
||||
message="Net Weight debe ser mayor a cero",
|
||||
solution="Proporciona un peso neto válido",
|
||||
code="INVALID_VALUE"
|
||||
)
|
||||
|
||||
# 7. Validar costo unitario si se proporciona financial (excepto subpartidas)
|
||||
if line.financial:
|
||||
is_subitem = line.fa_data and line.fa_data.is_subitem if line.fa_data else False
|
||||
|
||||
if not is_subitem:
|
||||
has_cost = (
|
||||
line.financial.unit_cost_usd or
|
||||
line.financial.unit_cost_mxn or
|
||||
line.financial.unit_cost_capture
|
||||
)
|
||||
if not has_cost:
|
||||
errors.add_error(
|
||||
field="fda_key",
|
||||
message="Si tiene código FDA, debe proporcionar la clave FDA",
|
||||
code="REQUIRED_FIELD"
|
||||
field="financial.unit_cost",
|
||||
message="Unit Cost es obligatorio",
|
||||
solution="Proporciona al menos un costo unitario (USD, MXN o captura)",
|
||||
code="REQUIRED"
|
||||
)
|
||||
# Validar que sean positivos
|
||||
if line.financial.unit_cost_usd is not None:
|
||||
if line.financial.unit_cost_usd <= 0:
|
||||
errors.add_error(
|
||||
field="financial.unit_cost_usd",
|
||||
message="Unit Cost (USD) debe ser mayor a cero",
|
||||
solution="Proporciona un costo unitario válido",
|
||||
code="INVALID_VALUE"
|
||||
)
|
||||
if line.financial.unit_cost_mxn is not None:
|
||||
if line.financial.unit_cost_mxn <= 0:
|
||||
errors.add_error(
|
||||
field="financial.unit_cost_mxn",
|
||||
message="Unit Cost (MXN) debe ser mayor a cero",
|
||||
solution="Proporciona un costo unitario válido",
|
||||
code="INVALID_VALUE"
|
||||
)
|
||||
if line.financial.unit_cost_capture is not None:
|
||||
if line.financial.unit_cost_capture <= 0:
|
||||
errors.add_error(
|
||||
field="financial.unit_cost_capture",
|
||||
message="Unit Cost (Captura) debe ser mayor a cero",
|
||||
solution="Proporciona un costo unitario válido",
|
||||
code="INVALID_VALUE"
|
||||
)
|
||||
|
||||
# 8. Validar datos aduanales si se proporcionan
|
||||
if line.customs:
|
||||
# Validar país de origen
|
||||
if line.customs.origin_country is not None and not line.customs.origin_country:
|
||||
errors.add_error(
|
||||
field="customs.origin_country",
|
||||
message="Origin Country no puede estar vacío",
|
||||
solution="Selecciona el país de origen del item",
|
||||
code="REQUIRED"
|
||||
)
|
||||
|
||||
# Validar preferencia arancelaria
|
||||
if line.customs.preference is not None and not line.customs.preference:
|
||||
errors.add_error(
|
||||
field="customs.preference",
|
||||
message="La preferencia arancelaria no puede estar vacía",
|
||||
solution="Selecciona la preferencia arancelaria",
|
||||
code="REQUIRED"
|
||||
)
|
||||
|
||||
# Validar formato de pago de impuestos
|
||||
if line.customs.tax_paid:
|
||||
val_tax = line.customs.tax_paid.upper()
|
||||
if val_tax not in ['SI', 'NO', 'S', 'N']:
|
||||
errors.add_error(
|
||||
field="customs.tax_paid",
|
||||
message="El valor de pago de impuesto debe ser SI/NO o S/N",
|
||||
solution="Proporciona un valor válido: SI, NO, S o N",
|
||||
code="INVALID_VALUE"
|
||||
)
|
||||
|
||||
# Validar forma de pago si existe
|
||||
if line.customs.payment_form:
|
||||
from api.v1.modules.a76.general_catalogs.forms_of_payment.models import PaymentForm
|
||||
|
||||
payment = db.query(PaymentForm).filter(
|
||||
PaymentForm.code == line.customs.payment_form,
|
||||
PaymentForm.tenant_id == tenant_id
|
||||
).first()
|
||||
|
||||
if not payment:
|
||||
errors.add_error(
|
||||
field="customs.payment_form",
|
||||
message=f"La forma de pago '{line.customs.payment_form}' no es válida",
|
||||
solution="Selecciona una forma de pago válida del catálogo",
|
||||
code="INVALID_VALUE"
|
||||
)
|
||||
|
||||
# 9. Validar descripción en español si se proporciona
|
||||
if line.description and hasattr(line.description, 'description_spanish'):
|
||||
if line.description.description_spanish is not None and not line.description.description_spanish:
|
||||
errors.add_error(
|
||||
field="description.description_spanish",
|
||||
message="La descripción en español no puede estar vacía",
|
||||
solution="Proporciona una descripción del item en español",
|
||||
code="REQUIRED"
|
||||
)
|
||||
|
||||
# 10. Validar subpartidas si se actualizan
|
||||
if line.fa_data and line.fa_data.is_subitem:
|
||||
# Es subpartida, debe tener partida principal
|
||||
if not line.fa_data.main_line_id:
|
||||
errors.add_error(
|
||||
field="fa_data.main_line_id",
|
||||
message="La subpartida debe tener asignada una partida principal",
|
||||
solution="Selecciona la partida principal de esta subpartida",
|
||||
code="REQUIRED"
|
||||
)
|
||||
elif invoice_id:
|
||||
# Validar que la partida principal exista en la misma factura
|
||||
from api.v1.modules.a76.items.line_items.models import LineItem
|
||||
from api.v1.modules.a76.items.models import Item
|
||||
|
||||
parent = db.query(LineItem).join(LineItem.item).filter(
|
||||
LineItem.line_number == line.fa_data.main_line_id,
|
||||
Item.invoice_id == invoice_id,
|
||||
LineItem.company_id == company_id
|
||||
).first()
|
||||
|
||||
if not parent:
|
||||
errors.add_error(
|
||||
field="fa_data.main_line_id",
|
||||
message=f"La partida principal {line.fa_data.main_line_id} no existe en esta factura",
|
||||
solution="Verifica el número de la partida principal",
|
||||
code="NOT_FOUND"
|
||||
)
|
||||
elif parent.fa_data and parent.fa_data.is_subitem:
|
||||
errors.add_error(
|
||||
field="fa_data.main_line_id",
|
||||
message="La partida principal no puede ser otra subpartida",
|
||||
solution="Selecciona una partida normal como principal",
|
||||
code="INVALID_VALUE"
|
||||
)
|
||||
else:
|
||||
item_data.has_fda_code = existing_item.has_fda_code
|
||||
@@ -163,6 +163,7 @@ class ItemService:
|
||||
errors = ErrorCollector()
|
||||
|
||||
# Validar que la factura exista y no esté actualizada (si viene invoice_id)
|
||||
invoice = None
|
||||
if item_data.invoice_id:
|
||||
invoice = db.query(InvoiceHeader).filter(
|
||||
InvoiceHeader.id == item_data.invoice_id,
|
||||
@@ -177,19 +178,6 @@ class ItemService:
|
||||
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:
|
||||
@@ -364,6 +352,29 @@ class ItemService:
|
||||
# Validaciones con ErrorCollector
|
||||
errors = ErrorCollector()
|
||||
|
||||
# Si se está actualizando el invoice_id, validar la factura
|
||||
invoice = None
|
||||
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:
|
||||
# Si no se está actualizando invoice_id, obtener la factura actual por invoice_id
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
invoice = db.query(InvoiceHeader).filter(
|
||||
InvoiceHeader.id == db_item.invoice_id
|
||||
).first()
|
||||
|
||||
# Validar cada line item que se va a actualizar
|
||||
if item_data.lines:
|
||||
for idx, line_data in enumerate(item_data.lines):
|
||||
@@ -377,7 +388,7 @@ class ItemService:
|
||||
if existing_line:
|
||||
# Convertir a LineItemUpdate para validar
|
||||
line_update = LineItemUpdate(**line_data.model_dump())
|
||||
validate_update(db, line_update, existing_line, errors)
|
||||
validate_update(db, line_update, tenant_id, company_id, errors)
|
||||
else:
|
||||
# Es un nuevo line item, validar como creación
|
||||
line_create = LineItemCreate(**line_data.model_dump())
|
||||
|
||||
@@ -72,15 +72,15 @@
|
||||
<!-- Descriptions -->
|
||||
<fieldset class="border rounded-md p-2 space-y-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="num_parte" class="text-xs">Part Number ID (opcional):</Label>
|
||||
<Label for="num_parte" class="text-xs">Part Number ID: <span class="text-red-500">*</span></Label>
|
||||
<div class="flex gap-1">
|
||||
<Input id="num_parte" type="number" bind:value={lineItem.part_number_id} class="h-7 text-xs" placeholder="Dejar vacío si no aplica" />
|
||||
<Input id="num_parte" type="number" bind:value={lineItem.part_number_id} class="h-7 text-xs" placeholder="ID de número de parte" />
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">ID de número de parte existente en catálogo</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<Label for="desc_espanol" class="text-xs">Description in Spanish:</Label>
|
||||
<Label for="desc_espanol" class="text-xs">Description in Spanish: <span class="text-red-500">*</span></Label>
|
||||
<textarea
|
||||
id="desc_espanol"
|
||||
bind:value={descriptions.description_spanish}
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Class - Full Width -->
|
||||
<div class="col-span-2 space-y-1">
|
||||
<Label for="clase" class="text-xs font-medium">* Clase (ID):</Label>
|
||||
<Label for="clase" class="text-xs font-medium">Clase (ID): <span class="text-red-500">*</span></Label>
|
||||
<div class="flex gap-1">
|
||||
<Input
|
||||
id="clase"
|
||||
@@ -88,12 +88,12 @@
|
||||
|
||||
<!-- Quantity and U.M. on the same row -->
|
||||
<div class="space-y-1">
|
||||
<Label for="cantidad" class="text-xs font-medium">* Quantity:</Label>
|
||||
<Label for="cantidad" class="text-xs font-medium">Quantity: <span class="text-red-500">*</span></Label>
|
||||
<Input id="cantidad" type="number" step="0.00000001" min="0" bind:value={quantities.quantity} class="h-8 text-xs text-right" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs font-medium">U.M.:</Label>
|
||||
<Label class="text-xs font-medium">U.M.: <span class="text-red-500">*</span></Label>
|
||||
<div class="flex gap-1">
|
||||
<Input id="um" type="number" bind:value={lineItem.unit_of_measure} class="h-8 text-xs flex-1" placeholder="ID de U.M." />
|
||||
<Button
|
||||
@@ -109,7 +109,7 @@
|
||||
|
||||
<!-- Unit Cost and Fraction -->
|
||||
<div class="space-y-1">
|
||||
<Label for="costo_unitario" class="text-xs font-medium">* Unit Cost:</Label>
|
||||
<Label for="costo_unitario" class="text-xs font-medium">Unit Cost: <span class="text-red-500">*</span></Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input id="costo_unitario" type="number" step="0.00000001" min="0" bind:value={financials.unit_cost_usd} class="h-8 text-xs text-right flex-1" />
|
||||
<span class="text-xs text-zinc-900 dark:text-zinc-100 font-semibold">USD</span>
|
||||
@@ -133,7 +133,7 @@
|
||||
|
||||
<!-- Origin Country and Tariff Type -->
|
||||
<div class="space-y-1">
|
||||
<Label for="pais_origen" class="text-xs font-medium">* Origin Country:</Label>
|
||||
<Label for="pais_origen" class="text-xs font-medium">Origin Country: <span class="text-red-500">*</span></Label>
|
||||
<div class="flex gap-1">
|
||||
<Input id="pais_origen" bind:value={customs.origin_country} class="h-8 text-xs flex-1" />
|
||||
<Button
|
||||
@@ -149,7 +149,7 @@
|
||||
|
||||
<div class="flex items-end gap-6">
|
||||
<div class="space-y-1">
|
||||
<Label for="tipo_tarifa" class="text-xs font-medium">* Tariff Type:</Label>
|
||||
<Label for="tipo_tarifa" class="text-xs font-medium">Tariff Type: <span class="text-red-500">*</span></Label>
|
||||
<select id="tipo_tarifa" bind:value={customs.fraction_type} class="flex h-8 w-auto min-w-[140px] rounded-md border border-input bg-background px-2 py-1 text-xs ring-offset-background">
|
||||
<option value="GENERAL">GENERAL</option>
|
||||
<option value="PROSEC">PROSEC</option>
|
||||
|
||||
@@ -524,7 +524,7 @@
|
||||
<Table.Cell>{item.quantity?.quantity || '0'}</Table.Cell>
|
||||
<Table.Cell>{item.unit_of_measure_code || '-'}</Table.Cell>
|
||||
<Table.Cell>{item.reference_number || '-'}</Table.Cell>
|
||||
<Table.Cell>{item.contains_subitems || '-'}</Table.Cell>
|
||||
<Table.Cell>{item.fa_data?.contains_subitems ? 'Sí' : 'No'}</Table.Cell>
|
||||
<Table.Cell>{item.warehouse || '-'}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
|
||||
Reference in New Issue
Block a user