From ba353493d3a26da36b037109cf05baf780593ea7 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Mon, 19 Jan 2026 13:38:06 -0600 Subject: [PATCH] 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 || '',