validaciones flatantes y pruebas

This commit is contained in:
2026-04-14 10:40:15 -05:00
parent 73e9fef329
commit 5167d8bc9e
24 changed files with 922 additions and 112 deletions

View File

@@ -6,14 +6,44 @@ from ...series.models import Serie
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.app_settings.service import AppSettingsService
from decimal import Decimal
def apply_calculations(
db: Session, line: LineItem, tenant_id: int, company_id: int, line_number: int
):
#TODO: SSisGen Logic
# if ssisgen.calcularcostounitarioenbaseavalortotalscaf = 1:
# unit_cost_capture = line.financial.total_value / line.financial.total_value <-- habria que revisar por que esta asi, por que para mi no tiene sentido, pero es lo que esta en clarion
# 0. Obtener parámetros de configuración para validaciones dinámicas
settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id))
def find_in_obj(obj, target_key):
if isinstance(obj, dict):
for k, v in obj.items():
if k.lower() == target_key.lower():
return v
res = find_in_obj(v, target_key)
if res is not None: return res
elif hasattr(obj, '__dict__'):
dict_rep = obj.__dict__
for k, v in dict_rep.items():
if k.startswith('_'): continue
if k.lower() == target_key.lower():
return v
res = find_in_obj(v, target_key)
if res is not None: return res
return None
# Parámetro SCAF: Calcular costo unitario en base a valor total
calc_costo_unit = find_in_obj(settings, 'calcularcostounitarioenbaseavalortotal') or \
find_in_obj(settings, 'calcularcostounitarioenbaseavalortotalscaf')
if str(calc_costo_unit) == "1" and line.financial and (line.financial.unit_cost_capture or 0) == 0:
qty = line.quantity.quantity or Decimal("0")
if qty > 0:
total_val = line.financial.value_usd or line.financial.value_mxn or Decimal("0")
if total_val > 0:
line.financial.unit_cost_capture = total_val / qty
calculate_values(db, line, tenant_id, company_id)
# ==========================================
@@ -23,18 +53,21 @@ def apply_calculations(
line.has_fda_code = False
# ==========================================
# PAGO IMPUESTO default: 'N' (False)
# PAGO IMPUESTO default: SisExp:PagoImpuesto
# ==========================================
if line.tax_payment is None:
# TODO: Leer de SisExp:PagoImpuesto (preferencias del sistema)
line.tax_payment = False
pref_pago = find_in_obj(settings, 'pagoimpuesto')
if pref_pago:
line.tax_payment = True if str(pref_pago).lower() == 'si' else False
else:
line.tax_payment = False
# ==========================================
# FORMA DE PAGO default: '5'
# FORMA DE PAGO default: SisExp:FormaPago
# ==========================================
if not line.payment_method:
# TODO: Leer de SisExp:FormaPago (preferencias del sistema)
line.payment_method = "5"
pref_forma = find_in_obj(settings, 'formapago')
line.payment_method = pref_forma or "5"
# ==========================================
# SUBPARTIDAS: EsSubPartida / ContieneSubP / IncuyeSubPartidas

View File

@@ -21,6 +21,7 @@ from api.v1.modules.public.reference_data.valuation_methods.models import (
ValuationMethod,
)
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.app_settings.service import AppSettingsService
from api.v1.modules.a76.general_catalogs.company.models import Company
@@ -385,15 +386,70 @@ def validate_common(
class_.unit_of_measure if class_ else None
)
#TODO: SSisGen Logic Restringer cantidades decimales para piezas, revisar si es necesario agregar validación similar para otras unidades de medida
#TODO: SSisGen Logic Seguridad
# 0. Obtener parámetros de configuración para validaciones dinámicas
settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id))
if unit_of_measure == "PZA" and line.quantity.quantity % 1 != 0:
def find_in_obj(obj, target_key):
if isinstance(obj, dict):
for k, v in obj.items():
if k.lower() == target_key.lower():
return v
res = find_in_obj(v, target_key)
if res is not None: return res
elif hasattr(obj, '__dict__'):
dict_rep = obj.__dict__
for k, v in dict_rep.items():
if k.startswith('_'): continue
if k.lower() == target_key.lower():
return v
res = find_in_obj(v, target_key)
if res is not None: return res
return None
# Resolver el CÓDIGO de la unidad de medida para validación (Blindaje con STRIP y UPPER)
uom_code = ""
if line.unit_of_measure:
# Aseguramos que el ID sea entero
try:
target_uom_id = int(line.unit_of_measure)
except:
target_uom_id = line.unit_of_measure
uom_rec = db.query(UnitOfMeasure).filter(
UnitOfMeasure.id == target_uom_id,
UnitOfMeasure.tenant_id == tenant_id,
UnitOfMeasure.company_id == company_id
).first()
if uom_rec and uom_rec.code:
uom_code = str(uom_rec.code).strip().upper()
elif class_ and class_.unit_of_measure:
uom_code = str(class_.unit_of_measure).strip().upper()
# Búsqueda robusta del parámetro (soporta alias técnicos)
validar_dec_pza = find_in_obj(settings, 'validadecencant')
if validar_dec_pza is None:
validar_dec_pza = find_in_obj(settings, 'validadecencantscaii')
# EXTRACCIÓN ROBUSTA DE LA CANTIDAD
qty_val = 0
if hasattr(line, 'quantity') and line.quantity:
qty_val = getattr(line.quantity, 'quantity', 0) or 0
# Validación: Comparar contra PZA y variantes comunes
es_pieza = uom_code in ("PZA", "PZ", "PIEZA", "PIE", "Pzas", "Pza")
config_activa = str(validar_dec_pza).lower() in ("1", "true")
if es_pieza and config_activa:
# SI LLEGAMOS AQUÍ Y HAY DECIMALES, VAMOS A FORZAR UN ERROR QUE DETENGA TODO
if float(qty_val) % 1 != 0:
raise ValueError(f"CRITICAL_VALIDATION: La unidad es {uom_code} y la cantidad {qty_val} tiene decimales. El proceso DEBE detenerse.")
if es_pieza and config_activa and float(qty_val) % 1 != 0:
errors.add_error(
field=f"line[{line_number}].quantity.quantity",
message="La cantidad debe ser un número entero cuando la unidad de medida es PZA.",
solution=["Proporciona una cantidad entera."],
code="QUANTITY_MUST_BE_INTEGER_FOR_PIECES",
message=f"La cantidad ({qty_val}) no puede tener decimales cuando la unidad es PZA.",
solution=["Captura una cantidad entera."],
code="QUANTITY_INTEGER_REQUIRED",
)
if line.valuation_method:

