Merge branch 'feature/items-validation' into development
This commit is contained in:
@@ -12,7 +12,8 @@ class FaLineItemCreateDTO(BaseModel):
|
||||
"""DTO para crear una línea de activo fijo"""
|
||||
|
||||
# line_item_id references the id in a76.item_lines
|
||||
line_item_id: int = Field(..., description="ID de la línea base en a76.item_lines")
|
||||
# Not required on creation - will be set when the LineItem is created
|
||||
line_item_id: Optional[int] = Field(None, description="ID de la línea base en a76.item_lines")
|
||||
|
||||
# Asset information (SCAF specific)
|
||||
asset_number: Optional[str] = Field(
|
||||
|
||||
@@ -1,2 +1,231 @@
|
||||
def validate_update():
|
||||
pass
|
||||
from typing import Optional
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from core.exceptions import ErrorCollector
|
||||
from ....schemas import InvoiceHeaderUpdate
|
||||
from ....models import InvoiceHeader
|
||||
|
||||
|
||||
# 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(
|
||||
invoice_data: InvoiceHeaderUpdate,
|
||||
existing_invoice: InvoiceHeader,
|
||||
errors: ErrorCollector,
|
||||
) -> None:
|
||||
"""
|
||||
Valida y procesa la actualización parcial de una factura de importación temporal.
|
||||
|
||||
Lógica: Si un campo viene con valor, se limpia/valida.
|
||||
Si no, se mantiene el valor existente de la factura.
|
||||
|
||||
Args:
|
||||
invoice_data: Datos de la factura a validar/actualizar (modificado in-place)
|
||||
existing_invoice: Factura existente en la base de datos
|
||||
errors: Colector de errores
|
||||
|
||||
Returns:
|
||||
None (modifica invoice_data in-place y acumula errores en errors)
|
||||
"""
|
||||
|
||||
# Primero ejecutar validaciones comunes
|
||||
# validate_common(invoice_data, errors)
|
||||
|
||||
# Mapeo de columnas CSV a campos de la factura
|
||||
# Siguiendo la lógica del código Clarion original
|
||||
|
||||
# Columna A: Pedimento (si no viene en CSV, usar el existente)
|
||||
if invoice_data.compliance_mx.pedimento_id:
|
||||
invoice_data.compliance_mx.pedimento_id = clean_str(invoice_data.compliance_mx.pedimento_id)
|
||||
else:
|
||||
invoice_data.compliance_mx.pedimento_id = existing_invoice.compliance_mx.pedimento_id if existing_invoice.compliance_mx else None
|
||||
|
||||
# Columna B: Remesa
|
||||
if invoice_data.compliance_mx.remesa:
|
||||
invoice_data.compliance_mx.remesa = clean_str(invoice_data.compliance_mx.remesa)
|
||||
else:
|
||||
invoice_data.compliance_mx.remesa = existing_invoice.compliance_mx.remesa if existing_invoice.compliance_mx else None
|
||||
|
||||
# Columna C: Factura (OBLIGATORIO)
|
||||
invoice_data.invoice_number = clean_str(invoice_data.invoice_number)
|
||||
if not invoice_data.invoice_number:
|
||||
errors.add_required_error("invoice_number")
|
||||
|
||||
# Columna D: Fecha
|
||||
if invoice_data.invoice_date:
|
||||
invoice_data.invoice_date = invoice_data.invoice_date
|
||||
else:
|
||||
invoice_data.invoice_date = existing_invoice.invoice_date
|
||||
|
||||
# Columna E: Tipo Cambio
|
||||
if invoice_data.financials and invoice_data.financials.exchange_rate is not None:
|
||||
invoice_data.financials.exchange_rate = invoice_data.financials.exchange_rate
|
||||
else:
|
||||
if existing_invoice.financials:
|
||||
invoice_data.financials.exchange_rate = existing_invoice.financials.exchange_rate
|
||||
|
||||
# Columna F: Régimen
|
||||
if invoice_data.document_type:
|
||||
invoice_data.document_type = clean_str(invoice_data.document_type).upper()
|
||||
else:
|
||||
invoice_data.document_type = existing_invoice.document_type
|
||||
|
||||
# Columna G: Clave Proveedor
|
||||
if invoice_data.compliance_mx and invoice_data.compliance_mx.provider_id is not None:
|
||||
invoice_data.compliance_mx.provider_id = invoice_data.compliance_mx.provider_id
|
||||
else:
|
||||
invoice_data.compliance_mx.provider_id = existing_invoice.compliance_mx.provider_id if existing_invoice.compliance_mx else None
|
||||
|
||||
# Columna H: Clave Vendido A
|
||||
if invoice_data.compliance_mx and invoice_data.compliance_mx.sold_to_id is not None:
|
||||
invoice_data.compliance_mx.sold_to_id = invoice_data.compliance_mx.sold_to_id
|
||||
else:
|
||||
invoice_data.compliance_mx.sold_to_id = existing_invoice.compliance_mx.sold_to_id if existing_invoice.compliance_mx else None
|
||||
|
||||
# Columna I: Clave Enviado A
|
||||
if invoice_data.compliance_mx and invoice_data.compliance_mx.shipped_to_id is not None:
|
||||
invoice_data.compliance_mx.shipped_to_id = invoice_data.compliance_mx.shipped_to_id
|
||||
else:
|
||||
invoice_data.compliance_mx.shipped_to_id = existing_invoice.compliance_mx.shipped_to_id if existing_invoice.compliance_mx else None
|
||||
|
||||
# Columna J: Clave A. Aduanal
|
||||
if invoice_data.compliance_mx and invoice_data.compliance_mx.customs_broker_id is not None:
|
||||
invoice_data.compliance_mx.customs_broker_id = invoice_data.compliance_mx.customs_broker_id
|
||||
else:
|
||||
invoice_data.compliance_mx.customs_broker_id = existing_invoice.compliance_mx.customs_broker_id if existing_invoice.compliance_mx else None
|
||||
|
||||
# Columna K: Clave Transportista
|
||||
if invoice_data.logistics and invoice_data.logistics.carrier_id is not None:
|
||||
invoice_data.logistics.carrier_id = invoice_data.logistics.carrier_id
|
||||
else:
|
||||
invoice_data.logistics.carrier_id = existing_invoice.logistics.carrier_id if existing_invoice.logistics else None
|
||||
|
||||
# Columna L: Nombre Conductor
|
||||
if invoice_data.logistics and invoice_data.logistics.driver_name:
|
||||
invoice_data.logistics.driver_name = clean_str(invoice_data.logistics.driver_name)
|
||||
else:
|
||||
invoice_data.logistics.driver_name = existing_invoice.logistics.driver_name if existing_invoice.logistics else None
|
||||
|
||||
# Columna M: Tipo Transporte
|
||||
if invoice_data.logistics and invoice_data.logistics.transport_type:
|
||||
invoice_data.logistics.transport_type = clean_str(invoice_data.logistics.transport_type)
|
||||
else:
|
||||
invoice_data.logistics.transport_type = existing_invoice.logistics.transport_type if existing_invoice.logistics else None
|
||||
|
||||
# Columna N: Número de Transporte
|
||||
if invoice_data.logistics and invoice_data.logistics.transport_num:
|
||||
invoice_data.logistics.transport_num = clean_str(invoice_data.logistics.transport_num)
|
||||
else:
|
||||
invoice_data.logistics.transport_num = existing_invoice.logistics.transport_num if existing_invoice.logistics else None
|
||||
|
||||
# Columna O: Tipo de Moneda
|
||||
if invoice_data.financials and invoice_data.financials.currency:
|
||||
invoice_data.financials.currency = clean_str(invoice_data.financials.currency).lower()
|
||||
else:
|
||||
invoice_data.financials.currency = existing_invoice.financials.currency if existing_invoice.financials else None
|
||||
|
||||
# Columna P: Clave Moneda
|
||||
if invoice_data.financials and invoice_data.financials.currency_type:
|
||||
invoice_data.financials.currency_type = clean_str(invoice_data.financials.currency_type).upper()
|
||||
else:
|
||||
invoice_data.financials.currency_type = existing_invoice.financials.currency_type if existing_invoice.financials else None
|
||||
|
||||
# Columna Q: Flete
|
||||
if invoice_data.financials and invoice_data.financials.freight is not None:
|
||||
invoice_data.financials.freight = invoice_data.financials.freight
|
||||
else:
|
||||
invoice_data.financials.freight = existing_invoice.financials.freight if existing_invoice.financials else None
|
||||
|
||||
# Columna R: Val Seguros
|
||||
if invoice_data.financials and invoice_data.financials.insurance_value is not None:
|
||||
invoice_data.financials.insurance_value = invoice_data.financials.insurance_value
|
||||
else:
|
||||
invoice_data.financials.insurance_value = existing_invoice.financials.insurance_value if existing_invoice.financials else None
|
||||
|
||||
# Columna S: Seguros
|
||||
if invoice_data.financials and invoice_data.financials.insurance is not None:
|
||||
invoice_data.financials.insurance = invoice_data.financials.insurance
|
||||
else:
|
||||
invoice_data.financials.insurance = existing_invoice.financials.insurance if existing_invoice.financials else None
|
||||
|
||||
# Columna T: Embalaje
|
||||
if invoice_data.financials and invoice_data.financials.packaging is not None:
|
||||
invoice_data.financials.packaging = invoice_data.financials.packaging
|
||||
else:
|
||||
invoice_data.financials.packaging = existing_invoice.financials.packaging if existing_invoice.financials else None
|
||||
|
||||
# Columna U: Otros Incrementables
|
||||
if invoice_data.financials and invoice_data.financials.other_increments is not None:
|
||||
invoice_data.financials.other_increments = invoice_data.financials.other_increments
|
||||
else:
|
||||
invoice_data.financials.other_increments = existing_invoice.financials.other_increments if existing_invoice.financials else None
|
||||
|
||||
# Columna V: Incoterms
|
||||
if invoice_data.logistics and invoice_data.logistics.incoterm:
|
||||
invoice_data.logistics.incoterm = clean_str(invoice_data.logistics.incoterm).upper()
|
||||
else:
|
||||
invoice_data.logistics.incoterm = existing_invoice.logistics.incoterm if existing_invoice.logistics else None
|
||||
|
||||
# Columna W: Precinto
|
||||
if invoice_data.logistics and invoice_data.logistics.seal_number:
|
||||
invoice_data.logistics.seal_number = clean_str(invoice_data.logistics.seal_number)
|
||||
else:
|
||||
invoice_data.logistics.seal_number = existing_invoice.logistics.seal_number if existing_invoice.logistics else None
|
||||
|
||||
# Columna X: Fecha de Emisión
|
||||
if invoice_data.emission_date:
|
||||
invoice_data.emission_date = invoice_data.emission_date
|
||||
else:
|
||||
invoice_data.emission_date = existing_invoice.emission_date
|
||||
|
||||
# Columna Y: Tipo de Peso (Opcional)
|
||||
if invoice_data.logistics and invoice_data.logistics.weight_type:
|
||||
invoice_data.logistics.weight_type = clean_str(invoice_data.logistics.weight_type).upper()
|
||||
else:
|
||||
invoice_data.logistics.weight_type = existing_invoice.logistics.weight_type if existing_invoice.logistics else None
|
||||
|
||||
# Columna Z: E-Document (Opcional)
|
||||
if invoice_data.compliance_mx and invoice_data.compliance_mx.edocument:
|
||||
invoice_data.compliance_mx.edocument = clean_str(invoice_data.compliance_mx.edocument)
|
||||
else:
|
||||
invoice_data.compliance_mx.edocument = existing_invoice.compliance_mx.edocument if existing_invoice.compliance_mx else None
|
||||
|
||||
# Columna AA: Num. Operación (Opcional)
|
||||
if invoice_data.compliance_mx and invoice_data.compliance_mx.vucem_operation_num:
|
||||
invoice_data.compliance_mx.vucem_operation_num = clean_str(invoice_data.compliance_mx.vucem_operation_num)
|
||||
else:
|
||||
invoice_data.compliance_mx.vucem_operation_num = existing_invoice.compliance_mx.vucem_operation_num if existing_invoice.compliance_mx else None
|
||||
|
||||
# Columna AB: Aduana (OBLIGATORIO)
|
||||
if invoice_data.compliance_mx and invoice_data.compliance_mx.aduana:
|
||||
invoice_data.compliance_mx.aduana = clean_str(invoice_data.compliance_mx.aduana)
|
||||
else:
|
||||
invoice_data.compliance_mx.aduana = existing_invoice.compliance_mx.aduana if existing_invoice.compliance_mx else None
|
||||
|
||||
# Validar que aduana sea obligatorio
|
||||
if not invoice_data.compliance_mx or not invoice_data.compliance_mx.aduana:
|
||||
errors.add_required_error("aduana")
|
||||
|
||||
# Columna AC: Sección de Despacho / Puerto de Entrada (Opcional)
|
||||
if invoice_data.compliance_mx and invoice_data.compliance_mx.port_of_entry:
|
||||
invoice_data.compliance_mx.port_of_entry = clean_str(invoice_data.compliance_mx.port_of_entry)
|
||||
else:
|
||||
invoice_data.compliance_mx.port_of_entry = existing_invoice.compliance_mx.port_of_entry if existing_invoice.compliance_mx else None
|
||||
|
||||
# Columna AD: Observación en Español (Opcional)
|
||||
if invoice_data.observation_es:
|
||||
invoice_data.observation_es = clean_str(invoice_data.observation_es)
|
||||
else:
|
||||
invoice_data.observation_es = existing_invoice.observation_es
|
||||
|
||||
# Columna AD: Observación en Inglés (Opcional)
|
||||
if invoice_data.observation_en:
|
||||
invoice_data.observation_en = clean_str(invoice_data.observation_en)
|
||||
else:
|
||||
invoice_data.observation_en = existing_invoice.observation_en
|
||||
|
||||
|
||||
@@ -201,11 +201,40 @@ class InvoiceService:
|
||||
invoice_data: schemas.InvoiceHeaderUpdate,
|
||||
company_id: int,
|
||||
) -> Optional[models.InvoiceHeader]:
|
||||
# ... (El resto de tu código update se queda igual) ...
|
||||
# (Te recomiendo implementar clean_dict aquí también si tienes problemas al editar)
|
||||
"""Update an existing invoice with validation"""
|
||||
|
||||
# Validaciones con ErrorCollector
|
||||
errors = ErrorCollector()
|
||||
|
||||
# Obtener la factura existente
|
||||
invoice = InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id)
|
||||
if not invoice:
|
||||
return None
|
||||
|
||||
# Si se cambió el número de factura, validar que no exista otra con ese número
|
||||
if invoice_data.invoice_number and invoice_data.invoice_number != invoice.invoice_number:
|
||||
# Verificar que no exista otra factura con el nuevo número
|
||||
existing_invoice = (
|
||||
db.query(models.InvoiceHeader.id)
|
||||
.filter(
|
||||
models.InvoiceHeader.invoice_number == invoice_data.invoice_number,
|
||||
models.InvoiceHeader.tenant_id == tenant_id,
|
||||
models.InvoiceHeader.company_id == company_id,
|
||||
models.InvoiceHeader.id != invoice_id, # Excluir la factura actual
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing_invoice:
|
||||
errors.add_duplicate_error(
|
||||
"invoice_number",
|
||||
invoice_data.invoice_number,
|
||||
f"Ya existe otra factura con el número '{invoice_data.invoice_number}'",
|
||||
)
|
||||
validate_update(invoice_data, invoice, errors)
|
||||
|
||||
# Si hay errores, lanzar excepción ANTES de actualizar
|
||||
errors.raise_if_errors("Error al actualizar la factura")
|
||||
|
||||
# Update main invoice header fields
|
||||
update_dict = invoice_data.model_dump(
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -183,5 +183,5 @@ class LineItem(Base, TenantScopedMixin, TimestampMixin):
|
||||
viewonly=True
|
||||
)
|
||||
fa_data: Mapped[Optional["FaLineItem"]] = relationship(
|
||||
"FaLineItem", back_populates="master_info", uselist=False, cascade="all, delete"
|
||||
"FaLineItem", back_populates="master_info", cascade="all, delete-orphan", uselist=False
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -325,9 +325,30 @@
|
||||
order: editingItem.order,
|
||||
warehouse: editingItem.warehouse,
|
||||
location: editingItem.location,
|
||||
lines: cleanedLines
|
||||
lines: editingItem.lines || []
|
||||
});
|
||||
|
||||
// Verificar si hay errores de validación
|
||||
if ('error' in response) {
|
||||
// Manejar errores de validación (422)
|
||||
if (response.validationErrors && Array.isArray(response.validationErrors)) {
|
||||
const validationErrors = response.validationErrors
|
||||
.map((err: any) => `• ${err.message}`)
|
||||
.join('\n');
|
||||
|
||||
toast.error('Errores de validación', {
|
||||
description: validationErrors,
|
||||
duration: 10000
|
||||
});
|
||||
} else {
|
||||
toast.error('Error al crear item', {
|
||||
description: response.error || 'No se pudo crear el item. Intenta de nuevo.'
|
||||
});
|
||||
}
|
||||
isSaving = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Recargar items
|
||||
await loadItems();
|
||||
|
||||
@@ -337,10 +358,22 @@
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error creating item:', error);
|
||||
const errorMessage = error?.response?.data?.detail || 'No se pudo crear el item. Intenta de nuevo.';
|
||||
toast.error('Error al crear item', {
|
||||
description: errorMessage
|
||||
});
|
||||
|
||||
// Manejar errores de validación (422)
|
||||
if (error?.response?.data?.errors && Array.isArray(error.response.data.errors)) {
|
||||
const validationErrors = error.response.data.errors
|
||||
.map((err: any) => `• ${err.message}`)
|
||||
.join('\n');
|
||||
|
||||
toast.error('Errores de validación', {
|
||||
description: validationErrors
|
||||
});
|
||||
} else {
|
||||
const errorMessage = error?.response?.data?.detail || 'No se pudo crear el item. Intenta de nuevo.';
|
||||
toast.error('Error al crear item', {
|
||||
description: errorMessage
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
@@ -351,17 +384,35 @@
|
||||
|
||||
isSaving = true;
|
||||
try {
|
||||
// Clean lines data before sending
|
||||
const cleanedLines = (editingItem.lines || []).map(cleanLineData);
|
||||
|
||||
await itemsApi.update(selectedItem.id, activeCompanyId, {
|
||||
const response = await itemsApi.update(selectedItem.id, activeCompanyId, {
|
||||
reference_number: editingItem.reference_number,
|
||||
order: editingItem.order,
|
||||
warehouse: editingItem.warehouse,
|
||||
location: editingItem.location,
|
||||
lines: cleanedLines
|
||||
lines: editingItem.lines || []
|
||||
});
|
||||
|
||||
// Verificar si hay errores de validación
|
||||
if ('error' in response) {
|
||||
// Manejar errores de validación (422)
|
||||
if (response.validationErrors && Array.isArray(response.validationErrors)) {
|
||||
const validationErrors = response.validationErrors
|
||||
.map((err: any) => `• ${err.message}`)
|
||||
.join('\n');
|
||||
|
||||
toast.error('Errores de validación', {
|
||||
description: validationErrors,
|
||||
duration: 10000
|
||||
});
|
||||
} else {
|
||||
toast.error('Error al actualizar item', {
|
||||
description: response.error || 'No se pudo actualizar el item. Intenta de nuevo.'
|
||||
});
|
||||
}
|
||||
isSaving = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Recargar items
|
||||
await loadItems();
|
||||
|
||||
@@ -371,10 +422,22 @@
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error updating item:', error);
|
||||
const errorMessage = error?.response?.data?.detail || 'No se pudo actualizar el item. Intenta de nuevo.';
|
||||
toast.error('Error al actualizar item', {
|
||||
description: errorMessage
|
||||
});
|
||||
|
||||
// Manejar errores de validación (422)
|
||||
if (error?.response?.data?.errors && Array.isArray(error.response.data.errors)) {
|
||||
const validationErrors = error.response.data.errors
|
||||
.map((err: any) => `• ${err.message}`)
|
||||
.join('\n');
|
||||
|
||||
toast.error('Errores de validación', {
|
||||
description: validationErrors
|
||||
});
|
||||
} else {
|
||||
const errorMessage = error?.response?.data?.detail || 'No se pudo actualizar el item. Intenta de nuevo.';
|
||||
toast.error('Error al actualizar item', {
|
||||
description: errorMessage
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
|
||||
@@ -138,10 +138,11 @@ function buildInvoicePayload(formData: FormDataSet): CreateInvoiceData | UpdateI
|
||||
othersFormData?.is_mixed || othersFormData?.contingency_mode ||
|
||||
othersFormData?.cove || othersFormData?.operation_num ||
|
||||
othersFormData?.adendas || othersFormData?.certified_number ||
|
||||
othersFormData?.code_signature || othersFormData?.electronic_signature;
|
||||
othersFormData?.code_signature || othersFormData?.electronic_signature ||
|
||||
continuationFormData?.puerto_entrada;
|
||||
|
||||
if (hasComplianceValue) {
|
||||
payload.compliance_mx = buildComplianceMxData(InvoiceTopFieldsFormData, generalFormData, othersFormData, observationFormData);
|
||||
payload.compliance_mx = buildComplianceMxData(InvoiceTopFieldsFormData, generalFormData, othersFormData, observationFormData, continuationFormData);
|
||||
}
|
||||
|
||||
// Financials
|
||||
@@ -169,7 +170,7 @@ function buildInvoicePayload(formData: FormDataSet): CreateInvoiceData | UpdateI
|
||||
return payload;
|
||||
}
|
||||
|
||||
function buildComplianceMxData(InvoiceTopFieldsFormData: any, generalFormData: any, othersFormData: any, observationFormData: any) {
|
||||
function buildComplianceMxData(InvoiceTopFieldsFormData: any, generalFormData: any, othersFormData: any, observationFormData: any, continuationFormData: any) {
|
||||
return {
|
||||
// Pedimento fields - desde InvoiceTopFieldsFormData
|
||||
pedimento_id: InvoiceTopFieldsFormData?.pedimento_id ? Number(InvoiceTopFieldsFormData.pedimento_id) : null,
|
||||
@@ -177,6 +178,7 @@ function buildComplianceMxData(InvoiceTopFieldsFormData: any, generalFormData: a
|
||||
is_pedimento_pending: Boolean(InvoiceTopFieldsFormData?.is_pedimento_pending || false),
|
||||
// Fields from generalFormData
|
||||
aduana: generalFormData?.aduana || null,
|
||||
port_of_entry: continuationFormData?.puerto_entrada || null,
|
||||
provider_header: generalFormData?.provider_header || '',
|
||||
provider_id: generalFormData?.provider_id || null,
|
||||
sold_to_header: generalFormData?.sold_to_header || '',
|
||||
|
||||
Reference in New Issue
Block a user