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 new file mode 100644 index 00000000..03965b8f --- /dev/null +++ b/backend/api/v1/modules/a76/items/imports/temporary/validators/common.py @@ -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 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 new file mode 100644 index 00000000..7de9bb5e --- /dev/null +++ b/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py @@ -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" + ) \ 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 new file mode 100644 index 00000000..3c335e40 --- /dev/null +++ b/backend/api/v1/modules/a76/items/imports/temporary/validators/update.py @@ -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 \ No newline at end of file diff --git a/backend/api/v1/modules/a76/items/line_items/models.py b/backend/api/v1/modules/a76/items/line_items/models.py index ad85b357..4df2aa54 100644 --- a/backend/api/v1/modules/a76/items/line_items/models.py +++ b/backend/api/v1/modules/a76/items/line_items/models.py @@ -179,5 +179,5 @@ class LineItem(Base, TenantScopedMixin, TimestampMixin): foreign_keys=[unit_of_measure], viewonly=True ) fa_data: Mapped[Optional["FaLineItem"]] = relationship( - "FaLineItem", back_populates="master_info", uselist=False + "FaLineItem", back_populates="master_info", cascade="all, delete-orphan", uselist=False ) diff --git a/backend/api/v1/modules/a76/items/service.py b/backend/api/v1/modules/a76/items/service.py index a1ad17af..7267e8b5 100644 --- a/backend/api/v1/modules/a76/items/service.py +++ b/backend/api/v1/modules/a76/items/service.py @@ -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 diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte index d354044a..84c6790e 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte @@ -277,6 +277,27 @@ 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(); @@ -286,10 +307,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; } @@ -300,7 +333,7 @@ isSaving = true; try { - 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, @@ -308,6 +341,27 @@ 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(); @@ -317,10 +371,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; }