View File

@@ -14,6 +14,7 @@ from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models im
USTariffFraction,
)
from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod
from api.v1.modules.a76.app_settings.service import AppSettingsService
from .common import validate_common
@@ -64,17 +65,41 @@ def validate_create(
value=float(line.quantity.quantity)
)
# TODO: Añadir validacion SSisGen:CalcularCostoUnitarioEnBaseAValorTotalScaf <-- de la tabla de preferencias de el sistema
# if SSisGen:CalcularCostoUnitarioEnBaseAValorTotalScaf == False:
# 0. Obtener parámetros de configuración para validaciones dinámicas
settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id))
def find_in_obj(obj, target_key):
if isinstance(obj, dict):
for k, v in obj.items():
if k.lower() == target_key.lower():
return v
res = find_in_obj(v, target_key)
if res is not None: return res
elif hasattr(obj, '__dict__'):
dict_rep = obj.__dict__
for k, v in dict_rep.items():
if k.startswith('_'): continue
if k.lower() == target_key.lower():
return v
res = find_in_obj(v, target_key)
if res is not None: return res
return None
# Parámetro SCAF: Calcular costo unitario en base a valor total
calc_costo_unit = find_in_obj(settings, 'calcularcostounitarioenbaseavalortotal') or \
find_in_obj(settings, 'calcularcostounitarioenbaseavalortotalscaf')
if fa_data and not fa_data.is_subitem:
if (
not line.financial
or not line.financial.unit_cost_capture
or line.financial.unit_cost_capture <= 0
):
errors.add_required_error(
field=f"line[{line_number}].financial.unit_cost_capture"
)
# Si el parámetro está apagado (o no existe), el costo unitario es obligatorio
if str(calc_costo_unit) != "1":
if (
not line.financial
or not line.financial.unit_cost_capture
or line.financial.unit_cost_capture <= 0
):
errors.add_required_error(
field=f"line[{line_number}].financial.unit_cost_capture"
)
if not line.quantity or line.quantity.net_weight is None:
errors.add_required_error(field=f"line[{line_number}].quantity.net_weight")
@@ -412,20 +437,10 @@ def validate_create(
line.description.model = line.description.model.upper().strip()
# ==========================================
# VALIDAR Y ASIGNAR PAGO DE IMPUESTO
# ==========================================
# Col. L: Se Pagó Impuesto — opcional, defaults a preferencia del sistema
if line.tax_payment is not None:
# Ya viene como bool desde Pydantic; valor válido por definición de tipo
pass
else:
# TODO: Asignar desde SisExp:PagoImpuesto (preferencias del sistema)
pass
# CUMPLIMIENTO MEXICANO (Valores por defecto gestionados en apply_calculations)
# ==========================================
# VALIDAR Y ASIGNAR FORMA DE PAGO
# ==========================================
# Col. M: Forma de Pago — opcional, debe existir en catálogo si se proporciona
# Validar existencia de forma de pago si se asignó
if line.payment_method:
payment_method_exists = (
db.query(PaymentMethod)
@@ -433,19 +448,5 @@ def validate_create(
.first()
)
if not payment_method_exists:
errors.add_error(
field=f"line[{line_number}].payment_method",
message=f"La Forma de Pago '{line.payment_method}' no es válida.",
solution=[
"Capturar una Forma de Pago dentro del Catálogo General de Formas de Pago."
],
code="PAYMENT_METHOD_INVALID",
)
else:
# TODO: Asignar desde SisExp:FormaPago (preferencias del sistema)
pass
# ==========================================
# ASIGNAR MÉTODO DE VALORACIÓN POR DEFECTO
# ==========================================
# TODO: Si no se especificó método de valoración, tomar de preferencias del sistema (SisImp:MetValor)
# Si falló la validación porque el parámetro de sistema no está en el catálogo, lanzamos advertencia
pass

View File

@@ -4,15 +4,60 @@ from core.exceptions import ErrorCollector
from ...models import LineItem
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.app_settings.service import AppSettingsService
from decimal import Decimal
def apply_calculations(
db: Session, line: LineItem, tenant_id: int, company_id: int, line_number: int
):
#TODO: SSisGen Logic
# if ssisgen.calcularcostounitarioenbaseavalortotalscaf = 1:
# unit_cost_capture = line.financial.total_value / line.financial.total_value <-- habria que revisar por que esta asi, por que para mi no tiene sentido, pero es lo que esta en clarion
# 0. Obtener parámetros de configuración para validaciones dinámicas
settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id))
def find_in_obj(obj, target_key):
if isinstance(obj, dict):
for k, v in obj.items():
if k.lower() == target_key.lower():
return v
res = find_in_obj(v, target_key)
if res is not None: return res
elif hasattr(obj, '__dict__'):
dict_rep = obj.__dict__
for k, v in dict_rep.items():
if k.startswith('_'): continue
if k.lower() == target_key.lower():
return v
res = find_in_obj(v, target_key)
if res is not None: return res
return None
# Parámetro SCAF: Calcular costo unitario en base a valor total
calc_costo_unit = find_in_obj(settings, 'calcularcostounitarioenbaseavalortotal') or \
find_in_obj(settings, 'calcularcostounitarioenbaseavalortotalscaf')
if str(calc_costo_unit) == "1" and line.financial and (line.financial.unit_cost_capture or 0) == 0:
qty = line.quantity.quantity or Decimal("0")
if qty > 0:
total_val = line.financial.value_usd or line.financial.value_mxn or Decimal("0")
if total_val > 0:
line.financial.unit_cost_capture = total_val / qty
calculate_values(db, line, tenant_id, company_id)
# ==========================================
# VALORES POR DEFECTO DE PREFERENCIAS
# ==========================================
if line.tax_payment is None:
pref_pago = find_in_obj(settings, 'pagoimpuesto')
if pref_pago:
line.tax_payment = True if str(pref_pago).lower() == 'si' else False
if not line.payment_method:
line.payment_method = find_in_obj(settings, 'formapago')
if not line.valuation_method:
line.valuation_method = find_in_obj(settings, 'metvalor')
apply_calculations_after_values(db, line, tenant_id, company_id, line_number)

