From 123cdb522bf9b264fa8147548a4c3fd870497d8f Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Wed, 21 Jan 2026 11:11:54 -0600 Subject: [PATCH 1/3] feat(validation): enhance item validation and update UI components for required fields --- .../units_of_measure/models.py | 2 +- .../imports/temporary/validators/common.py | 171 ++++++++-- .../imports/temporary/validators/create.py | 207 ++++-------- .../imports/temporary/validators/update.py | 299 ++++++++++++------ backend/api/v1/modules/a76/items/service.py | 39 ++- .../edit/items/fa/item-configuration.svelte | 6 +- .../invoices/edit/items/fa/main-data.svelte | 12 +- .../invoices/edit/items/items-tab-form.svelte | 2 +- 8 files changed, 446 insertions(+), 292 deletions(-) diff --git a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/models.py b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/models.py index 4564c8d6..e45a9743 100644 --- a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/models.py +++ b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/models.py @@ -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) diff --git a/backend/api/v1/modules/a76/items/imports/temporary/validators/common.py b/backend/api/v1/modules/a76/items/imports/temporary/validators/common.py index 03965b8f..d7db7a63 100644 --- a/backend/api/v1/modules/a76/items/imports/temporary/validators/common.py +++ b/backend/api/v1/modules/a76/items/imports/temporary/validators/common.py @@ -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 diff --git a/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py b/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py index 7de9bb5e..d6080ac3 100644 --- a/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py +++ b/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py @@ -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" ) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/items/imports/temporary/validators/update.py b/backend/api/v1/modules/a76/items/imports/temporary/validators/update.py index 3c335e40..8feaa515 100644 --- a/backend/api/v1/modules/a76/items/imports/temporary/validators/update.py +++ b/backend/api/v1/modules/a76/items/imports/temporary/validators/update.py @@ -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 \ No newline at end of file diff --git a/backend/api/v1/modules/a76/items/service.py b/backend/api/v1/modules/a76/items/service.py index 7267e8b5..2866fb3d 100644 --- a/backend/api/v1/modules/a76/items/service.py +++ b/backend/api/v1/modules/a76/items/service.py @@ -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()) diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte index 5c76d561..34333daa 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte @@ -72,15 +72,15 @@
- +
- +

ID de número de parte existente en catálogo

- +