diff --git a/backend/api/v1/modules/a76/invoices/common/calculations.py b/backend/api/v1/modules/a76/invoices/common/calculations.py new file mode 100644 index 00000000..f3278732 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/common/calculations.py @@ -0,0 +1,37 @@ +from sqlalchemy.orm import Session +from api.v1.modules.a76.invoices.models import InvoiceFinancials, InvoiceHeader, InvoiceLogistics +from core.exceptions import ErrorCollector + +from .. import schemas + + + +def apply_calculations( + invoice: schemas.InvoiceHeaderUpdate, +): + if invoice.invoice_type == "CR": + invoice.compliance_mx.is_regime_change = True + else: + invoice.compliance_mx.is_regime_change = False + + increments_me = (invoice.financials.freight or 0) + (invoice.financials.insurance or 0) + (invoice.financials.packaging or 0) + (invoice.financials.other_increments or 0) + if invoice.financials.currency == "foreign": + invoice.financials.total_increments_me = increments_me + invoice.financials.total_increments_mn = invoice.financials.total_increments_me * invoice.financials.exchange_rate + invoice.financials.currency_type = "USD" + elif invoice.financials.currency == "local": + invoice.financials.total_increments_mn = increments_me + invoice.financials.total_increments_me = invoice.financials.total_increments_mn / invoice.financials.exchange_rate + invoice.financials.currency_type = "MXN" + elif invoice.financials.currency == "manual": + invoice.financials.total_increments_me = (increments_me)/invoice.financials.exchange_rate + invoice.financials.total_increments_mn = invoice.financials.total_increments_me * invoice.financials.exchange_rate + + + invoice.compliance_mx.is_pedimento_pending = False + if not invoice.compliance_mx.pedimento_id: + invoice.compliance_mx.is_pedimento_pending = True + + + + \ No newline at end of file diff --git a/backend/api/v1/modules/a76/invoices/common/common_validators.py b/backend/api/v1/modules/a76/invoices/common/common_validators.py index 72a5abdf..f6e249bd 100644 --- a/backend/api/v1/modules/a76/invoices/common/common_validators.py +++ b/backend/api/v1/modules/a76/invoices/common/common_validators.py @@ -1,7 +1,24 @@ from typing import Optional from core.exceptions import ErrorCollector -from .. import models +from sqlalchemy import func from sqlalchemy.orm import Session +from .. import models +from .. import schemas +from ..models import InvoiceComplianceMx +from ..models import TransportType, Currency, WeightUnit +from api.v1.modules.a76.invoices.common.calculations import apply_calculations +from api.v1.modules.a76.items.models import LineItem +from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos +from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate +from api.v1.modules.a76.clients_and_providers.models import ClientProvider +from api.v1.modules.a76.customs_brokers.models import CustomsBroker +from api.v1.modules.a76.transportation.transporters.models import Transporter +from api.v1.modules.a76.manifests.manifest.models import Manifest +from api.v1.modules.public.reference_data.incoterms.models import Incoterm +from api.v1.modules.public.reference_data.currency_types.models import CurrencyType +from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection +from core.exceptions import ErrorCollector +from typing import Dict, Any def invoice_exists( @@ -58,7 +75,6 @@ def invoice_exists_by_id( return invoice return None - def invoice_updated( db: Session, invoice_id: str, @@ -86,3 +102,590 @@ def invoice_updated( ) return True return False + +def validate_required_fields_by_operation( + invoice_data: Dict[str, Any], + operation_type: str, + errors: ErrorCollector +) -> None: + """ + Valida campos obligatorios según tipo de operación. + Usar ANTES de guardar en BD. + """ + + # PROVEEDOR (SIEMPRE OBLIGATORIO - mensaje dinámico) + if not invoice_data.get('provider_id'): + # Mensaje dinámico según el header seleccionado + provider_labels = { + 'proveedor': 'Proveedor', + 'exportador': 'Exportador' + } + provider_header = invoice_data.get('provider_header') or 'proveedor' + field_label = provider_labels.get(provider_header, 'Proveedor') + + errors.add_error( + field="provider_id", + message=f"Debe seleccionar {field_label}", + solution=["Seleccione un proveedor de la lista desplegable"], + code="REQUIRED", + value=None + ) + + # VENDIDO A / CONSIGNADO A (SIEMPRE OBLIGATORIO - mensaje dinámico) + if not invoice_data.get('sold_to_id'): + # Mensaje dinámico según el header seleccionado + sold_to_labels = { + 'consignado_a': 'Consignado a', + 'vendido_a': 'Vendido a', + 'exportado_a': 'Exportado a', + 'importador': 'Importador' + } + sold_to_header = invoice_data.get('sold_to_header') or 'consignado_a' + field_label = sold_to_labels.get(sold_to_header, 'Cliente') + + errors.add_error( + field="sold_to_id", + message=f"Debe seleccionar {field_label}", + solution=["Seleccione una opción de la lista desplegable"], + code="REQUIRED", + value=None + ) + + # ENVIADO A (SIEMPRE OBLIGATORIO - mensaje fijo) + if not invoice_data.get('shipped_to_id'): + errors.add_error( + field="shipped_to_id", + message="Debe seleccionar el Destinatario", + solution=["Seleccione un destinatario de la lista desplegable"], + code="REQUIRED", + value=None + ) + + # AGENTE ADUANAL (OBLIGATORIO si hay pedimento) + if invoice_data.get('pedimento_id') and not invoice_data.get('customs_broker_id'): + errors.add_error( + field="customs_broker_id", + message="Debe seleccionar un Agente Aduanal", + solution=["Seleccione un agente aduanal de la lista desplegable"], + code="REQUIRED", + value=None + ) + + +def validate_common( + db: Session, + invoice: schemas.InvoiceHeaderUpdate, + tenant_id: int, + company_id: int, + errors: ErrorCollector, +): + if invoice.compliance_mx.pedimento_id: + pedimento = ( + db.query(Pedimentos) + .filter( + Pedimentos.id == invoice.compliance_mx.pedimento_id, + Pedimentos.tenant_id == tenant_id, + Pedimentos.company_id == company_id, + ) + .first() + ) + if not pedimento: + errors.add_error( + field="compliance_mx.pedimento_id", + message="El Pedimento no existe en el Catálogo de Pedimentos.", + solution=["Verifica el ID", "Revisa el catálogo"], + code="NOT_FOUND", + value=invoice.compliance_mx.pedimento_id, + ) + return # Stop here if pedimento not found + + if not invoice.compliance_mx.is_regime_change: + operacion = "Importación" if pedimento.operation_type == "imp" else "Exportación" + # Validar que el pedimento sea de importación + if pedimento.operation_type != invoice.operation_type: + errors.add_error( + field="compliance_mx.pedimento_id", + message=f"El Pedimento seleccionado no corresponde a una {operacion}.", + solution=[f"Selecciona un Pedimento de {operacion}"], + code="INVALID_OPERATION_TYPE", + value=pedimento.operation_type, + ) + else: + # Validar regímenes incompatibles + only_regimes = ["EXD", "ETE", "ETR"] + is_valid = True + if invoice.operation_type == "imp" and pedimento.regime in only_regimes: + is_valid = False + oposite_operacion = "Exportación" + elif invoice.operation_type == "exp" and pedimento.regime not in only_regimes: + is_valid = False + oposite_operacion = "Importación" + + if not is_valid: + errors.add_error( + field="compliance_mx.pedimento_id", + message=f"El Pedimento seleccionado no corresponde a una {oposite_operacion}, no a una {operacion}.", + solution=[f"Selecciona un Pedimento de {operacion}"], + code="INVALID_REGIME", + value=pedimento.regime, + ) + + # Validar que el tipo de documento coincida con el régimen del pedimento + # NOTA: Solo validamos si no hay errores previos y si document_type está presente + if not errors.has_errors() and invoice.document_type: + if invoice.document_type.upper().strip() != pedimento.regime: + errors.add_error( + field="document_type", + message=f"El Tipo de Documento '{invoice.document_type}' no coincide con el Régimen '{pedimento.regime}' del Pedimento seleccionado.", + solution=[ + "Ajusta el Tipo de Documento o selecciona otro Pedimento" + ], + code="REGIME_MISMATCH", + value=invoice.document_type, + ) + else: + # Cambio de Régimen - Generalmente es de Importación Temporal a Definitiva (IMD) + # En el código legacy se comparaba pedimento.operation_type != 2. + # Si asumimos que 2 era Importación en el sistema anterior: + if pedimento.operation_type != "imp": + errors.add_error( + field="compliance_mx.pedimento_id", + message="El Pedimento seleccionado no corresponde a una Importación (requerido para Cambio de Régimen).", + solution=["Selecciona un Pedimento de Importación"], + code="INVALID_OPERATION_TYPE", + value=pedimento.operation_type, + ) + else: + if pedimento.regime != "IMD" and invoice.document_type == "IMD": + # Si el destino es IMD, validamos que el pedimento original sea de importación + # (aunque usualmente el pedimento que se asocia aquí es el nuevo, el de IMD) + pass + + if invoice.document_type.upper().strip() != pedimento.regime: + errors.add_error( + field="document_type", + message=f"El Tipo de Documento '{invoice.document_type}' no coincide con el Régimen '{pedimento.regime}' del Pedimento seleccionado.", + solution=[ + "Ajusta el Tipo de Documento o selecciona otro Pedimento" + ], + code="REGIME_MISMATCH", + value=invoice.document_type, + ) + + if pedimento.pedimento_code not in ["A1", "A3"]: + errors.add_error( + field="compliance_mx.pedimento_id", + message=f"El Pedimento seleccionado no es de tipo A1 o A3 requerido para Cambio de Régimen.", + solution=["Selecciona un Pedimento de tipo A1 o A3"], + code="INVALID_PEDEMENTO_CODE", + value=pedimento.pedimento_code, + ) + + if pedimento.pedimento_type == "consolidated": + if not pedimento.pedimento_dates: + errors.add_error( + field="compliance_mx.pedimento_id", + message="El Pedimento seleccionado no tiene fechas registradas.", + solution=["Verifica las fechas del Pedimento en el catálogo"], + code="MISSING_PEDIMENTO_DATES", + value=invoice.compliance_mx.pedimento_id, + ) + else: + # Convertir invoice_date a date si es datetime para poder comparar + invoice_date = ( + invoice.invoice_date.date() + if hasattr(invoice.invoice_date, "date") + else invoice.invoice_date + ) + entry_date = ( + pedimento.pedimento_dates.entry_date.date() + if hasattr(pedimento.pedimento_dates.entry_date, "date") + else pedimento.pedimento_dates.entry_date + ) + end_date = ( + pedimento.pedimento_dates.end_date.date() + if hasattr(pedimento.pedimento_dates.end_date, "date") + else pedimento.pedimento_dates.end_date + ) + + if pedimento.pedimento_dates and (invoice_date < entry_date or invoice_date > end_date): + errors.add_error( + field="invoice_date", + message=f"La Fecha de la Factura {invoice.invoice_date} no está dentro del rango de fechas del Pedimento {pedimento.customs_office}-{pedimento.license}-{pedimento.pedimento_number}.", + solution=[ + f"Capturar una Fecha de Factura, entre la Fecha de Inicio: {pedimento.pedimento_dates.entry_date} y la Fecha Final: {pedimento.pedimento_dates.end_date} ." + ], + code="DATE_OUT_OF_RANGE", + value=invoice.invoice_date, + ) + + # Remesa check + if pedimento.pedimento_type == "consolidated": + if not invoice.compliance_mx.remesa: + errors.add_error( + field="compliance_mx.remesa", + message="El campo Remesa es obligatorio cuando se asocia un Pedimento consolidado.", + solution=["Proporciona un valor para Remesa"], + code="REQUIRED_FIELD", + value=invoice.compliance_mx.remesa, + ) + elif invoice.compliance_mx.remesa == 0: + errors.add_error( + field="compliance_mx.remesa", + message="El campo Remesa no puede ser cero cuando se asocia un Pedimento.", + solution=["Proporciona un valor válido para Remesa"], + code="INVALID_VALUE", + value=invoice.compliance_mx.remesa, + ) + + duplicated_remesa = ( + db.query(InvoiceComplianceMx) + .filter( + InvoiceComplianceMx.remesa == invoice.compliance_mx.remesa, + InvoiceComplianceMx.tenant_id == tenant_id, + InvoiceComplianceMx.company_id == company_id, + ) + .first() + ) + if duplicated_remesa and hasattr(invoice, "id"): + if invoice.id != duplicated_remesa.invoice_id: + errors.add_error( + field="compliance_mx.remesa", + message="El valor de Remesa ya está asociado a otro Pedimento.", + solution=["Proporciona un valor único para Remesa"], + code="DUPLICATE_VALUE", + value=invoice.compliance_mx.remesa, + ) + + # Financials checks (if provided) + if invoice.financials: + if not invoice.financials.exchange_rate or invoice.financials.exchange_rate <= 0: + exchange_rate_exists = ( + db.query(ExchangeRate) + .filter( + func.date(ExchangeRate.date) == invoice.invoice_date, + ExchangeRate.tenant_id == tenant_id, + ExchangeRate.company_id == company_id, + ) + .first() + ) + if not exchange_rate_exists: + errors.add_error( + field="financials.exchange_rate", + message=f"No existe un Tipo de Cambio registrado para la fecha {invoice.invoice_date}.", + solution=["Registra el Tipo de Cambio en el catálogo correspondiente"], + code="EXCHANGE_RATE_NOT_FOUND", + value=invoice.financials.exchange_rate, + ) + else: + invoice.financials.exchange_rate = exchange_rate_exists.value + else: + # If financials missing, we might want to error if it's required for this operation + pass + + if invoice.compliance_mx.is_regime_change: + if invoice.document_type in ["EXD", "ETE", "ETR"]: + errors.add_error( + field="document_type", + message="El Tipo de Documento no puede ser de Exportación cuando se trata de un Cambio de Régimen.", + solution=[ + "Selecciona un Tipo de Documento válido para Cambio de Régimen" + ], + code="INVALID_DOCUMENT_TYPE", + value=invoice.document_type, + ) + else: + if invoice.document_type == "IMD": + errors.add_error( + field="document_type", + message="El Tipo de Documento no puede ser 'IMD' a menos que sea un Cambio de Régimen.", + solution=["Selecciona un Tipo de Documento válido"], + code="INVALID_DOCUMENT_TYPE", + value=invoice.document_type, + ) + + # Validar proveedor solo si se proporciona + if invoice.compliance_mx.provider_id: + provider_exists = ( + db.query(ClientProvider) + .filter( + ClientProvider.id == invoice.compliance_mx.provider_id, + ClientProvider.tenant_id == tenant_id, + ClientProvider.company_id == company_id, + ) + .first() + ) + if not provider_exists: + errors.add_error( + field="compliance_mx.provider_id", + message="El Proveedor no existe en el Catálogo de Clientes y Proveedores.", + solution=["Verifica el ID del Proveedor", "Revisa el catálogo"], + code="NOT_FOUND", + value=invoice.compliance_mx.provider_id, + ) + + # Validar vendido a solo si se proporciona + if invoice.compliance_mx.sold_to_id: + selled_to_exists = ( + db.query(ClientProvider) + .filter( + ClientProvider.id == invoice.compliance_mx.sold_to_id, + ClientProvider.tenant_id == tenant_id, + ClientProvider.company_id == company_id, + ) + .first() + ) + if not selled_to_exists: + errors.add_error( + field="compliance_mx.sold_to_id", + message="El Cliente no existe en el Catálogo de Clientes y Proveedores.", + solution=["Verifica el ID del Cliente", "Revisa el catálogo"], + code="NOT_FOUND", + value=invoice.compliance_mx.sold_to_id, + ) + + # Validar destinatario solo si se proporciona + if invoice.compliance_mx.shipped_to_id: + shipped_to_exists = ( + db.query(ClientProvider) + .filter( + ClientProvider.id == invoice.compliance_mx.shipped_to_id, + ClientProvider.tenant_id == tenant_id, + ClientProvider.company_id == company_id, + ) + .first() + ) + if not shipped_to_exists: + errors.add_error( + field="compliance_mx.shipped_to_id", + message="El Destinatario no existe en el Catálogo de Clientes y Proveedores.", + solution=["Verifica el ID del Destinatario", "Revisa el catálogo"], + code="NOT_FOUND", + value=invoice.compliance_mx.shipped_to_id, + ) + + if invoice.compliance_mx.shipped_by_id: + shipped_by_exists = ( + db.query(ClientProvider) + .filter( + ClientProvider.id == invoice.compliance_mx.shipped_by_id, + ClientProvider.tenant_id == tenant_id, + ClientProvider.company_id == company_id, + ) + .first() + ) + if not shipped_by_exists: + errors.add_error( + field="compliance_mx.shipped_by_id", + message="El Remitente no existe en el Catálogo de Clientes y Proveedores.", + solution=["Verifica el ID del Remitente", "Revisa el catálogo"], + code="NOT_FOUND", + value=invoice.compliance_mx.shipped_by_id, + ) + + # Validar agente aduanal solo si se proporciona + if invoice.compliance_mx.customs_broker_id: + customs_broker_exists = ( + db.query(CustomsBroker) + .filter( + CustomsBroker.id == invoice.compliance_mx.customs_broker_id, + CustomsBroker.tenant_id == tenant_id, + CustomsBroker.company_id == company_id, + ) + .first() + ) + if not customs_broker_exists: + errors.add_error( + field="compliance_mx.customs_broker_id", + message="El Agente Aduanal no existe en el Catálogo de Clientes y Proveedores.", + solution=["Verifica el ID del Agente Aduanal", "Revisa el catálogo"], + code="NOT_FOUND", + value=invoice.compliance_mx.customs_broker_id, + ) + + if invoice.logistics: + if invoice.logistics.carrier_id: + carrier_exists = ( + db.query(Transporter) + .filter( + Transporter.id == invoice.logistics.carrier_id, + Transporter.tenant_id == tenant_id, + Transporter.company_id == company_id, + ) + .first() + ) + if not carrier_exists: + errors.add_error( + field="logistics.carrier_id", + message="El Transportista no existe en el Catálogo de Clientes y Proveedores.", + solution=["Verifica el ID del Transportista", "Revisa el catálogo"], + code="NOT_FOUND", + value=invoice.logistics.carrier_id, + ) + + if invoice.logistics.transport_type not in [t.value for t in TransportType]: + errors.add_error( + field="logistics.transport_type", + message="El Tipo de Transporte proporcionado no es válido.", + solution=[ + f"Selecciona un Tipo de Transporte válido: {[t.value for t in TransportType]}" + ], + code="INVALID_TRANSPORT_TYPE", + value=invoice.logistics.transport_type, + ) + else: + if ( + invoice.logistics.transport_type == "none" + and invoice.logistics.transport_num + ): + errors.add_error( + field="logistics.transport_num", + message="El Número de Transporte no debe proporcionarse cuando el Tipo de Transporte es 'none'.", + solution=[ + "Elimina el Número de Transporte o selecciona un Tipo de Transporte válido" + ], + code="INVALID_VALUE", + value=invoice.logistics.transport_num, + ) + else: + if ( + not invoice.logistics.transport_num + and invoice.logistics.transport_type != "none" + ): + errors.add_error( + field="logistics.transport_num", + message="El Número de Transporte es obligatorio cuando se proporciona un Tipo de Transporte distinto de 'none'.", + solution=["Proporciona un Número de Transporte válido"], + code="REQUIRED_FIELD", + value=invoice.logistics.transport_num, + ) + + invoice.financials.currency = invoice.financials.currency or "foreign" + + if invoice.financials.currency not in [c.value for c in Currency]: + errors.add_error( + field="financials.currency", + message="La Moneda proporcionada no es válida.", + solution=[f"Selecciona una Moneda válida: {[c.value for c in Currency]}"], + code="INVALID_CURRENCY", + value=invoice.financials.currency, + ) + else: + # Only check for existing items during update operations (when invoice has an id) + if hasattr(invoice, "id"): + has_items = ( + db.query(LineItem) + .filter( + LineItem.invoice_id == invoice.id, + LineItem.tenant_id == tenant_id, + LineItem.company_id == company_id, + ) + .first() + ) + if has_items: + errors.add_error( + field="items", + message=f"La opcion tipo de moneda {invoice.financials.currency} no puede ser modificada ya que la factura tiene items asociados.", + solution=[ + "Verifica la moneda de los items asociados a la factura." + ], + code="CURRENCY_CANNOT_BE_CHANGED", + value=invoice.financials.currency, + ) + + if invoice.financials.currency == "foreign": + invoice.financials.currency_type = "USD" + elif invoice.financials.currency == "local": + invoice.financials.currency_type = "MXN" + elif invoice.financials.currency == "manual": + if not invoice.financials.currency_type: + errors.add_error( + field="financials.currency_type", + message="El Tipo de Moneda es obligatorio cuando la Moneda es 'manual'.", + solution=["Proporciona un Tipo de Moneda válido"], + code="REQUIRED_FIELD", + value=invoice.financials.currency_type, + ) + else: + currency_exists = ( + db.query(CurrencyType) + .filter(CurrencyType.code == invoice.financials.currency_type) + .first() + ) + if not currency_exists: + errors.add_error( + field="financials.currency_type", + message="El Tipo de Moneda no existe en el Catálogo de Tipos de Moneda.", + solution=[ + "Verifica el código del Tipo de Moneda", + "Revisa el catálogo", + ], + code="NOT_FOUND", + value=invoice.financials.currency_type, + ) + + if invoice.logistics.incoterm: + incoterm_exists = ( + db.query(Incoterm) + .filter( + Incoterm.code == invoice.logistics.incoterm + ) + .first() + ) + if not incoterm_exists: + errors.add_error( + field="logistics.incoterm", + message="El Incoterm no existe en el Catálogo de Incoterms.", + solution=["Verifica el código del Incoterm", "Revisa el catálogo"], + code="NOT_FOUND", + value=invoice.logistics.incoterm, + ) + + if invoice.logistics.weight_type not in [w.value for w in WeightUnit]: + errors.add_error( + field="logistics.weight_type", + message="La Unidad de Peso proporcionada no es válida.", + solution=[ + f"Selecciona una Unidad de Peso válida: {[w.value for w in WeightUnit]}" + ], + code="INVALID_WEIGHT_UNIT", + value=invoice.logistics.weight_type, + ) + + if invoice.compliance_mx.aduana: + custom_section_exists = ( + db.query(CustomsSection) + .filter( + CustomsSection.customs_code == invoice.compliance_mx.aduana, + ) + .first() + ) + if not custom_section_exists: + errors.add_error( + field="compliance_mx.aduana", + message="La Aduana no existe en el Catálogo de Secciones Aduaneras.", + solution=["Verifica el código de la Aduana", "Revisa el catálogo"], + code="NOT_FOUND", + value=invoice.compliance_mx.aduana, + ) + + if invoice.compliance_mx.manifest_number: + manifest_exists = ( + db.query(Manifest) + .filter( + Manifest.manifest_number == invoice.compliance_mx.manifest_number, + Manifest.tenant_id == tenant_id, + Manifest.company_id == company_id, + ) + .first() + ) + if not manifest_exists: + errors.add_error( + field="compliance_mx.manifest_number", + message="El Número de Manifiesto no existe en el sistema.", + solution=["Verifica el Número de Manifiesto", "Revisa el catálogo"], + code="NOT_FOUND", + value=invoice.compliance_mx.manifest_number, + ) + + apply_calculations(invoice) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/invoices/common/create_validators.py b/backend/api/v1/modules/a76/invoices/common/create_validators.py deleted file mode 100644 index 8b137891..00000000 --- a/backend/api/v1/modules/a76/invoices/common/create_validators.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/create.py b/backend/api/v1/modules/a76/invoices/exports/validators/create.py similarity index 92% rename from backend/api/v1/modules/a76/invoices/imports/temporary/validators/create.py rename to backend/api/v1/modules/a76/invoices/exports/validators/create.py index 79a6f0cb..9afedbc2 100644 --- a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/create.py +++ b/backend/api/v1/modules/a76/invoices/exports/validators/create.py @@ -2,8 +2,8 @@ from sqlalchemy.orm import Session from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate from core.exceptions import ErrorCollector -from ....schemas import InvoiceHeaderCreate -from .common import validate_common, validate_required_fields_by_operation +from ...schemas import InvoiceHeaderCreate +from ...common.common_validators import validate_common, validate_required_fields_by_operation def validate_create(db: Session, invoice: InvoiceHeaderCreate, tenant_id: int, company_id: int, errors: ErrorCollector) -> None: """ Valida la creación de una nueva factura de importe temporal """ @@ -23,16 +23,13 @@ def validate_create(db: Session, invoice: InvoiceHeaderCreate, tenant_id: int, c if not invoice.invoice_date: errors.add_required_error("invoice_date") - if errors.has_errors(): - """Se retorna porque hay campos obligatorios básicos que deben ser llenados""" - return - # Validar campos obligatorios según tipo de operación invoice_data = { 'provider_header': invoice.compliance_mx.provider_header if invoice.compliance_mx else None, 'provider_id': invoice.compliance_mx.provider_id if invoice.compliance_mx else None, - 'sold_to_id': invoice.compliance_mx.sold_to_id if invoice.compliance_mx else None, 'sold_to_header': invoice.compliance_mx.sold_to_header if invoice.compliance_mx else None, + 'sold_to_id': invoice.compliance_mx.sold_to_id if invoice.compliance_mx else None, + 'shipped_to_header': invoice.compliance_mx.shipped_to_header if invoice.compliance_mx else None, 'shipped_to_id': invoice.compliance_mx.shipped_to_id if invoice.compliance_mx else None, 'customs_broker_id': invoice.compliance_mx.customs_broker_id if invoice.compliance_mx else None, 'pedimento_id': invoice.compliance_mx.pedimento_id if invoice.compliance_mx else None, diff --git a/backend/api/v1/modules/a76/invoices/exports/validators/update.py b/backend/api/v1/modules/a76/invoices/exports/validators/update.py new file mode 100644 index 00000000..9a2ceec2 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/validators/update.py @@ -0,0 +1,284 @@ +from typing import Optional +from datetime import date +from decimal import Decimal + +from sqlalchemy.orm import Session + +from ...common.common_validators import validate_common, validate_required_fields_by_operation +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( + db: Session, + invoice: InvoiceHeaderUpdate, + existing_invoice: InvoiceHeader, + tenant_id: int, + company_id: int, + 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: 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 in-place y acumula errores en errors) + """ + + # Validar campos requeridos según el tipo de operación + invoice_dict = { + 'provider_id': invoice.compliance_mx.provider_id if invoice.compliance_mx else (existing_invoice.compliance_mx.provider_id if existing_invoice.compliance_mx else None), + 'sold_to_id': invoice.compliance_mx.sold_to_id if invoice.compliance_mx else (existing_invoice.compliance_mx.sold_to_id if existing_invoice.compliance_mx else None), + 'sold_to_header': invoice.compliance_mx.sold_to_header if invoice.compliance_mx else (existing_invoice.compliance_mx.sold_to_header if existing_invoice.compliance_mx else None), + 'shipped_to_id': invoice.compliance_mx.shipped_to_id if invoice.compliance_mx else (existing_invoice.compliance_mx.shipped_to_id if existing_invoice.compliance_mx else None), + 'customs_broker_id': invoice.compliance_mx.customs_broker_id if invoice.compliance_mx else (existing_invoice.compliance_mx.customs_broker_id if existing_invoice.compliance_mx else None), + 'pedimento_id': invoice.compliance_mx.pedimento_id if invoice.compliance_mx else (existing_invoice.compliance_mx.pedimento_id if existing_invoice.compliance_mx else None), + } + + validate_required_fields_by_operation( + invoice_data=invoice_dict, + operation_type=invoice.operation_type or (existing_invoice.operation_type or 'imp'), + errors=errors + ) + + # Primero ejecutar validaciones comunes + validate_common(db, invoice, tenant_id, company_id, 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.compliance_mx: + if invoice.compliance_mx.pedimento_id: + invoice.compliance_mx.pedimento_id = invoice.compliance_mx.pedimento_id + else: + invoice.compliance_mx.pedimento_id = existing_invoice.compliance_mx.pedimento_id if existing_invoice.compliance_mx else None + + # Columna B: Remesa + if invoice.compliance_mx: + if invoice.compliance_mx.remesa: + invoice.compliance_mx.remesa = invoice.compliance_mx.remesa + else: + invoice.compliance_mx.remesa = existing_invoice.compliance_mx.remesa if existing_invoice.compliance_mx else None + + # Columna C: Factura (OBLIGATORIO) + if invoice.invoice_number is not None: + invoice.invoice_number = clean_str(invoice.invoice_number) + if not invoice.invoice_number: + errors.add_required_error("invoice_number") + else: + invoice.invoice_number = existing_invoice.invoice_number + + # Columna D: Fecha + if not invoice.invoice_date: + invoice.invoice_date = existing_invoice.invoice_date + + # Columna E: Tipo Cambio + if invoice.financials: + if invoice.financials.exchange_rate is None: + if existing_invoice.financials: + invoice.financials.exchange_rate = existing_invoice.financials.exchange_rate + + # Columna F: Régimen + if invoice.document_type: + invoice.document_type = clean_str(invoice.document_type).upper() + else: + invoice.document_type = existing_invoice.document_type + + # Columna G: Clave Proveedor + if invoice.compliance_mx: + if invoice.compliance_mx.provider_id is None: + invoice.compliance_mx.provider_id = existing_invoice.compliance_mx.provider_id if existing_invoice.compliance_mx else None + + # Columna H: Clave Vendido A + if invoice.compliance_mx: + if invoice.compliance_mx.sold_to_id is None: + invoice.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.compliance_mx: + if invoice.compliance_mx.shipped_to_id is None: + invoice.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.compliance_mx: + if invoice.compliance_mx.customs_broker_id is None: + invoice.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.logistics: + # Note: logistics in update schema seems to be a single object, but in model it's a list. + # This validator seems to expect a single object (InvoiceLogisticsUpdate). + # We'll stick to the existing logic but make it safe. + if hasattr(invoice.logistics, 'carrier_id') and invoice.logistics.carrier_id is None: + invoice.logistics.carrier_id = existing_invoice.logistics.carrier_id if existing_invoice.logistics else None + + # Columna L: Nombre Conductor + if invoice.logistics: + if hasattr(invoice.logistics, 'driver_name') and not invoice.logistics.driver_name: + invoice.logistics.driver_name = existing_invoice.logistics.driver_name if existing_invoice.logistics else None + elif hasattr(invoice.logistics, 'driver_name'): + invoice.logistics.driver_name = clean_str(invoice.logistics.driver_name) + + # Columna M: Tipo Transporte + if invoice.logistics: + if hasattr(invoice.logistics, 'transport_type') and not invoice.logistics.transport_type: + invoice.logistics.transport_type = existing_invoice.logistics.transport_type if existing_invoice.logistics else None + elif hasattr(invoice.logistics, 'transport_type'): + invoice.logistics.transport_type = clean_str(invoice.logistics.transport_type) + + # Columna N: Número de Transporte + if invoice.logistics: + if hasattr(invoice.logistics, 'transport_num') and not invoice.logistics.transport_num: + invoice.logistics.transport_num = existing_invoice.logistics.transport_num if existing_invoice.logistics else None + elif hasattr(invoice.logistics, 'transport_num'): + invoice.logistics.transport_num = clean_str(invoice.logistics.transport_num) + + # Columna O: Tipo de Moneda + if invoice.financials: + if not invoice.financials.currency: + invoice.financials.currency = existing_invoice.financials.currency if existing_invoice.financials else None + else: + invoice.financials.currency = clean_str(invoice.financials.currency).lower() + + # Columna P: Clave Moneda + if invoice.financials: + if not invoice.financials.currency_type: + invoice.financials.currency_type = existing_invoice.financials.currency_type if existing_invoice.financials else None + else: + invoice.financials.currency_type = clean_str(invoice.financials.currency_type).upper() + + # Columna Q: Flete + if invoice.financials: + if invoice.financials.freight is None: + invoice.financials.freight = existing_invoice.financials.freight if existing_invoice.financials else None + + # Columna R: Val Seguros + if invoice.financials: + if invoice.financials.insurance_value is None: + invoice.financials.insurance_value = existing_invoice.financials.insurance_value if existing_invoice.financials else None + + # Columna S: Seguros + if invoice.financials: + if invoice.financials.insurance is None: + invoice.financials.insurance = existing_invoice.financials.insurance if existing_invoice.financials else None + + # Columna T: Embalaje + if invoice.financials: + if invoice.financials.packaging is None: + invoice.financials.packaging = existing_invoice.financials.packaging if existing_invoice.financials else None + + # Columna U: Otros Incrementables + if invoice.financials: + if invoice.financials.other_increments is None: + invoice.financials.other_increments = existing_invoice.financials.other_increments if existing_invoice.financials else None + + # Columna V: Incoterms + if invoice.logistics: + if hasattr(invoice.logistics, 'incoterm') and not invoice.logistics.incoterm: + invoice.logistics.incoterm = existing_invoice.logistics.incoterm if existing_invoice.logistics else None + elif hasattr(invoice.logistics, 'incoterm'): + invoice.logistics.incoterm = clean_str(invoice.logistics.incoterm).upper() + + # Columna W: Precinto + if invoice.logistics: + if hasattr(invoice.logistics, 'seal_number') and not invoice.logistics.seal_number: + invoice.logistics.seal_number = existing_invoice.logistics.seal_number if existing_invoice.logistics else None + elif hasattr(invoice.logistics, 'seal_number'): + invoice.logistics.seal_number = clean_str(invoice.logistics.seal_number) + + # Columna X: Fecha de Emisión + if not invoice.emission_date: + invoice.emission_date = existing_invoice.emission_date + + # Columna Y: Tipo de Peso (Opcional) + if invoice.logistics: + if hasattr(invoice.logistics, 'weight_type') and not invoice.logistics.weight_type: + invoice.logistics.weight_type = existing_invoice.logistics.weight_type if existing_invoice.logistics else None + elif hasattr(invoice.logistics, 'weight_type'): + invoice.logistics.weight_type = clean_str(invoice.logistics.weight_type).upper() + + # Columna Z: Número de Manifiesto (Opcional) + if invoice.compliance_mx.manifest_number: + if not invoice.compliance_mx.manifest_number: + invoice.compliance_mx.manifest_number = existing_invoice.compliance_mx.manifest_number if existing_invoice.compliance_mx else None + else: + invoice.compliance_mx.manifest_number = clean_str(invoice.compliance_mx.manifest_number) + + # Columna AA: E-Document (Opcional) + if invoice.compliance_mx: + if not invoice.compliance_mx.edocument: + invoice.compliance_mx.edocument = existing_invoice.compliance_mx.edocument if existing_invoice.compliance_mx else None + else: + invoice.compliance_mx.edocument = clean_str(invoice.compliance_mx.edocument) + + # Columna AB: Num. Operación (Opcional) + if invoice.compliance_mx: + if not invoice.compliance_mx.vucem_operation_num: + invoice.compliance_mx.vucem_operation_num = existing_invoice.compliance_mx.vucem_operation_num if existing_invoice.compliance_mx else None + else: + invoice.compliance_mx.vucem_operation_num = clean_str(invoice.compliance_mx.vucem_operation_num) + + # Columna AB: Aduana (OBLIGATORIO) + if invoice.compliance_mx: + if not invoice.compliance_mx.aduana: + invoice.compliance_mx.aduana = existing_invoice.compliance_mx.aduana if existing_invoice.compliance_mx else None + else: + invoice.compliance_mx.aduana = clean_str(invoice.compliance_mx.aduana) + + # Columna AC: Enviado Por (Obligatorio) + if invoice.compliance_mx: + if not invoice.compliance_mx.shipped_by_id: + invoice.compliance_mx.shipped_by_id = existing_invoice.compliance_mx.shipped_by_id if existing_invoice.compliance_mx else None + else: + invoice.compliance_mx.shipped_by_id = clean_str(invoice.compliance_mx.shipped_by_id) + + # Columna AD: Aduana_Cruce (Obligatorio) + current_aduana = invoice.compliance_mx.aduana if invoice.compliance_mx else (existing_invoice.compliance_mx.aduana if existing_invoice.compliance_mx else None) + if not current_aduana: + errors.add_required_error("aduana") + + # Columna AC: Sección de Despacho / Puerto de Entrada (Opcional) + if invoice.compliance_mx: + if not invoice.compliance_mx.port_of_entry: + invoice.compliance_mx.port_of_entry = existing_invoice.compliance_mx.port_of_entry if existing_invoice.compliance_mx else None + else: + invoice.compliance_mx.port_of_entry = clean_str(invoice.compliance_mx.port_of_entry) + + # Columna AE: Observación en Español (Opcional) + if not invoice.observation_es: + invoice.observation_es = existing_invoice.observation_es + else: + invoice.observation_es = clean_str(invoice.observation_es) + + # Columna AF: Observación en Inglés (Opcional) + if not invoice.observation_en: + invoice.observation_en = existing_invoice.observation_en + else: + invoice.observation_en = clean_str(invoice.observation_en) + + # Columna AG: cfdi_uuid (Opcional) + if not invoice.cfdi_uuid: + invoice.cfdi_uuid = existing_invoice.cfdi_uuid if existing_invoice.compliance_mx else None + else: + invoice.cfdi_uuid = clean_str(invoice.cfdi_uuid) + + # Columna AH: Localizacion (Opcional) + if invoice.compliance_mx.location: + if not invoice.compliance_mx.location: + invoice.compliance_mx.location = existing_invoice.compliance_mx.location if existing_invoice.compliance_mx.location else None + else: + invoice.compliance_mx.location = clean_str(invoice.compliance_mx.location) diff --git a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/common.py b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/common.py deleted file mode 100644 index a690343d..00000000 --- a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/common.py +++ /dev/null @@ -1,533 +0,0 @@ -from sqlalchemy import func -from sqlalchemy.orm import Session -from .... import schemas -from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos -from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate -from api.v1.modules.a76.clients_and_providers.models import ClientProvider -from api.v1.modules.a76.customs_brokers.models import CustomsBroker -from api.v1.modules.public.reference_data.incoterms.models import Incoterm -from ....models import InvoiceComplianceMx -from api.v1.modules.a76.items.models import LineItem -from api.v1.modules.public.reference_data.currency_types.models import CurrencyType -from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection -from ....models import TransportType, Currency, WeightUnit -from core.exceptions import ErrorCollector -from typing import Dict, Any - - -def validate_required_fields_by_operation( - invoice_data: Dict[str, Any], - operation_type: str, - errors: ErrorCollector -) -> None: - """ - Valida campos obligatorios según tipo de operación. - Usar ANTES de guardar en BD. - """ - - # PROVEEDOR (SIEMPRE OBLIGATORIO - mensaje dinámico) - if not invoice_data.get('provider_id'): - # Mensaje dinámico según el header seleccionado - provider_labels = { - 'proveedor': 'Proveedor', - 'exportador': 'Exportador' - } - provider_header = invoice_data.get('provider_header') or 'proveedor' - field_label = provider_labels.get(provider_header, 'Proveedor') - - errors.add_error( - field="provider_id", - message=f"Debe seleccionar {field_label}", - solution=["Seleccione un proveedor de la lista desplegable"], - code="REQUIRED", - value=None - ) - - # VENDIDO A / CONSIGNADO A (SIEMPRE OBLIGATORIO - mensaje dinámico) - if not invoice_data.get('sold_to_id'): - # Mensaje dinámico según el header seleccionado - sold_to_labels = { - 'consignado_a': 'Consignado a', - 'vendido_a': 'Vendido a', - 'exportado_a': 'Exportado a', - 'importador': 'Importador' - } - sold_to_header = invoice_data.get('sold_to_header') or 'consignado_a' - field_label = sold_to_labels.get(sold_to_header, 'Cliente') - - errors.add_error( - field="sold_to_id", - message=f"Debe seleccionar {field_label}", - solution=["Seleccione una opción de la lista desplegable"], - code="REQUIRED", - value=None - ) - - # ENVIADO A (SIEMPRE OBLIGATORIO - mensaje fijo) - if not invoice_data.get('shipped_to_id'): - errors.add_error( - field="shipped_to_id", - message="Debe seleccionar el Destinatario", - solution=["Seleccione un destinatario de la lista desplegable"], - code="REQUIRED", - value=None - ) - - # AGENTE ADUANAL (OBLIGATORIO si hay pedimento) - if invoice_data.get('pedimento_id') and not invoice_data.get('customs_broker_id'): - errors.add_error( - field="customs_broker_id", - message="Debe seleccionar un Agente Aduanal", - solution=["Seleccione un agente aduanal de la lista desplegable"], - code="REQUIRED", - value=None - ) - - -def validate_common( - db: Session, - invoice: schemas.InvoiceHeaderUpdate, - tenant_id: int, - company_id: int, - errors: ErrorCollector, -): - if invoice.compliance_mx.pedimento_id: - pedimento = ( - db.query(Pedimentos) - .filter( - Pedimentos.id == invoice.compliance_mx.pedimento_id, - Pedimentos.tenant_id == tenant_id, - Pedimentos.company_id == company_id, - ) - .first() - ) - if not pedimento: - errors.add_error( - field="compliance_mx.pedimento_id", - message="El Pedimento no existe en el Catálogo de Pedimentos.", - solution=["Verifica el ID", "Revisa el catálogo"], - code="NOT_FOUND", - value=invoice.compliance_mx.pedimento_id, - ) - return # Stop here if pedimento not found - - if not invoice.compliance_mx.is_regime_change: - # Validar que el pedimento sea de importación (hardcoded restriction) - if pedimento.operation_type != "imp": - errors.add_error( - field="compliance_mx.pedimento_id", - message="El Pedimento seleccionado no corresponde a una Importación.", - solution=["Selecciona un Pedimento de Importación"], - code="INVALID_OPERATION_TYPE", - value=pedimento.operation_type, - ) - else: - # Validar regímenes incompatibles - export_only_regimes = ["EXD", "ETE", "ETR"] - - if pedimento.regime in export_only_regimes: - errors.add_error( - field="compliance_mx.pedimento_id", - message="El Pedimento seleccionado corresponde a una Exportación, no a una Importación.", - solution=["Selecciona un Pedimento de Importación"], - code="INVALID_REGIME", - value=pedimento.regime, - ) - - # Validar que el tipo de documento coincida con el régimen del pedimento - # NOTA: Solo validamos si no hay errores previos y si document_type está presente - if not errors.has_errors() and invoice.document_type: - if invoice.document_type.upper().strip() != pedimento.regime: - errors.add_error( - field="document_type", - message=f"El Tipo de Documento '{invoice.document_type}' no coincide con el Régimen '{pedimento.regime}' del Pedimento seleccionado.", - solution=[ - "Ajusta el Tipo de Documento o selecciona otro Pedimento" - ], - code="REGIME_MISMATCH", - value=invoice.document_type, - ) - else: - # Cambio de Régimen - Generalmente es de Importación Temporal a Definitiva (IMD) - # En el código legacy se comparaba pedimento.operation_type != 2. - # Si asumimos que 2 era Importación en el sistema anterior: - if pedimento.operation_type != "imp": - errors.add_error( - field="compliance_mx.pedimento_id", - message="El Pedimento seleccionado no corresponde a una Importación (requerido para Cambio de Régimen).", - solution=["Selecciona un Pedimento de Importación"], - code="INVALID_OPERATION_TYPE", - value=pedimento.operation_type, - ) - else: - if pedimento.regime != "IMD" and invoice.document_type == "IMD": - # Si el destino es IMD, validamos que el pedimento original sea de importación - # (aunque usualmente el pedimento que se asocia aquí es el nuevo, el de IMD) - pass - - if invoice.document_type.upper().strip() != pedimento.regime: - errors.add_error( - field="document_type", - message=f"El Tipo de Documento '{invoice.document_type}' no coincide con el Régimen '{pedimento.regime}' del Pedimento seleccionado.", - solution=[ - "Ajusta el Tipo de Documento o selecciona otro Pedimento" - ], - code="REGIME_MISMATCH", - value=invoice.document_type, - ) - - if pedimento.pedimento_code not in ["A1", "A3"]: - errors.add_error( - field="compliance_mx.pedimento_id", - message=f"El Pedimento seleccionado no es de tipo A1 o A3 requerido para Cambio de Régimen.", - solution=["Selecciona un Pedimento de tipo A1 o A3"], - code="INVALID_PEDEMENTO_CODE", - value=pedimento.pedimento_code, - ) - - if pedimento.pedimento_type == "consolidated": - # Convertir invoice_date a date si es datetime para poder comparar - invoice_date = ( - invoice.invoice_date.date() - if hasattr(invoice.invoice_date, "date") - else invoice.invoice_date - ) - entry_date = ( - pedimento.pedimento_dates.entry_date.date() - if hasattr(pedimento.pedimento_dates.entry_date, "date") - else pedimento.pedimento_dates.entry_date - ) - end_date = ( - pedimento.pedimento_dates.end_date.date() - if hasattr(pedimento.pedimento_dates.end_date, "date") - else pedimento.pedimento_dates.end_date - ) - - if invoice_date < entry_date or invoice_date > end_date: - errors.add_error( - field="invoice_date", - message=f"La Fecha de la Factura {invoice.invoice_date} no está dentro del rango de fechas del Pedimento {pedimento.customs_office}-{pedimento.license}-{pedimento.pedimento_number}.", - solution=[ - f"Capturar una Fecha de Factura, entre la Fecha de Inicio: {pedimento.pedimento_dates.entry_date} y la Fecha Final: {pedimento.pedimento_dates.end_date} ." - ], - code="DATE_OUT_OF_RANGE", - value=invoice.invoice_date, - ) - - # Remesa check - if pedimento.pedimento_type == "consolidated": - if not invoice.compliance_mx.remesa: - errors.add_error( - field="compliance_mx.remesa", - message="El campo Remesa es obligatorio cuando se asocia un Pedimento consolidado.", - solution=["Proporciona un valor para Remesa"], - code="REQUIRED_FIELD", - value=invoice.compliance_mx.remesa, - ) - elif invoice.compliance_mx.remesa == 0: - errors.add_error( - field="compliance_mx.remesa", - message="El campo Remesa no puede ser cero cuando se asocia un Pedimento.", - solution=["Proporciona un valor válido para Remesa"], - code="INVALID_VALUE", - value=invoice.compliance_mx.remesa, - ) - - duplicated_remesa = ( - db.query(InvoiceComplianceMx) - .filter( - InvoiceComplianceMx.remesa == invoice.compliance_mx.remesa, - InvoiceComplianceMx.tenant_id == tenant_id, - InvoiceComplianceMx.company_id == company_id, - ) - .first() - ) - if duplicated_remesa and hasattr(invoice, "id"): - if invoice.id != duplicated_remesa.invoice_id: - errors.add_error( - field="compliance_mx.remesa", - message="El valor de Remesa ya está asociado a otro Pedimento.", - solution=["Proporciona un valor único para Remesa"], - code="DUPLICATE_VALUE", - value=invoice.compliance_mx.remesa, - ) - - # Financials checks (if provided) - if invoice.financials: - if not invoice.financials.exchange_rate or invoice.financials.exchange_rate <= 0: - exchange_rate_exists = ( - db.query(ExchangeRate) - .filter( - func.date(ExchangeRate.date) == invoice.invoice_date, - ExchangeRate.tenant_id == tenant_id, - ExchangeRate.company_id == company_id, - ) - .first() - ) - if not exchange_rate_exists: - errors.add_error( - field="financials.exchange_rate", - message=f"No existe un Tipo de Cambio registrado para la fecha {invoice.invoice_date}.", - solution=["Registra el Tipo de Cambio en el catálogo correspondiente"], - code="EXCHANGE_RATE_NOT_FOUND", - value=invoice.financials.exchange_rate, - ) - else: - invoice.financials.exchange_rate = exchange_rate_exists.value - else: - # If financials missing, we might want to error if it's required for this operation - pass - - if invoice.compliance_mx.is_regime_change: - if invoice.document_type in ["EXD", "ETE", "ETR"]: - errors.add_error( - field="document_type", - message="El Tipo de Documento no puede ser de Exportación cuando se trata de un Cambio de Régimen.", - solution=[ - "Selecciona un Tipo de Documento válido para Cambio de Régimen" - ], - code="INVALID_DOCUMENT_TYPE", - value=invoice.document_type, - ) - else: - if invoice.document_type == "IMD": - errors.add_error( - field="document_type", - message="El Tipo de Documento no puede ser 'IMD' a menos que sea un Cambio de Régimen.", - solution=["Selecciona un Tipo de Documento válido"], - code="INVALID_DOCUMENT_TYPE", - value=invoice.document_type, - ) - - # Validar proveedor solo si se proporciona - if invoice.compliance_mx.provider_id: - provider_exists = ( - db.query(ClientProvider) - .filter( - ClientProvider.id == invoice.compliance_mx.provider_id, - ClientProvider.tenant_id == tenant_id, - ClientProvider.company_id == company_id, - ) - .first() - ) - if not provider_exists: - errors.add_error( - field="compliance_mx.provider_id", - message="El Proveedor no existe en el Catálogo de Clientes y Proveedores.", - solution=["Verifica el ID del Proveedor", "Revisa el catálogo"], - code="NOT_FOUND", - value=invoice.compliance_mx.provider_id, - ) - - # Validar vendido a solo si se proporciona - if invoice.compliance_mx.sold_to_id: - selled_to_exists = ( - db.query(ClientProvider) - .filter( - ClientProvider.id == invoice.compliance_mx.sold_to_id, - ClientProvider.tenant_id == tenant_id, - ClientProvider.company_id == company_id, - ) - .first() - ) - if not selled_to_exists: - errors.add_error( - field="compliance_mx.sold_to_id", - message="El Cliente no existe en el Catálogo de Clientes y Proveedores.", - solution=["Verifica el ID del Cliente", "Revisa el catálogo"], - code="NOT_FOUND", - value=invoice.compliance_mx.sold_to_id, - ) - - # Validar destinatario solo si se proporciona - if invoice.compliance_mx.shipped_to_id: - shipped_to_exists = ( - db.query(ClientProvider) - .filter( - ClientProvider.id == invoice.compliance_mx.shipped_to_id, - ClientProvider.tenant_id == tenant_id, - ClientProvider.company_id == company_id, - ) - .first() - ) - if not shipped_to_exists: - errors.add_error( - field="compliance_mx.shipped_to_id", - message="El Destinatario no existe en el Catálogo de Clientes y Proveedores.", - solution=["Verifica el ID del Destinatario", "Revisa el catálogo"], - code="NOT_FOUND", - value=invoice.compliance_mx.shipped_to_id, - ) - - # Validar agente aduanal solo si se proporciona - if invoice.compliance_mx.customs_broker_id: - customs_broker_exists = ( - db.query(CustomsBroker) - .filter( - CustomsBroker.id == invoice.compliance_mx.customs_broker_id, - CustomsBroker.tenant_id == tenant_id, - CustomsBroker.company_id == company_id, - ) - .first() - ) - if not customs_broker_exists: - errors.add_error( - field="compliance_mx.customs_broker_id", - message="El Agente Aduanal no existe en el Catálogo de Clientes y Proveedores.", - solution=["Verifica el ID del Agente Aduanal", "Revisa el catálogo"], - code="NOT_FOUND", - value=invoice.compliance_mx.customs_broker_id, - ) - - if invoice.logistics: - if invoice.logistics.transport_num and not invoice.logistics.transport_num: - # logic ... - pass - - if invoice.logistics.transport_type not in [t.value for t in TransportType]: - errors.add_error( - field="logistics.transport_type", - message="El Tipo de Transporte proporcionado no es válido.", - solution=[ - f"Selecciona un Tipo de Transporte válido: {[t.value for t in TransportType]}" - ], - code="INVALID_TRANSPORT_TYPE", - value=invoice.logistics.transport_type, - ) - else: - if ( - invoice.logistics.transport_type == "none" - and invoice.logistics.transport_num - ): - errors.add_error( - field="logistics.transport_num", - message="El Número de Transporte no debe proporcionarse cuando el Tipo de Transporte es 'none'.", - solution=[ - "Elimina el Número de Transporte o selecciona un Tipo de Transporte válido" - ], - code="INVALID_VALUE", - value=invoice.logistics.transport_num, - ) - else: - if ( - not invoice.logistics.transport_num - and invoice.logistics.transport_type != "none" - ): - errors.add_error( - field="logistics.transport_num", - message="El Número de Transporte es obligatorio cuando se proporciona un Tipo de Transporte distinto de 'none'.", - solution=["Proporciona un Número de Transporte válido"], - code="REQUIRED_FIELD", - value=invoice.logistics.transport_num, - ) - - invoice.financials.currency = invoice.financials.currency or "foreign" - - if invoice.financials.currency not in [c.value for c in Currency]: - errors.add_error( - field="financials.currency", - message="La Moneda proporcionada no es válida.", - solution=[f"Selecciona una Moneda válida: {[c.value for c in Currency]}"], - code="INVALID_CURRENCY", - value=invoice.financials.currency, - ) - else: - # Only check for existing items during update operations (when invoice has an id) - if hasattr(invoice, "id"): - has_items = ( - db.query(LineItem) - .filter( - LineItem.invoice_id == invoice.id, - LineItem.tenant_id == tenant_id, - LineItem.company_id == company_id, - ) - .first() - ) - if has_items: - errors.add_error( - field="items", - message=f"La opcion tipo de moneda {invoice.financials.currency} no puede ser modificada ya que la factura tiene items asociados.", - solution=[ - "Verifica la moneda de los items asociados a la factura." - ], - code="CURRENCY_CANNOT_BE_CHANGED", - value=invoice.financials.currency, - ) - - if invoice.financials.currency == "foreign": - invoice.financials.currency_type = "USD" - elif invoice.financials.currency == "local": - invoice.financials.currency_type = "MXN" - elif invoice.financials.currency == "manual": - if not invoice.financials.currency_type: - errors.add_error( - field="financials.currency_type", - message="El Tipo de Moneda es obligatorio cuando la Moneda es 'manual'.", - solution=["Proporciona un Tipo de Moneda válido"], - code="REQUIRED_FIELD", - value=invoice.financials.currency_type, - ) - else: - currency_exists = ( - db.query(CurrencyType) - .filter(CurrencyType.code == invoice.financials.currency_type) - .first() - ) - if not currency_exists: - errors.add_error( - field="financials.currency_type", - message="El Tipo de Moneda no existe en el Catálogo de Tipos de Moneda.", - solution=[ - "Verifica el código del Tipo de Moneda", - "Revisa el catálogo", - ], - code="NOT_FOUND", - value=invoice.financials.currency_type, - ) - - if invoice.logistics.incoterm: - incoterm_exists = ( - db.query(Incoterm) - .filter( - Incoterm.code == invoice.logistics.incoterm, - Incoterm.tenant_id == tenant_id, - Incoterm.company_id == company_id, - ) - .first() - ) - if not incoterm_exists: - errors.add_error( - field="logistics.incoterm", - message="El Incoterm no existe en el Catálogo de Incoterms.", - solution=["Verifica el código del Incoterm", "Revisa el catálogo"], - code="NOT_FOUND", - value=invoice.logistics.incoterm, - ) - - if invoice.logistics.weight_type not in [w.value for w in WeightUnit]: - errors.add_error( - field="logistics.weight_type", - message="La Unidad de Peso proporcionada no es válida.", - solution=[ - f"Selecciona una Unidad de Peso válida: {[w.value for w in WeightUnit]}" - ], - code="INVALID_WEIGHT_UNIT", - value=invoice.logistics.weight_type, - ) - - if invoice.compliance_mx.aduana: - custom_section_exists = ( - db.query(CustomsSection) - .filter( - CustomsSection.customs_code == invoice.compliance_mx.aduana, - ) - .first() - ) - if not custom_section_exists: - errors.add_error( - field="compliance_mx.aduana", - message="La Aduana no existe en el Catálogo de Secciones Aduaneras.", - solution=["Verifica el código de la Aduana", "Revisa el catálogo"], - code="NOT_FOUND", - value=invoice.compliance_mx.aduana, - ) 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 deleted file mode 100644 index ac85ff24..00000000 --- a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/update.py +++ /dev/null @@ -1,255 +0,0 @@ -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 -from .common import validate_required_fields_by_operation - - -# 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) - """ - - # Validar campos requeridos según el tipo de operación - invoice_dict = { - 'provider_id': invoice_data.compliance_mx.provider_id if invoice_data.compliance_mx else (existing_invoice.compliance_mx.provider_id if existing_invoice.compliance_mx else None), - 'sold_to_id': invoice_data.compliance_mx.sold_to_id if invoice_data.compliance_mx else (existing_invoice.compliance_mx.sold_to_id if existing_invoice.compliance_mx else None), - 'sold_to_header': invoice_data.compliance_mx.sold_to_header if invoice_data.compliance_mx else (existing_invoice.compliance_mx.sold_to_header if existing_invoice.compliance_mx else None), - 'shipped_to_id': invoice_data.compliance_mx.shipped_to_id if invoice_data.compliance_mx else (existing_invoice.compliance_mx.shipped_to_id if existing_invoice.compliance_mx else None), - 'customs_broker_id': invoice_data.compliance_mx.customs_broker_id if invoice_data.compliance_mx else (existing_invoice.compliance_mx.customs_broker_id if existing_invoice.compliance_mx else None), - 'pedimento_id': invoice_data.compliance_mx.pedimento_id if invoice_data.compliance_mx else (existing_invoice.compliance_mx.pedimento_id if existing_invoice.compliance_mx else None), - } - - validate_required_fields_by_operation( - invoice_data=invoice_dict, - operation_type=invoice_data.operation_type or (existing_invoice.operation_type or 'imp'), - errors=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: - if invoice_data.compliance_mx.pedimento_id: - invoice_data.compliance_mx.pedimento_id = 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: - if invoice_data.compliance_mx.remesa: - invoice_data.compliance_mx.remesa = 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) - if invoice_data.invoice_number is not None: - invoice_data.invoice_number = clean_str(invoice_data.invoice_number) - if not invoice_data.invoice_number: - errors.add_required_error("invoice_number") - else: - invoice_data.invoice_number = existing_invoice.invoice_number - - # Columna D: Fecha - if not invoice_data.invoice_date: - invoice_data.invoice_date = existing_invoice.invoice_date - - # Columna E: Tipo Cambio - if invoice_data.financials: - if invoice_data.financials.exchange_rate is None: - 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: - if invoice_data.compliance_mx.provider_id is None: - 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: - if invoice_data.compliance_mx.sold_to_id is None: - 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: - if invoice_data.compliance_mx.shipped_to_id is None: - 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: - if invoice_data.compliance_mx.customs_broker_id is None: - 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: - # Note: logistics in update schema seems to be a single object, but in model it's a list. - # This validator seems to expect a single object (InvoiceLogisticsUpdate). - # We'll stick to the existing logic but make it safe. - if hasattr(invoice_data.logistics, 'carrier_id') and invoice_data.logistics.carrier_id is None: - invoice_data.logistics.carrier_id = existing_invoice.logistics.carrier_id if existing_invoice.logistics else None - - # Columna L: Nombre Conductor - if invoice_data.logistics: - if hasattr(invoice_data.logistics, 'driver_name') and not invoice_data.logistics.driver_name: - invoice_data.logistics.driver_name = existing_invoice.logistics.driver_name if existing_invoice.logistics else None - elif hasattr(invoice_data.logistics, 'driver_name'): - invoice_data.logistics.driver_name = clean_str(invoice_data.logistics.driver_name) - - # Columna M: Tipo Transporte - if invoice_data.logistics: - if hasattr(invoice_data.logistics, 'transport_type') and not invoice_data.logistics.transport_type: - invoice_data.logistics.transport_type = existing_invoice.logistics.transport_type if existing_invoice.logistics else None - elif hasattr(invoice_data.logistics, 'transport_type'): - invoice_data.logistics.transport_type = clean_str(invoice_data.logistics.transport_type) - - # Columna N: Número de Transporte - if invoice_data.logistics: - if hasattr(invoice_data.logistics, 'transport_num') and not invoice_data.logistics.transport_num: - invoice_data.logistics.transport_num = existing_invoice.logistics.transport_num if existing_invoice.logistics else None - elif hasattr(invoice_data.logistics, 'transport_num'): - invoice_data.logistics.transport_num = clean_str(invoice_data.logistics.transport_num) - - # Columna O: Tipo de Moneda - if invoice_data.financials: - if not invoice_data.financials.currency: - invoice_data.financials.currency = existing_invoice.financials.currency if existing_invoice.financials else None - else: - invoice_data.financials.currency = clean_str(invoice_data.financials.currency).lower() - - # Columna P: Clave Moneda - if invoice_data.financials: - if not invoice_data.financials.currency_type: - invoice_data.financials.currency_type = existing_invoice.financials.currency_type if existing_invoice.financials else None - else: - invoice_data.financials.currency_type = clean_str(invoice_data.financials.currency_type).upper() - - # Columna Q: Flete - if invoice_data.financials: - if invoice_data.financials.freight is None: - invoice_data.financials.freight = existing_invoice.financials.freight if existing_invoice.financials else None - - # Columna R: Val Seguros - if invoice_data.financials: - if invoice_data.financials.insurance_value is None: - invoice_data.financials.insurance_value = existing_invoice.financials.insurance_value if existing_invoice.financials else None - - # Columna S: Seguros - if invoice_data.financials: - if invoice_data.financials.insurance is None: - invoice_data.financials.insurance = existing_invoice.financials.insurance if existing_invoice.financials else None - - # Columna T: Embalaje - if invoice_data.financials: - if invoice_data.financials.packaging is None: - invoice_data.financials.packaging = existing_invoice.financials.packaging if existing_invoice.financials else None - - # Columna U: Otros Incrementables - if invoice_data.financials: - if invoice_data.financials.other_increments is None: - invoice_data.financials.other_increments = existing_invoice.financials.other_increments if existing_invoice.financials else None - - # Columna V: Incoterms - if invoice_data.logistics: - if hasattr(invoice_data.logistics, 'incoterm') and not invoice_data.logistics.incoterm: - invoice_data.logistics.incoterm = existing_invoice.logistics.incoterm if existing_invoice.logistics else None - elif hasattr(invoice_data.logistics, 'incoterm'): - invoice_data.logistics.incoterm = clean_str(invoice_data.logistics.incoterm).upper() - - # Columna W: Precinto - if invoice_data.logistics: - if hasattr(invoice_data.logistics, 'seal_number') and not invoice_data.logistics.seal_number: - invoice_data.logistics.seal_number = existing_invoice.logistics.seal_number if existing_invoice.logistics else None - elif hasattr(invoice_data.logistics, 'seal_number'): - invoice_data.logistics.seal_number = clean_str(invoice_data.logistics.seal_number) - - # Columna X: Fecha de Emisión - if not invoice_data.emission_date: - invoice_data.emission_date = existing_invoice.emission_date - - # Columna Y: Tipo de Peso (Opcional) - if invoice_data.logistics: - if hasattr(invoice_data.logistics, 'weight_type') and not invoice_data.logistics.weight_type: - invoice_data.logistics.weight_type = existing_invoice.logistics.weight_type if existing_invoice.logistics else None - elif hasattr(invoice_data.logistics, 'weight_type'): - invoice_data.logistics.weight_type = clean_str(invoice_data.logistics.weight_type).upper() - - # Columna Z: E-Document (Opcional) - if invoice_data.compliance_mx: - if not invoice_data.compliance_mx.edocument: - invoice_data.compliance_mx.edocument = existing_invoice.compliance_mx.edocument if existing_invoice.compliance_mx else None - else: - invoice_data.compliance_mx.edocument = clean_str(invoice_data.compliance_mx.edocument) - - # Columna AA: Num. Operación (Opcional) - if invoice_data.compliance_mx: - if not invoice_data.compliance_mx.vucem_operation_num: - invoice_data.compliance_mx.vucem_operation_num = existing_invoice.compliance_mx.vucem_operation_num if existing_invoice.compliance_mx else None - else: - invoice_data.compliance_mx.vucem_operation_num = clean_str(invoice_data.compliance_mx.vucem_operation_num) - - # Columna AB: Aduana (OBLIGATORIO) - if invoice_data.compliance_mx: - if not invoice_data.compliance_mx.aduana: - invoice_data.compliance_mx.aduana = existing_invoice.compliance_mx.aduana if existing_invoice.compliance_mx else None - else: - invoice_data.compliance_mx.aduana = clean_str(invoice_data.compliance_mx.aduana) - - # Validar que aduana sea obligatorio (excepto para MEX) - if existing_invoice.invoice_type != "MEX": - current_aduana = invoice_data.compliance_mx.aduana if invoice_data.compliance_mx else (existing_invoice.compliance_mx.aduana if existing_invoice.compliance_mx else None) - if not current_aduana: - errors.add_required_error("aduana") - - # Columna AC: Sección de Despacho / Puerto de Entrada (Opcional) - if invoice_data.compliance_mx: - if not invoice_data.compliance_mx.port_of_entry: - invoice_data.compliance_mx.port_of_entry = existing_invoice.compliance_mx.port_of_entry if existing_invoice.compliance_mx else None - else: - invoice_data.compliance_mx.port_of_entry = clean_str(invoice_data.compliance_mx.port_of_entry) - - # Columna AD: Observación en Español (Opcional) - if not invoice_data.observation_es: - invoice_data.observation_es = existing_invoice.observation_es - else: - invoice_data.observation_es = clean_str(invoice_data.observation_es) - - # Columna AD: Observación en Inglés (Opcional) - if not invoice_data.observation_en: - invoice_data.observation_en = existing_invoice.observation_en - else: - invoice_data.observation_en = clean_str(invoice_data.observation_en) - diff --git a/backend/api/v1/modules/a76/invoices/imports/validators/create.py b/backend/api/v1/modules/a76/invoices/imports/validators/create.py new file mode 100644 index 00000000..9afedbc2 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/imports/validators/create.py @@ -0,0 +1,95 @@ +from sqlalchemy.orm import Session + +from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate +from core.exceptions import ErrorCollector +from ...schemas import InvoiceHeaderCreate +from ...common.common_validators import validate_common, validate_required_fields_by_operation + +def validate_create(db: Session, invoice: InvoiceHeaderCreate, tenant_id: int, company_id: int, errors: ErrorCollector) -> None: + """ Valida la creación de una nueva factura de importe temporal """ + + if not invoice.operation_type: + errors.add_required_error("operation_type") + + if not invoice.invoice_type: + errors.add_required_error("invoice_type") + + if not invoice.document_type and invoice.invoice_type != "MEX": + errors.add_required_error("document_type") + + if not invoice.invoice_number: + errors.add_required_error("invoice_number") + + if not invoice.invoice_date: + errors.add_required_error("invoice_date") + + # Validar campos obligatorios según tipo de operación + invoice_data = { + 'provider_header': invoice.compliance_mx.provider_header if invoice.compliance_mx else None, + 'provider_id': invoice.compliance_mx.provider_id if invoice.compliance_mx else None, + 'sold_to_header': invoice.compliance_mx.sold_to_header if invoice.compliance_mx else None, + 'sold_to_id': invoice.compliance_mx.sold_to_id if invoice.compliance_mx else None, + 'shipped_to_header': invoice.compliance_mx.shipped_to_header if invoice.compliance_mx else None, + 'shipped_to_id': invoice.compliance_mx.shipped_to_id if invoice.compliance_mx else None, + 'customs_broker_id': invoice.compliance_mx.customs_broker_id if invoice.compliance_mx else None, + 'pedimento_id': invoice.compliance_mx.pedimento_id if invoice.compliance_mx else None, + } + + validate_required_fields_by_operation( + invoice_data=invoice_data, + operation_type=invoice.operation_type, + errors=errors + ) + + if errors.has_errors(): + """Se retorna porque hay campos obligatorios según el tipo de operación que deben ser llenados""" + return + + validate_common(db, invoice, tenant_id, company_id, errors) + + if errors.has_errors(): + """Se retorna por que fallaron las validaciones generales""" + return + + if invoice.compliance_mx: + if not invoice.compliance_mx.pedimento_id: + invoice.compliance_mx.remesa = None + + if invoice.financials: + if not invoice.financials.exchange_rate: + invoice.financials.exchange_rate = db.query(ExchangeRate.value).filter(ExchangeRate.date == invoice.invoice_date).scalar() + + invoice.document_type = (invoice.document_type or "").upper() + + if invoice.logistics: + if not invoice.logistics.transport_type: + invoice.logistics.transport_type = "none" + + if invoice.logistics.transport_type == "none" and invoice.logistics.transport_num: + invoice.logistics.transport_num = None + + invoice.logistics.incoterm = (invoice.logistics.incoterm or "").upper() + + if not invoice.logistics.weight_type: + invoice.logistics.weight_type = "kgs" + + if invoice.financials: + if not invoice.financials.currency: + invoice.financials.currency = "foreign" + + if invoice.financials.currency == "local": + invoice.financials.currency_type = "MXN" + elif invoice.financials.currency == "foreign": + invoice.financials.currency_type = "USD" + elif invoice.financials.currency == "manual": + invoice.financials.currency_type = (invoice.financials.currency_type or "").upper() + + + + + + + + + + \ No newline at end of file diff --git a/backend/api/v1/modules/a76/invoices/imports/validators/update.py b/backend/api/v1/modules/a76/invoices/imports/validators/update.py new file mode 100644 index 00000000..606df18a --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/imports/validators/update.py @@ -0,0 +1,259 @@ +from typing import Optional +from datetime import date +from decimal import Decimal + +from sqlalchemy.orm import Session + +from ...common.common_validators import validate_common, validate_required_fields_by_operation +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( + db: Session, + invoice: InvoiceHeaderUpdate, + existing_invoice: InvoiceHeader, + tenant_id: int, + company_id: int, + 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: 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 in-place y acumula errores en errors) + """ + + # Validar campos requeridos según el tipo de operación + invoice_dict = { + 'provider_id': invoice.compliance_mx.provider_id if invoice.compliance_mx else (existing_invoice.compliance_mx.provider_id if existing_invoice.compliance_mx else None), + 'sold_to_id': invoice.compliance_mx.sold_to_id if invoice.compliance_mx else (existing_invoice.compliance_mx.sold_to_id if existing_invoice.compliance_mx else None), + 'sold_to_header': invoice.compliance_mx.sold_to_header if invoice.compliance_mx else (existing_invoice.compliance_mx.sold_to_header if existing_invoice.compliance_mx else None), + 'shipped_to_id': invoice.compliance_mx.shipped_to_id if invoice.compliance_mx else (existing_invoice.compliance_mx.shipped_to_id if existing_invoice.compliance_mx else None), + 'customs_broker_id': invoice.compliance_mx.customs_broker_id if invoice.compliance_mx else (existing_invoice.compliance_mx.customs_broker_id if existing_invoice.compliance_mx else None), + 'pedimento_id': invoice.compliance_mx.pedimento_id if invoice.compliance_mx else (existing_invoice.compliance_mx.pedimento_id if existing_invoice.compliance_mx else None), + } + + validate_required_fields_by_operation( + invoice_data=invoice_dict, + operation_type=invoice.operation_type or (existing_invoice.operation_type or 'imp'), + errors=errors + ) + + # Primero ejecutar validaciones comunes + validate_common(db, invoice, tenant_id, company_id, 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.compliance_mx: + if invoice.compliance_mx.pedimento_id: + invoice.compliance_mx.pedimento_id = invoice.compliance_mx.pedimento_id + else: + invoice.compliance_mx.pedimento_id = existing_invoice.compliance_mx.pedimento_id if existing_invoice.compliance_mx else None + + # Columna B: Remesa + if invoice.compliance_mx: + if invoice.compliance_mx.remesa: + invoice.compliance_mx.remesa = invoice.compliance_mx.remesa + else: + invoice.compliance_mx.remesa = existing_invoice.compliance_mx.remesa if existing_invoice.compliance_mx else None + + # Columna C: Factura (OBLIGATORIO) + if invoice.invoice_number is not None: + invoice.invoice_number = clean_str(invoice.invoice_number) + if not invoice.invoice_number: + errors.add_required_error("invoice_number") + else: + invoice.invoice_number = existing_invoice.invoice_number + + # Columna D: Fecha + if not invoice.invoice_date: + invoice.invoice_date = existing_invoice.invoice_date + + # Columna E: Tipo Cambio + if invoice.financials: + if invoice.financials.exchange_rate is None: + if existing_invoice.financials: + invoice.financials.exchange_rate = existing_invoice.financials.exchange_rate + + # Columna F: Régimen + if invoice.document_type: + invoice.document_type = clean_str(invoice.document_type).upper() + else: + invoice.document_type = existing_invoice.document_type + + # Columna G: Clave Proveedor + if invoice.compliance_mx: + if invoice.compliance_mx.provider_id is None: + invoice.compliance_mx.provider_id = existing_invoice.compliance_mx.provider_id if existing_invoice.compliance_mx else None + + # Columna H: Clave Vendido A + if invoice.compliance_mx: + if invoice.compliance_mx.sold_to_id is None: + invoice.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.compliance_mx: + if invoice.compliance_mx.shipped_to_id is None: + invoice.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.compliance_mx: + if invoice.compliance_mx.customs_broker_id is None: + invoice.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.logistics: + # Note: logistics in update schema seems to be a single object, but in model it's a list. + # This validator seems to expect a single object (InvoiceLogisticsUpdate). + # We'll stick to the existing logic but make it safe. + if hasattr(invoice.logistics, 'carrier_id') and invoice.logistics.carrier_id is None: + invoice.logistics.carrier_id = existing_invoice.logistics.carrier_id if existing_invoice.logistics else None + + # Columna L: Nombre Conductor + if invoice.logistics: + if hasattr(invoice.logistics, 'driver_name') and not invoice.logistics.driver_name: + invoice.logistics.driver_name = existing_invoice.logistics.driver_name if existing_invoice.logistics else None + elif hasattr(invoice.logistics, 'driver_name'): + invoice.logistics.driver_name = clean_str(invoice.logistics.driver_name) + + # Columna M: Tipo Transporte + if invoice.logistics: + if hasattr(invoice.logistics, 'transport_type') and not invoice.logistics.transport_type: + invoice.logistics.transport_type = existing_invoice.logistics.transport_type if existing_invoice.logistics else None + elif hasattr(invoice.logistics, 'transport_type'): + invoice.logistics.transport_type = clean_str(invoice.logistics.transport_type) + + # Columna N: Número de Transporte + if invoice.logistics: + if hasattr(invoice.logistics, 'transport_num') and not invoice.logistics.transport_num: + invoice.logistics.transport_num = existing_invoice.logistics.transport_num if existing_invoice.logistics else None + elif hasattr(invoice.logistics, 'transport_num'): + invoice.logistics.transport_num = clean_str(invoice.logistics.transport_num) + + # Columna O: Tipo de Moneda + if invoice.financials: + if not invoice.financials.currency: + invoice.financials.currency = existing_invoice.financials.currency if existing_invoice.financials else None + else: + invoice.financials.currency = clean_str(invoice.financials.currency).lower() + + # Columna P: Clave Moneda + if invoice.financials: + if not invoice.financials.currency_type: + invoice.financials.currency_type = existing_invoice.financials.currency_type if existing_invoice.financials else None + else: + invoice.financials.currency_type = clean_str(invoice.financials.currency_type).upper() + + # Columna Q: Flete + if invoice.financials: + if invoice.financials.freight is None: + invoice.financials.freight = existing_invoice.financials.freight if existing_invoice.financials else None + + # Columna R: Val Seguros + if invoice.financials: + if invoice.financials.insurance_value is None: + invoice.financials.insurance_value = existing_invoice.financials.insurance_value if existing_invoice.financials else None + + # Columna S: Seguros + if invoice.financials: + if invoice.financials.insurance is None: + invoice.financials.insurance = existing_invoice.financials.insurance if existing_invoice.financials else None + + # Columna T: Embalaje + if invoice.financials: + if invoice.financials.packaging is None: + invoice.financials.packaging = existing_invoice.financials.packaging if existing_invoice.financials else None + + # Columna U: Otros Incrementables + if invoice.financials: + if invoice.financials.other_increments is None: + invoice.financials.other_increments = existing_invoice.financials.other_increments if existing_invoice.financials else None + + # Columna V: Incoterms + if invoice.logistics: + if hasattr(invoice.logistics, 'incoterm') and not invoice.logistics.incoterm: + invoice.logistics.incoterm = existing_invoice.logistics.incoterm if existing_invoice.logistics else None + elif hasattr(invoice.logistics, 'incoterm'): + invoice.logistics.incoterm = clean_str(invoice.logistics.incoterm).upper() + + # Columna W: Precinto + if invoice.logistics: + if hasattr(invoice.logistics, 'seal_number') and not invoice.logistics.seal_number: + invoice.logistics.seal_number = existing_invoice.logistics.seal_number if existing_invoice.logistics else None + elif hasattr(invoice.logistics, 'seal_number'): + invoice.logistics.seal_number = clean_str(invoice.logistics.seal_number) + + # Columna X: Fecha de Emisión + if not invoice.emission_date: + invoice.emission_date = existing_invoice.emission_date + + # Columna Y: Tipo de Peso (Opcional) + if invoice.logistics: + if hasattr(invoice.logistics, 'weight_type') and not invoice.logistics.weight_type: + invoice.logistics.weight_type = existing_invoice.logistics.weight_type if existing_invoice.logistics else None + elif hasattr(invoice.logistics, 'weight_type'): + invoice.logistics.weight_type = clean_str(invoice.logistics.weight_type).upper() + + # Columna Z: E-Document (Opcional) + if invoice.compliance_mx: + if not invoice.compliance_mx.edocument: + invoice.compliance_mx.edocument = existing_invoice.compliance_mx.edocument if existing_invoice.compliance_mx else None + else: + invoice.compliance_mx.edocument = clean_str(invoice.compliance_mx.edocument) + + # Columna AA: Num. Operación (Opcional) + if invoice.compliance_mx: + if not invoice.compliance_mx.vucem_operation_num: + invoice.compliance_mx.vucem_operation_num = existing_invoice.compliance_mx.vucem_operation_num if existing_invoice.compliance_mx else None + else: + invoice.compliance_mx.vucem_operation_num = clean_str(invoice.compliance_mx.vucem_operation_num) + + # Columna AB: Aduana (OBLIGATORIO) + if invoice.compliance_mx: + if not invoice.compliance_mx.aduana: + invoice.compliance_mx.aduana = existing_invoice.compliance_mx.aduana if existing_invoice.compliance_mx else None + else: + invoice.compliance_mx.aduana = clean_str(invoice.compliance_mx.aduana) + + # Validar que aduana sea obligatorio (excepto para MEX) + if existing_invoice.invoice_type != "MEX": + current_aduana = invoice.compliance_mx.aduana if invoice.compliance_mx else (existing_invoice.compliance_mx.aduana if existing_invoice.compliance_mx else None) + if not current_aduana: + errors.add_required_error("aduana") + + # Columna AC: Sección de Despacho / Puerto de Entrada (Opcional) + if invoice.compliance_mx: + if not invoice.compliance_mx.port_of_entry: + invoice.compliance_mx.port_of_entry = existing_invoice.compliance_mx.port_of_entry if existing_invoice.compliance_mx else None + else: + invoice.compliance_mx.port_of_entry = clean_str(invoice.compliance_mx.port_of_entry) + + # Columna AD: Observación en Español (Opcional) + if not invoice.observation_es: + invoice.observation_es = existing_invoice.observation_es + else: + invoice.observation_es = clean_str(invoice.observation_es) + + # Columna AD: Observación en Inglés (Opcional) + if not invoice.observation_en: + invoice.observation_en = existing_invoice.observation_en + else: + invoice.observation_en = clean_str(invoice.observation_en) + diff --git a/backend/api/v1/modules/a76/invoices/schemas.py b/backend/api/v1/modules/a76/invoices/schemas.py index 089429bb..5d05e82e 100644 --- a/backend/api/v1/modules/a76/invoices/schemas.py +++ b/backend/api/v1/modules/a76/invoices/schemas.py @@ -210,75 +210,75 @@ class InvoiceFinancialsBase(BaseModel): currency_type: Optional[str] = Field("USD", description="Currency type") exchange_rate: Optional[Decimal] = Field(0.00, description="Exchange rate") exchange_rate_mm: Optional[Decimal] = Field( - None, description="Exchange rate currency to currency" + 0.00, description="Exchange rate currency to currency" ) - value_mn: Optional[Decimal] = Field(None, description="Value in MXN") - value_me: Optional[Decimal] = Field(None, description="Value in foreign currency") - value_mc: Optional[Decimal] = Field(None, description="Value in third currency") + value_mn: Optional[Decimal] = Field(0.00, description="Value in MXN") + value_me: Optional[Decimal] = Field(0.00, description="Value in foreign currency") + value_mc: Optional[Decimal] = Field(0.00, description="Value in third currency") customs_value_mn: Optional[Decimal] = Field( - None, description="Customs value in MXN" + 0.00, description="Customs value in MXN" ) customs_value_me: Optional[Decimal] = Field( - None, description="Customs value in foreign currency" + 0.00, description="Customs value in foreign currency" ) raw_material_value_mn: Optional[Decimal] = Field( - None, description="Raw material value in MXN" + 0.00, description="Raw material value in MXN" ) raw_material_value_me: Optional[Decimal] = Field( - None, description="Raw material value in foreign currency" + 0.00, description="Raw material value in foreign currency" ) aggregate_value_mn: Optional[Decimal] = Field( - None, description="Aggregate value in MXN" + 0.00, description="Aggregate value in MXN" ) aggregate_value_me: Optional[Decimal] = Field( - None, description="Aggregate value in foreign currency" + 0.00, description="Aggregate value in foreign currency" ) aggregate_value_mc: Optional[Decimal] = Field( - None, description="Aggregate value in third currency" + 0.00, description="Aggregate value in third currency" ) mexican_value_mn: Optional[Decimal] = Field( - None, description="Mexican merchandise value in MXN" + 0.00, description="Mexican merchandise value in MXN" ) mexican_value_me: Optional[Decimal] = Field( - None, description="Mexican merchandise value in foreign currency" + 0.00, description="Mexican merchandise value in foreign currency" ) mexican_value_mc: Optional[Decimal] = Field( - None, description="Mexican merchandise value in third currency" + 0.00, description="Mexican merchandise value in third currency" ) national_packaging_mn: Optional[Decimal] = Field( - None, description="National packaging in MXN" + 0.00, description="National packaging in MXN" ) national_packaging_me: Optional[Decimal] = Field( - None, description="National packaging in foreign currency" + 0.00, description="National packaging in foreign currency" ) national_packaging_mc: Optional[Decimal] = Field( - None, description="National packaging in third currency" + 0.00, description="National packaging in third currency" ) - freight: Optional[Decimal] = Field(None, description="Freight cost") - insurance: Optional[Decimal] = Field(None, description="Insurance cost") - insurance_value: Optional[Decimal] = Field(None, description="Insurance value") - packaging: Optional[Decimal] = Field(None, description="Packaging") - other_increments: Optional[Decimal] = Field(None, description="Other increments") - other_deductibles: Optional[Decimal] = Field(None, description="Other deductibles") + freight: Optional[Decimal] = Field(0.00, description="Freight cost") + insurance: Optional[Decimal] = Field(0.00, description="Insurance cost") + insurance_value: Optional[Decimal] = Field(0.00, description="Insurance value") + packaging: Optional[Decimal] = Field(0.00, description="Packaging") + other_increments: Optional[Decimal] = Field(0.00, description="Other increments") + other_deductibles: Optional[Decimal] = Field(0.00, description="Other deductibles") total_increments_mn: Optional[Decimal] = Field( - None, description="Total increments in MXN" + 0.00, description="Total increments in MXN" ) total_increments_me: Optional[Decimal] = Field( - None, description="Total increments in foreign currency" + 0.00, description="Total increments in foreign currency" ) - iva_mn: Optional[Decimal] = Field(None, description="IVA in MXN") - iva_me: Optional[Decimal] = Field(None, description="IVA in foreign currency") - iva_mc: Optional[Decimal] = Field(None, description="IVA in third currency") - iva_factor: Optional[Decimal] = Field(None, description="IVA factor") + iva_mn: Optional[Decimal] = Field(0.00, description="IVA in MXN") + iva_me: Optional[Decimal] = Field(0.00, description="IVA in foreign currency") + iva_mc: Optional[Decimal] = Field(0.00, description="IVA in third currency") + iva_factor: Optional[Decimal] = Field(0.00, description="IVA factor") tax_value_me: Optional[Decimal] = Field( - None, description="Tax value in foreign currency" + 0.00, description="Tax value in foreign currency" ) seal_value_2500: Optional[bool] = Field(None, description="Seal value 2500") - total_quantity: Optional[Decimal] = Field(None, description="Total quantity") - gross_weight: Optional[Decimal] = Field(None, description="Gross weight") - net_weight: Optional[Decimal] = Field(None, description="Net weight") - bundle_count: Optional[int] = Field(None, description="Bundle count") - weight_factor: Optional[Decimal] = Field(None, description="Weight factor") + total_quantity: Optional[Decimal] = Field(0.00, description="Total quantity") + gross_weight: Optional[Decimal] = Field(0.00, description="Gross weight") + net_weight: Optional[Decimal] = Field(0.00, description="Net weight") + bundle_count: Optional[int] = Field(0, description="Bundle count") + weight_factor: Optional[Decimal] = Field(0.00, description="Weight factor") class InvoiceLogisticsBase(BaseModel): @@ -485,7 +485,7 @@ class InvoiceHeaderUpdate(InvoiceHeaderBase): operation_type: Optional[OperationType] = None compliance_mx: Optional[InvoiceComplianceMxUpdate] = None - financials: Optional[InvoiceFinancialsUpdate] = None + financials: Optional[InvoiceFinancialsUpdate] logistics: Optional[InvoiceLogisticsUpdate] = None details: Optional[List[InvoiceSalesDetailsUpdate]] = None collections: Optional[List[InvoiceCollectionsUpdate]] = None diff --git a/backend/api/v1/modules/a76/invoices/services.py b/backend/api/v1/modules/a76/invoices/services.py index 90e6a84a..86db7830 100644 --- a/backend/api/v1/modules/a76/invoices/services.py +++ b/backend/api/v1/modules/a76/invoices/services.py @@ -5,8 +5,10 @@ from sqlalchemy import func from core.exceptions import ErrorCollector, DuplicateResourceException from core.context import get_user_context from .common.mappers import clean_dict -from .imports.temporary.validators.create import validate_create -from .imports.temporary.validators.update import validate_update +from .imports.validators.create import validate_create as validate_create_import +from .imports.validators.update import validate_update as validate_update_import +from .exports.validators.create import validate_create as validate_create_export +from .exports.validators.update import validate_update as validate_update_export from .common.common_validators import invoice_exists from . import models, schemas @@ -124,7 +126,7 @@ class InvoiceService: # Validar si la factura ya existe invoice_exists(db, invoice_data.invoice_number, tenant_id, company_id, errors) - validate_create(db, invoice_data, tenant_id, company_id, errors) + validate_create_import(db, invoice_data, tenant_id, company_id, errors) # Si hay errores, lanzar excepción ANTES de intentar crear errors.raise_if_errors("Error al crear la factura") @@ -273,7 +275,11 @@ class InvoiceService: invoice_data.invoice_number, f"Ya existe otra factura con el número '{invoice_data.invoice_number}'", ) - validate_update(invoice_data, invoice, errors) + + if invoice_data.operation_type == "exp": + validate_update_export(db, invoice_data, invoice, tenant_id, company_id, errors) + else: + validate_update_import(db, invoice_data, invoice, tenant_id, company_id, errors) # Si hay errores, lanzar excepción ANTES de actualizar errors.raise_if_errors("Error al actualizar la factura") diff --git a/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte b/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte index d5578a0c..3fb918c1 100644 --- a/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte +++ b/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte @@ -109,6 +109,10 @@ formData.import_tariff_type = snap.import_tariff_type ?? ''; formData.export_tariff_code = snap.export_tariff_code ?? ''; formData.export_tariff_type = snap.export_tariff_type ?? ''; + + // Reiniciar banderas de error al cargar datos + showErrors = false; + validationErrors = {}; } else { // Reset form when initialData is null (new class) formData.class_code = ''; @@ -128,12 +132,17 @@ formData.import_tariff_type = ''; formData.export_tariff_code = ''; formData.export_tariff_type = ''; + + // Reiniciar banderas de error al limpiar formulario + showErrors = false; + validationErrors = {}; } }); // Estado de validación let validationErrors = $state>({}); let showErrors = $state(false); + let isSubmitting = $state(false); // Estado de diálogos let showMaterialDialog = $state(false); @@ -262,6 +271,37 @@ searchMaterial = ''; } + // Fix 1: Buscar descripción de Tipo de Activo Fijo al perder el foco + async function handleMaterialBlur() { + validateField('material_key'); + const code = formData.material_key?.trim().toUpperCase(); + if (!code) { + formData.material_description = ''; + return; + } + // Primero buscar en la caché local (si ya se cargó el catálogo) + if (materialTypes.length > 0) { + const found = materialTypes.find((m) => m.key.toUpperCase() === code); + if (found) { + formData.material_key = found.key; + formData.material_description = found.description; + return; + } + } + // Si no está en caché, consultar el API + try { + const response = await materialTypesApi.get(code); + if (response.data) { + formData.material_key = response.data.key; + formData.material_description = response.data.description; + } else { + formData.material_description = '(Código no encontrado)'; + } + } catch { + formData.material_description = '(Código no encontrado)'; + } + } + async function openUnitOfMeasureSearch() { showUnitDialog = true; searchUnit = ''; @@ -276,6 +316,39 @@ searchUnit = ''; } + // Fix 1: Buscar descripción de U.M. Comercial al perder el foco + async function handleUnitBlur() { + validateField('unit_of_measure'); + const code = formData.unit_of_measure?.trim().toUpperCase(); + if (!code) { + formData.unit_of_measure_description = ''; + return; + } + // Primero buscar en la caché local (si ya se cargó el catálogo) + await loadUnitsOfMeasure(); + const found = unitsOfMeasureData.find((u) => u.code.toUpperCase() === code); + if (found) { + formData.unit_of_measure = found.code; + formData.unit_of_measure_description = found.description; + formData.unit_measure_key = found.claveMexicana; + } else { + formData.unit_of_measure_description = '(Código no encontrado)'; + } + } + + // Fix 2: Máscara para Fracción Americana (formato 0000.00.00.00 = 13 chars) + function formatUSFraction(event: Event) { + const input = event.target as HTMLInputElement; + // Extraer solo dígitos + const digits = input.value.replace(/\D/g, '').slice(0, 10); + // Construir la máscara insertando puntos en las posiciones correctas + let masked = digits; + if (digits.length > 4) masked = digits.slice(0, 4) + '.' + digits.slice(4); + if (digits.length > 6) masked = digits.slice(0, 4) + '.' + digits.slice(4, 6) + '.' + digits.slice(6); + if (digits.length > 8) masked = digits.slice(0, 4) + '.' + digits.slice(4, 6) + '.' + digits.slice(6, 8) + '.' + digits.slice(8); + formData.us_fraction = masked; + } + async function openUSFractionSearch() { showUSFractionDialog = true; searchUSFraction = ''; @@ -451,7 +524,8 @@ // Validar campo individual (para validación en blur) function validateField(fieldName: string) { - if (!showErrors) return; // Solo validar si ya se intentó guardar + // Bloquear validación repetida si: a) no se ha intentado guardar o b) está guardando + if (!showErrors || isSubmitting) return; const errors = { ...validationErrors }; @@ -496,22 +570,28 @@ validationErrors = errors; } - function handleSave() { - // Activar visualización de errores - showErrors = true; - + async function handleSave() { // Validar formulario if (!validateForm()) { + showErrors = true; toast.error('Por favor, complete todos los campos obligatorios'); return; } + // Activar bandera the submitting para que no se validen inputs al azar en el blur + isSubmitting = true; + // Tomamos una copia muerta de los datos actuales const dataToSave = $state.snapshot(formData); - // Ejecutamos el onSave pasándole la copia - if (onSave) { - onSave(dataToSave); + try { + // Ejecutamos el onSave pasándole la copia. Await en caso de que devuelva promesa. + if (onSave) { + await onSave(dataToSave); + } + } finally { + // Liberar el estado de subida solo después de que acabe todo el flujo + isSubmitting = false; } } @@ -522,9 +602,17 @@ } // Escuchar el evento de guardado del padre + import { onDestroy } from 'svelte'; + if (typeof document !== 'undefined') { document.addEventListener('save-form', handleSave); } + + onDestroy(() => { + if (typeof document !== 'undefined') { + document.removeEventListener('save-form', handleSave); + } + });
@@ -569,7 +657,7 @@ ? 'border-red-500 focus-visible:ring-red-500' : ''}" maxlength={10} - onblur={() => validateField('material_key')} + onblur={handleMaterialBlur} />
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/observations-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/observations-tab-form.svelte index 30be961a..a10d6ef6 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/observations-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/observations-tab-form.svelte @@ -96,7 +96,7 @@ } -
+
@@ -249,7 +249,7 @@ {#if operationType === 1 || invoiceType === 'CR'} -
+
Factura Alterna & Flags -
+
@@ -520,8 +520,8 @@ {#if operationType !== 1 && invoiceType !== 'MEX' && invoiceType !== 'CR'} -
-
+
+
{#if invoiceType !== 'MEX'} -
+
@@ -152,7 +152,7 @@ (formData.is_mixed = v === 'true')} - class="flex gap-4" + class="flex flex-wrap gap-4" >
@@ -248,7 +248,7 @@
-
+
diff --git a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte index 2eb4bbd9..9374f6c0 100644 --- a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte @@ -1002,7 +1002,7 @@ }} />
diff --git a/frontend/src/routes/dashboard/pedimentos/+page.svelte b/frontend/src/routes/dashboard/pedimentos/+page.svelte index cb07499a..d8283960 100644 --- a/frontend/src/routes/dashboard/pedimentos/+page.svelte +++ b/frontend/src/routes/dashboard/pedimentos/+page.svelte @@ -532,10 +532,6 @@
-