Merge pull request 'fix/production' (#61) from fix/production into development

Reviewed-on: ADUANASOFT/anexo76#61
This commit is contained in:
2026-01-21 18:13:46 +00:00
10 changed files with 451 additions and 297 deletions

View File

@@ -30,7 +30,7 @@ class UnitOfMeasureBase(BaseModel):
code: str = Field(..., max_length=20, description="Unit Code")
description: Optional[str] = Field(None, max_length=100)
description_en: Optional[str] = Field(None, max_length=100)
customs_code: Optional[str] = Field(None, max_length=20)
customs_code: Optional[int] = Field(None)
american_code: Optional[str] = Field(None, max_length=20)
ace_code: Optional[str] = Field(None, max_length=20)
oma_code: Optional[str] = Field(None, max_length=20)
@@ -42,7 +42,7 @@ class UnitOfMeasureGeneralBase(BaseModel):
conversion_factor: Optional[Decimal] = None
mexico_unit: Optional[str] = Field(None, max_length=20)
american_unit_code: Optional[str] = Field(None, max_length=20)
customs_code: Optional[str] = Field(None, max_length=20)
customs_code: Optional[int] = Field(None)
ace_code: Optional[str] = Field(None, max_length=20)
# --- Create DTOs ---
@@ -99,7 +99,7 @@ class UnitOfMeasureUpdate(BaseModel):
code: Optional[str] = Field(None, max_length=20)
description: Optional[str] = Field(None, max_length=100)
description_en: Optional[str] = Field(None, max_length=100)
customs_code: Optional[str] = Field(None, max_length=20)
customs_code: Optional[int] = Field(None)
american_code: Optional[str] = Field(None, max_length=20)
ace_code: Optional[str] = Field(None, max_length=20)
oma_code: Optional[str] = Field(None, max_length=20)
@@ -111,7 +111,7 @@ class UnitOfMeasureGeneralUpdate(BaseModel):
conversion_factor: Optional[Decimal] = None
mexico_unit: Optional[str] = Field(None, max_length=20)
american_unit_code: Optional[str] = Field(None, max_length=20)
customs_code: Optional[str] = Field(None, max_length=20)
customs_code: Optional[int] = Field(None)
ace_code: Optional[str] = Field(None, max_length=20)
# --- Response DTOs ---

View File

@@ -64,7 +64,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)

View File

@@ -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

View File

@@ -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"
)

View File

@@ -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

View File

@@ -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())