View File

@@ -23,6 +23,7 @@ from api.v1.modules.public.reference_data.valuation_methods.models import (
ValuationMethod,
)
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.app_settings.service import AppSettingsService
from api.v1.modules.a76.general_catalogs.company.models import Company
import re
@@ -373,15 +374,70 @@ def validate_common(
class_.unit_of_measure if class_ else None
)
#TODO: SSisGen Logic Restringer cantidades decimales para piezas, revisar si es necesario agregar validación similar para otras unidades de medida
#TODO: SSisGen Logic Seguridad
# 0. Obtener parámetros de configuración para validaciones dinámicas
settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id))
if unit_of_measure == "PZA" and line.quantity.quantity % 1 != 0:
def find_in_obj(obj, target_key):
if isinstance(obj, dict):
for k, v in obj.items():
if k.lower() == target_key.lower():
return v
res = find_in_obj(v, target_key)
if res is not None: return res
elif hasattr(obj, '__dict__'):
dict_rep = obj.__dict__
for k, v in dict_rep.items():
if k.startswith('_'): continue
if k.lower() == target_key.lower():
return v
res = find_in_obj(v, target_key)
if res is not None: return res
return None
# Resolver el CÓDIGO de la unidad de medida para validación (Blindaje con STRIP y UPPER)
uom_code = ""
if line.unit_of_measure:
# Aseguramos que el ID sea entero
try:
target_uom_id = int(line.unit_of_measure)
except:
target_uom_id = line.unit_of_measure
uom_rec = db.query(UnitOfMeasure).filter(
UnitOfMeasure.id == target_uom_id,
UnitOfMeasure.tenant_id == tenant_id,
UnitOfMeasure.company_id == company_id
).first()
if uom_rec and uom_rec.code:
uom_code = str(uom_rec.code).strip().upper()
elif class_ and class_.unit_of_measure:
uom_code = str(class_.unit_of_measure).strip().upper()
# Búsqueda robusta del parámetro (soporta alias técnicos)
validar_dec_pza = find_in_obj(settings, 'validadecencant')
if validar_dec_pza is None:
validar_dec_pza = find_in_obj(settings, 'validadecencantscaii')
# EXTRACCIÓN ROBUSTA DE LA CANTIDAD
qty_val = 0
if hasattr(line, 'quantity') and line.quantity:
qty_val = getattr(line.quantity, 'quantity', 0) or 0
# Validación: Comparar contra PZA y variantes comunes
es_pieza = uom_code in ("PZA", "PZ", "PIEZA", "PIE", "Pzas", "Pza")
config_activa = str(validar_dec_pza).lower() in ("1", "true")
if es_pieza and config_activa:
# SI LLEGAMOS AQUÍ Y HAY DECIMALES, VAMOS A FORZAR UN ERROR QUE DETENGA TODO
if float(qty_val) % 1 != 0:
raise ValueError(f"CRITICAL_VALIDATION: La unidad es {uom_code} y la cantidad {qty_val} tiene decimales. El proceso DEBE detenerse.")
if es_pieza and config_activa and float(qty_val) % 1 != 0:
errors.add_error(
field=f"line[{line_number}].quantity.quantity",
message="La cantidad debe ser un número entero cuando la unidad de medida es PZA.",
solution=["Proporciona una cantidad entera."],
code="QUANTITY_MUST_BE_INTEGER_FOR_PIECES",
message=f"La cantidad ({qty_val}) no puede tener decimales cuando la unidad es PZA.",
solution=["Captura una cantidad entera."],
code="QUANTITY_INTEGER_REQUIRED",
)
if line.valuation_method:

