From ba353493d3a26da36b037109cf05baf780593ea7 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Mon, 19 Jan 2026 13:38:06 -0600 Subject: [PATCH 1/2] feat(invoice): enhance invoice update validation and payload construction --- .../v1/modules/a24/fa/fa_item_lines/dto.py | 3 +- .../imports/temporary/validators/update.py | 233 +++++++++++++++++- .../api/v1/modules/a76/invoices/services.py | 33 ++- .../dashboard/invoices/edit/save-invoice.ts | 8 +- 4 files changed, 269 insertions(+), 8 deletions(-) diff --git a/backend/api/v1/modules/a24/fa/fa_item_lines/dto.py b/backend/api/v1/modules/a24/fa/fa_item_lines/dto.py index 191c38b0..bf4f63d4 100644 --- a/backend/api/v1/modules/a24/fa/fa_item_lines/dto.py +++ b/backend/api/v1/modules/a24/fa/fa_item_lines/dto.py @@ -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( diff --git a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/update.py b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/update.py index 57877ae4..58efd41a 100644 --- a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/update.py +++ b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/update.py @@ -1,2 +1,231 @@ -def validate_update(): - pass \ No newline at end of file +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 + diff --git a/backend/api/v1/modules/a76/invoices/services.py b/backend/api/v1/modules/a76/invoices/services.py index 81a37d23..7042cca8 100644 --- a/backend/api/v1/modules/a76/invoices/services.py +++ b/backend/api/v1/modules/a76/invoices/services.py @@ -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( diff --git a/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts b/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts index c1a4bd98..a9651280 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts +++ b/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts @@ -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 || '', From dd6c83267402eeb0fe0095ef3cb024cc26b59407 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Tue, 20 Jan 2026 12:53:08 -0600 Subject: [PATCH 2/2] feat(validation): implement comprehensive validation for item creation and updates --- .../imports/temporary/validators/common.py | 47 ++++ .../imports/temporary/validators/create.py | 201 ++++++++++++++++++ .../imports/temporary/validators/update.py | 116 ++++++++++ .../v1/modules/a76/items/line_items/models.py | 2 +- backend/api/v1/modules/a76/items/service.py | 177 ++++++++++++++- .../invoices/edit/items/items-tab-form.svelte | 84 +++++++- 6 files changed, 611 insertions(+), 16 deletions(-) create mode 100644 backend/api/v1/modules/a76/items/imports/temporary/validators/common.py create mode 100644 backend/api/v1/modules/a76/items/imports/temporary/validators/create.py create mode 100644 backend/api/v1/modules/a76/items/imports/temporary/validators/update.py 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; }