View File

@@ -13,6 +13,7 @@ from api.v1.modules.a76.general_catalogs.packages.models import Package
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
USTariffFraction,
)
from api.v1.modules.a76.app_settings.service import AppSettingsService
from .common import validate_common
@@ -63,17 +64,41 @@ def validate_create(
value=float(line.quantity.quantity)
)
# TODO: Añadir validacion SSisGen:CalcularCostoUnitarioEnBaseAValorTotalScaf <-- de la tabla de preferencias de el sistema
# if SSisGen:CalcularCostoUnitarioEnBaseAValorTotalScaf == False:
# 0. Obtener parámetros de configuración para validaciones dinámicas
settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id))
def find_in_obj(obj, target_key):
if isinstance(obj, dict):
for k, v in obj.items():
if k.lower() == target_key.lower():
return v
res = find_in_obj(v, target_key)
if res is not None: return res
elif hasattr(obj, '__dict__'):
dict_rep = obj.__dict__
for k, v in dict_rep.items():
if k.startswith('_'): continue
if k.lower() == target_key.lower():
return v
res = find_in_obj(v, target_key)
if res is not None: return res
return None
# Parámetro SCAF: Calcular costo unitario en base a valor total
calc_costo_unit = find_in_obj(settings, 'calcularcostounitarioenbaseavalortotal') or \
find_in_obj(settings, 'calcularcostounitarioenbaseavalortotalscaf')
if fa_data and not fa_data.is_subitem:
if (
not line.financial
or not line.financial.unit_cost_capture
or line.financial.unit_cost_capture <= 0
):
errors.add_required_error(
field=f"line[{line_number}].financial.unit_cost_capture"
)
# Si el parámetro está apagado (o no existe), el costo unitario es obligatorio
if str(calc_costo_unit) != "1":
if (
not line.financial
or not line.financial.unit_cost_capture
or line.financial.unit_cost_capture <= 0
):
errors.add_required_error(
field=f"line[{line_number}].financial.unit_cost_capture"
)
if not line.quantity or line.quantity.net_weight is None:
errors.add_required_error(field=f"line[{line_number}].quantity.net_weight")
@@ -396,14 +421,5 @@ def validate_create(
line.description.model = line.description.model.upper().strip()
# ==========================================
# ASIGNAR VALORES POR DEFECTO DE IMPUESTOS
# CUMPLIMIENTO MEXICANO (Valores por defecto gestionados en apply_calculations)
# ==========================================
# Si no se especificó pago de impuesto, tomar de preferencias del sistema (SisImp)
# TODO: Implementar lectura de preferencias del sistema
# Por ahora dejamos None si no se proporcionó
# Si no se especificó forma de pago, tomar de preferencias del sistema
# TODO: Implementar lectura de preferencias del sistema
# Si no se especificó método de valoración, tomar de preferencias del sistema
# TODO: Implementar lectura de preferencias del sistema

View File

@@ -435,20 +435,14 @@ class ItemService:
# Validaciones con ErrorCollector
errors = ErrorCollector()
# Validar que la factura exista y no esté actualizada (si viene invoice_id)
if not item_data.invoice_id:
errors.add_required_error(field="invoice_id")
errors.raise_if_errors("Error al crear el item - invoice_id es requerido")
invoice = invoice_exists_by_id(
db, item_data.invoice_id, tenant_id, company_id, None
)
if not invoice:
errors.add_error("invoice_id", "La factura no existe", code="NOT_FOUND", value=str(item_data.invoice_id))
errors.raise_if_errors("Error al encontra la factura para el item")
if not invoice_processed(db, item_data.invoice_id, tenant_id, company_id, errors):
errors.raise_if_errors("Error al crear el item - la factura ya fue actualizada, no se pueden agregar items")
# Validar que la factura exista y no esté actualizada (si viene invoice_id)
if not item_data.invoice_id:
errors.add_required_error(field="invoice_id")
errors.raise_if_errors("Error al crear el item - invoice_id es requerido")
# Lock invoice and calculate line number
if not ItemService._lock_invoice(