diff --git a/backend/api/v1/modules/a76/audit_log/services/service.py b/backend/api/v1/modules/a76/audit_log/services/service.py index 10ca1f55..368e3fed 100644 --- a/backend/api/v1/modules/a76/audit_log/services/service.py +++ b/backend/api/v1/modules/a76/audit_log/services/service.py @@ -103,8 +103,7 @@ class AuditService: ) db.add(log) - db.commit() - db.refresh(log) + db.flush() return log @staticmethod 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 f4c0db4c..fac17226 100644 --- a/backend/api/v1/modules/a76/invoices/common/common_validators.py +++ b/backend/api/v1/modules/a76/invoices/common/common_validators.py @@ -68,6 +68,10 @@ def invoice_exists_by_id( company_id: int, errors: Optional[ErrorCollector], ): + """ + Check if an invoice exists by ID. + WARNING: Adds a DUPLICATE error if found (to be used when creating NEW invoices with specific IDs). + """ invoice = ( db.query(models.InvoiceHeader) .filter( @@ -88,6 +92,41 @@ def invoice_exists_by_id( return invoice return None +def invoice_id_required( + db: Session, + invoice_id: int, + tenant_id: int, + company_id: int, + errors: ErrorCollector, +) -> Optional[models.InvoiceHeader]: + """ + Validates that an invoice exists by ID. + Adds a NOT_FOUND error if it doesn't exist. + """ + if not invoice_id: + errors.add_required_error(field="invoice_id") + return None + + invoice = ( + db.query(models.InvoiceHeader) + .filter( + models.InvoiceHeader.id == invoice_id, + models.InvoiceHeader.tenant_id == tenant_id, + models.InvoiceHeader.company_id == company_id, + ) + .first() + ) + + if not invoice: + errors.add_error( + field="invoice_id", + message=f"La factura con ID '{invoice_id}' no existe.", + solution=["Seleccionar una factura válida."], + code="NOT_FOUND", + value=str(invoice_id), + ) + return invoice + def invoice_processed( db: Session, invoice_id: str, diff --git a/backend/api/v1/modules/a76/invoices/exports/process/main_process.py b/backend/api/v1/modules/a76/invoices/exports/process/main_process.py index 0ea95e59..f7e283e7 100644 --- a/backend/api/v1/modules/a76/invoices/exports/process/main_process.py +++ b/backend/api/v1/modules/a76/invoices/exports/process/main_process.py @@ -1,204 +1,119 @@ - +import logging +logger = logging.getLogger(__name__) +from datetime import datetime +from decimal import Decimal from typing import List from sqlalchemy.orm import Session -from api.v1.modules.a76.invoices.models import InvoiceHeader -from api.v1.modules.a76.items.models import LineItem +from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus +from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate from core.exceptions import ErrorCollector from .pre_validators import pre_validators -from .sub_process.assign_no_discharges import assign_no_discharges_items, assign_no_discharges_series -from .sub_process.review_class import review_class from .sub_process.review_exchange_rate import review_exchange_rate from .sub_process.assign_values import assign_values -from .sub_process.review_exchange_rate import review_exchange_rate -from .sub_process.review_qty_vs_weight import review_qty_vs_weight -from .sub_process.review_unit_cost import review_unit_cost -from .sub_process.review_limits import limit_weight, limit_value -from .sub_process.series.review_qty_series import review_qty_series +from .sub_process.assign_no_discharges import assign_no_discharges_items, assign_no_discharges_series + +# Parameter Service +from api.v1.modules.a76.app_settings.service import AppSettingsService + + +from .sub_process.finalize_invoice import finalize_invoice_no_discharge, finalize_invoice_with_discharge from .sub_process.download_balance_collector import collect_lines_to_discharge -from .sub_process.discharge_types import DownloadEntry -from .sub_process.finalize_invoice import ( - finalize_invoice_no_discharge, - finalize_invoice_with_discharge, -) -from .sub_process.review_origin_procedure import review_origin_procedure from .sub_process.fill_available_balances import fill_available_balances from .sub_process.compare_balances import compare_balances from .sub_process.verify_consolidated import verify_consolidated -from .sub_process.generate_definitive_import import ( - generate_definitive_import, - generate_definitive_import_all_lines, -) -# --------------------------------------------------------------------------- -# Bloque reutilizable: descarga normal (AFIJO / DONAC / SCRAP / REEXP / VEMEX) -# --------------------------------------------------------------------------- - -def _process_with_discharge( - db: Session, - invoice: InvoiceHeader, - lines: List[LineItem], - errors: ErrorCollector, -) -> None: - """ - Secuencia común para los tipos de factura que realizan descarga de saldos: - AFIJO, DONAC, SCRAP, REEXP, VEMEX. - """ - assign_no_discharges_series(db, lines, errors) - review_class(db, lines, errors) - review_exchange_rate(db, invoice, errors) - assign_values(db, invoice, lines, invoice.tenant_id, invoice.company_id, errors) - - review_qty_vs_weight(lines, invoice.logistics.weight_type.upper(), errors) - - review_unit_cost(lines, errors) - total_qty, total_net_weight = limit_weight(lines) - total_value = limit_value(lines) - review_qty_series(db, invoice, lines, invoice.tenant_id, invoice.company_id, errors) - - # QUIERE_DESCARGAR → LLENA_QUEUE_SALDOS → COMPARA_SALDOS - to_discharge = collect_lines_to_discharge(db, invoice, lines, errors) - - fill_available_balances(db, invoice, to_discharge, errors) - compare_balances(db, invoice, to_discharge, errors) - verify_consolidated(db, invoice, to_discharge, errors) - - finalize_invoice_with_discharge(db, invoice, lines, errors, to_discharge) - - -# --------------------------------------------------------------------------- -# Proceso principal -# --------------------------------------------------------------------------- - -def main_process(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id: str) -> dict: - """ - Proceso principal para actualizar facturas de exportación. - - Flujo (porta la rutina principal del legacy SCAII – Facturas de Exportación): - - 1. Validaciones previas (pre_validators) - 2. TODO: Compartir parámetros generales (QSisGen / GEmpresa) - 3. TODO: Compartir parámetros de exportación (QSisExpo) según EsCambioRegimen - 4. TODO: Validar permisos de usuario (GUsuarios / GNivelesSeguridad) - 5. TODO: Iniciar transacción SQL (BEGIN TRAN) - 6. Verificar que existan partidas - 7. TODO: Obtener tipo de cambio según SisGen:CalValBaseTCPedExpo - (TCPED desde la fecha de pago del pedimento, o TCFAC desde la factura) - 8. TODO: Validar que la factura no exista ya en Importaciones Definitivas (si GeneraID='S') - 9. CASE invoice_type → ejecutar sub-proceso específico por tipo: - - NODES : sin descarga - - AFIJO / DONAC / SCRAP : con descarga + lógica de CambioRegimen opcional - - REEXP / VEMEX : con descarga + revisión de procedencia DEF - 10. Si hay errores: rollback implícito (raise) - Si no hay errores: COMMIT y marcar factura como procesada - """ - errors = ErrorCollector() - - # --- Paso 1: Validaciones previas ---------------------------------------- +def pre_process(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id: str, errors: ErrorCollector) -> list: + """Validaciones previas y obtención de partidas.""" lines = pre_validators(db, invoice, tenant_id, company_id, errors) - errors.raise_if_errors() - - # --- Paso 2-4: Parámetros generales, parámetros expo y permisos ---------- - # TODO: Compartir QSisGen / GEmpresa - # TODO: Compartir QSisExpo (EsCambioRegimen = 'S' → SisExp:EsCambioRegimen = 'CR') - # TODO: Validar permisos usuario (GUsuarios / GNivelesSeguridad) - - # --- Paso 5: Iniciar transacción ----------------------------------------- - # TODO: BEGIN TRAN (en el legacy: GSQLFile{PROP:SQL} = 'BEGIN TRAN') - - # --- Paso 6: Verificar que existan partidas ------------------------------ if not lines: - errors.add_error( - field="items", - message="Esta Factura no tiene partidas.", - solution=["Capturar al menos una partida a la factura."], - code="NO_ITEMS_FOUND", - ) - errors.raise_if_errors() + errors.add_error(field="line_items", message="La factura debe contener partidas.", solution=["Agregue partidas."], code="NO_LINE_ITEMS") + return lines - # --- Paso 7: Tipo de cambio ---------------------------------------------- - # TODO: Si SisGen:CalValBaseTCPedExpo = 1: - # invoice.which_exchange_rate = 'TCPED' - # Buscar pedimento (EqiPed:Pedimento = EqiFex:PedimentoExpo) - # Buscar GTipoCambio por EqiPed:Fecha_Pago - # exchange_rate = GenTC:Valor - # Else: - # invoice.which_exchange_rate = 'TCFAC' - # exchange_rate = invoice.financials.exchange_rate - # --- Paso 8: Validar que la factura no exista en ImportDef --------------- - # TODO: Si invoice.generate_id = True: - # Buscar en QFacImpDef por invoice.invoice_number - # Si ya existe → agregar error - - # --- Paso 9: Sub-proceso por tipo de factura ----------------------------- - invoice_type = invoice.invoice_type - - if invoice_type == "NODES": - # Sin descarga de saldos - assign_no_discharges_items(lines, errors) - assign_no_discharges_series(db, lines, errors) - review_class(db, lines, errors) - review_exchange_rate(db, invoice, errors) - assign_values(db, invoice, lines, invoice.tenant_id, invoice.company_id, errors) - - review_qty_vs_weight(lines, invoice.logistics.weight_type.upper(), errors) - - review_unit_cost(lines, errors) - review_qty_series(db, invoice, lines, tenant_id, company_id, errors) - total_qty, total_net_weight = limit_weight(lines) - total_value = limit_value(lines) - - finalize_invoice_no_discharge(db, invoice, lines, errors) - - elif invoice_type == "AFIJO": - if invoice.compliance_mx and invoice.compliance_mx.is_regime_change: - review_origin_procedure(db, invoice, lines, "TEM", errors) - if invoice.generate_id and invoice.generate_desc_parties == "Todas": - def_inv = generate_definitive_import(db, invoice, errors) - if def_inv: - generate_definitive_import_all_lines(db, invoice, def_inv, errors) - - _process_with_discharge(db, invoice, lines, errors) - - elif invoice_type == "DONAC": - _process_with_discharge(db, invoice, lines, errors) - - elif invoice_type == "SCRAP": - if invoice.compliance_mx and invoice.compliance_mx.is_regime_change: - review_origin_procedure(db, invoice, lines, "TEM", errors) - if invoice.generate_id and invoice.generate_desc_parties == "Todas": - def_inv = generate_definitive_import(db, invoice, errors) - if def_inv: - generate_definitive_import_all_lines(db, invoice, def_inv, errors) - - _process_with_discharge(db, invoice, lines, errors) - - elif invoice_type == "REEXP": - review_origin_procedure(db, invoice, lines, "DEF", errors) - _process_with_discharge(db, invoice, lines, errors) - - elif invoice_type == "VEMEX": - review_origin_procedure(db, invoice, lines, "DEF", errors) - _process_with_discharge(db, invoice, lines, errors) - - else: - errors.add_error( - field="invoice_type", - message=f"{invoice_type} no es un Tipo de Factura válido, llamar al proveedor del Sistema SCAII.", - solution=["Verificar el tipo de factura de exportación."], - code="INVALID_INVOICE_TYPE", - value=invoice_type, - ) - - # --- Paso 10: Commit / Rollback ------------------------------------------ +def main_process(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id: str, username: str = "SYSTEM") -> dict: + """Proceso principal para facturas de exportación.""" + errors = ErrorCollector() + lines = pre_process(db, invoice, tenant_id, company_id, errors) errors.raise_if_errors() - # TODO: COMMIT TRAN (en el legacy: gSQLFile{PROP:SQL} = 'COMMIT TRAN') - # TODO: GBitacora('ACTUALIZAR FACTURA', invoice.invoice_number) + settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id)) + q_settings = settings.get("qsisgen", {}) + s_settings = settings.get("ssisgen", {}) - # invoice.status and totals are set inside finalize_invoice_no_discharge / termina_ac_o_lp_normal + cal_val_base_tc = int(q_settings.get("calvalbasetcpedexpo") or s_settings.get("calvalbasetcpedexpo", 0)) + + # El TC ahora se resuelve dentro de assign_values (para per-line) + # o dentro de _assign_invoice_totals (para base-pedimento-global). + # Sin embargo, para mantener compatibilidad con validaciones intermedias, lo dejamos aquí también: + exchange_rate = invoice.financials.exchange_rate if invoice.financials else 0 + which_exchange_rate = "TCFAC" + + if cal_val_base_tc == 1: + if invoice.compliance_mx and invoice.compliance_mx.pedimento: + ped = invoice.compliance_mx.pedimento + if ped.pedimento_dates and ped.pedimento_dates.payment_date: + payment_date = ped.pedimento_dates.payment_date + from sqlalchemy import select + stmt = select(ExchangeRate).where( + ExchangeRate.tenant_id == int(tenant_id), + ExchangeRate.company_id == int(company_id), + ExchangeRate.date == payment_date.date()) + ex_rate_row = db.execute(stmt).scalar_one_or_none() + if ex_rate_row: + exchange_rate = float(ex_rate_row.value) + which_exchange_rate = "TCPED" + + if invoice.compliance_mx: invoice.compliance_mx.which_exchange_rate = which_exchange_rate + if invoice.financials: invoice.financials.exchange_rate = exchange_rate db.flush() + review_exchange_rate(db, invoice, cal_val_base_tc, errors) + errors.raise_if_errors() + + savepoint = db.begin_nested() + try: + invoice_type = (invoice.invoice_type or "").strip().upper() + + # 1. Lógica específica de NODES + if invoice_type == "NODES": + assign_no_discharges_items(lines, errors) + assign_no_discharges_series(db, lines, errors) + + # 2. Asignar valores (costos/pesos) a las partidas + assign_values(db, invoice, lines, tenant_id, company_id, cal_val_base_tc, errors) + errors.raise_if_errors() + + # 3. Finalización (Límites, TC global, Auditoría, Descargas A24) + if invoice_type == "NODES": + finalize_invoice_no_discharge(db, invoice, lines, tenant_id, company_id, errors, username=username) + else: + # Lógica de descarga PEPS + to_discharge = collect_lines_to_discharge(db, invoice, lines, errors) + errors.raise_if_errors() + + if to_discharge: + fill_available_balances(db, invoice, to_discharge, errors) + errors.raise_if_errors() + + compare_balances(db, invoice, to_discharge, errors) + errors.raise_if_errors() + + verify_consolidated(db, invoice, to_discharge, errors) + errors.raise_if_errors() + + finalize_invoice_with_discharge( + db, invoice, lines, tenant_id, company_id, errors, + to_discharge=to_discharge, username=username + ) + + errors.raise_if_errors() + savepoint.commit() + + except Exception as e: + savepoint.rollback() + raise e + + db.flush() return {"status": "success", "invoice_id": str(invoice.id)} diff --git a/backend/api/v1/modules/a76/invoices/exports/process/pre_validators.py b/backend/api/v1/modules/a76/invoices/exports/process/pre_validators.py index 8f1140f0..db1cb160 100644 --- a/backend/api/v1/modules/a76/invoices/exports/process/pre_validators.py +++ b/backend/api/v1/modules/a76/invoices/exports/process/pre_validators.py @@ -4,6 +4,7 @@ from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus from api.v1.modules.a76.items.models import LineItem from api.v1.modules.a76.general_catalogs.fractions.warning_fractions.models import WarningFraction from api.v1.modules.a76.clients_and_providers.models import ClientProvider +from api.v1.modules.a76.app_settings.service import AppSettingsService from core.exceptions import ErrorCollector def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id: str, errors: ErrorCollector): @@ -72,10 +73,24 @@ def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_ elif invoice.financials.currency == "manual" and not invoice.financials.currency_type: errors.add_required_error("financials.currency_type") - #TODO: SSISGEN: Seguridad Ejemplo en: BrowseQFacImp - - # 2.- Existe tipo de cambio para la factura seleccionada - #TODO: SSISGEN: VALIDACION DEL TIPO DE CAMBIO EN BASE A LA FECHA DE PAGO DEL PEDIMENTO. + # Validación de estatus de pedimento + # Validation of Pedimento status (CERRADO / PAGADO) + settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id)) + expo_params = settings.get("ssisexpo", {}) + # Resolver validarestatusped (podría estar en ssisexpo o qsisgen según el tipo de factura) + valida_estatus = int(expo_params.get("validarestatusped", 0)) + + if valida_estatus == 1: + if invoice.compliance_mx and invoice.compliance_mx.pedimento: + ped = invoice.compliance_mx.pedimento + if ped.pedimento_dates and ped.pedimento_dates.payment_date: + errors.add_error( + field="compliance_mx.pedimento", + message=f"No se puede procesar la factura porque el pedimento {ped.pedimento_number} ya se encuentra pagado el {ped.pedimento_dates.payment_date.date()}.", + solution=["Desactive el parámetro 'validarestatusped' o rectifique el pedimento si requiere cambios."], + code="PEDIMENTO_ALREADY_PAID" + ) + errors.raise_if_errors() # 3.- Validacion que deber de existir un pedimento cuando es requerido if not invoice.compliance_mx.is_pedimento_pending and not invoice.compliance_mx.pedimento_id: diff --git a/backend/api/v1/modules/a76/invoices/exports/process/routes.py b/backend/api/v1/modules/a76/invoices/exports/process/routes.py index 2bd193bd..4014ae1e 100644 --- a/backend/api/v1/modules/a76/invoices/exports/process/routes.py +++ b/backend/api/v1/modules/a76/invoices/exports/process/routes.py @@ -26,16 +26,18 @@ def trigger_invoice_process( """ tenant_id = validate_access_to_resource(db, company_id, current_user) + username = current_user.get("preferred_username") or current_user.get("email") or current_user.get("sub") or "SYSTEM" + task = track_and_dispatch( db=db, task=process_export_invoice_task, tenant_id=tenant_id, company_id=company_id, - requested_by_user=current_user.get("preferred_username") or current_user.get("email") or current_user.get("sub"), + requested_by_user=username, task_name="process_export_invoice_task", task_group="invoices", task_origin="a76/invoices/exports/process", - args=[invoice_id, str(tenant_id), str(company_id)], + args=[invoice_id, str(tenant_id), str(company_id), username], ) return {"task_id": task.id} diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/assign_values.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/assign_values.py index 2dc6b770..aa401166 100644 --- a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/assign_values.py +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/assign_values.py @@ -3,7 +3,7 @@ ASIGNA_VALORES_PARTIDAS_ASIGNA_PESOS Resets and recalculates unit costs, export values (KGS ↔ LBS) for every line item of an export invoice. -Two cost-assignment strategies (controlled by SisExp:ValFactTC — TODO): +Two cost-assignment strategies (controlled by SisExp:ValFactTC): TCE → bulk SQL UPDATE using the invoice-level exchange rate (Loc:TipoCambio). else → per-line loop that resolves each line's exchange rate from its source import invoice (TEM → QFacImp, DEF → QFacImpDef). @@ -140,12 +140,12 @@ def _get_source_invoice_tc( Legacy fields: EqiPex:TipoMovImpo → line.customs.origin_procedure - EqiPex:FacturaImpo → line.reference.import_invoice (TODO: confirm field) + EqiPex:FacturaImpo → line.fa_data.search_invoice """ fallback_tc = Decimal(str(invoice.financials.exchange_rate or 0)) movement_type = (line.customs.origin_procedure or "").strip().upper() if line.customs else "" - import_invoice_number = (line.reference.import_invoice if line.reference else None) or "" + import_invoice_number = (line.fa_data.search_invoice if getattr(line, "fa_data", None) else None) or "" if not import_invoice_number: return fallback_tc @@ -200,6 +200,7 @@ def assign_values( lines: List[LineItem], tenant_id: str, company_id: str, + cal_val_base_tc: int, errors: ErrorCollector, ) -> None: """ @@ -216,11 +217,29 @@ def assign_values( # --- Step 1: Assign costs / values --------------------------------------- - # TODO: Read SisExp:ValFactTC from the export system parameters model. - # When ValFactTC = 'TCE' use _assign_costs_tce (single TC for all lines). - # Otherwise use _assign_costs_per_line (TC from each source import invoice). - # For now the per-line strategy is always used as the safe default. - val_fact_tc = "PER_LINE" # TODO: replace with SisExp.val_fact_tc + # Resolve Settings for Strategy + from api.v1.modules.a76.app_settings.service import AppSettingsService + settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id)) + + inv_type = (invoice.invoice_type or "").strip().upper() + op_type = "exp" + + # Hierarchical resolve: invoices.types.exp.{type}.ssisgen.ssimpFormData + inv_map = settings.get("invoices", {}).get("types", {}).get(op_type, {}).get(inv_type, {}) + form_data = inv_map.get("ssisgen", {}).get("ssimpFormData", {}) or inv_map.get("qsisgen", {}).get("ssimpFormData", {}) or settings.get("ssisexpo", {}) + + # Parameter ValFactTC: TCE (Encabezado) vs PER_LINE (Partida) + val_fact_tc = (form_data.get("valfacttc") or form_data.get("ValFactTC") or "").strip().upper() + + # Fallback/Auto-resolve: + # Si la configuración indica usar el TC del Pedimento (cal_val_base_tc = 1), + # usualmente forzamos TCE porque el encabezado ya fue nivelado al pago del pedimento. + if cal_val_base_tc == 1: + val_fact_tc = "TCE" + + # Default if empty + if not val_fact_tc: + val_fact_tc = "PER_LINE" if val_fact_tc == "TCE": _assign_costs_tce(lines, currency, tc, tc_mm) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/fill_available_balances.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/fill_available_balances.py index eec6f686..32d33a0b 100644 --- a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/fill_available_balances.py +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/fill_available_balances.py @@ -152,15 +152,9 @@ def fill_available_balances( ) -> None: """ LLENA_QUEUE_SALDOS_DISPONIBLES_FACTURA - Validates each discharge entry and populates ``entry.available_lots`` - with the net balance available from the PEPS ledger. - - Parameters - ---------- - db : active SQLAlchemy session - export_invoice : the export invoice being processed - to_discharge : list of DownloadEntry objects (QueADescargar) - errors : shared error collector + Refactorizada para PEPS Flexible: + Busca automáticamente todos los lotes con saldo disponible para el número de parte + de cada partida a descargar. """ export_date: datetime.date = ( export_invoice.invoice_date.date() @@ -168,109 +162,60 @@ def fill_available_balances( else export_invoice.invoice_date ) - # Sort mirrors Clarion: Sort(QueADescargar, FacturaImpo, LineaImpo) - sorted_entries = sorted( - to_discharge, - key=lambda e: (e.import_invoice, e.import_line), - ) - - # Track already-resolved (invoice, line) pairs to skip duplicates - seen: set = set() - - for entry in sorted_entries: - key = (entry.import_invoice, entry.import_line) - if key in seen: - continue - seen.add(key) - - if not entry.import_invoice or entry.import_line == 0: + for entry in to_discharge: + # Recuperar la partida de exportación original para obtener IDs precisos + export_line = db.get(LineItem, entry.line_item_id) + if not export_line: continue - # ── 1. Validate import invoice ──────────────────────────────────────── - import_invoice = _fetch_import_invoice(db, entry.import_invoice, export_invoice) - - if import_invoice is None: - errors.add_error( - field=f"line[{entry.export_line}].import_invoice", - message=f"La Factura de Importación: '{entry.import_invoice}' no existe.", - solution=["Seleccionar otra factura de Importación."], - code="IMPORT_INVOICE_NOT_FOUND", - value=entry.import_invoice, + part_id = export_line.part_number_id + class_id = export_line.class_id + + # 1. Buscar todos los lotes candidatos (Importaciones del mismo número de parte y clase) + # Solo facturas procesadas y con fecha <= export_date + candidates = ( + db.query(LineItem) + .join(InvoiceHeader, InvoiceHeader.id == LineItem.invoice_id) + .filter( + LineItem.tenant_id == export_invoice.tenant_id, + LineItem.company_id == export_invoice.company_id, + LineItem.part_number_id == part_id, + LineItem.class_id == class_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.status != InvoiceStatus.PENDING, + InvoiceHeader.invoice_date <= export_date ) - continue - - # Status 'NA' == not processed (Clarion: Estatus = 'NA') - if import_invoice.status == InvoiceStatus.PENDING: - errors.add_error( - field=f"line[{entry.export_line}].import_invoice", - message=f"La Factura de Importación: '{entry.import_invoice}' está Desactualizada.", - solution=["Actualizar la factura de Importación."], - code="IMPORT_INVOICE_UNPROCESSED", - value=entry.import_invoice, - ) - continue - - # Import date must not be later than export date - imp_date: datetime.date = ( - import_invoice.invoice_date.date() - if hasattr(import_invoice.invoice_date, "date") - else import_invoice.invoice_date - ) - if imp_date > export_date: - errors.add_error( - field=f"line[{entry.export_line}].import_invoice", - message=( - f"La Factura de Importación: '{entry.import_invoice}' tiene una Fecha Mayor " - f"a la Fecha de Descarga." - ), - solution=[ - f"Seleccionar otra factura de Importación con Fecha Anterior al " - f"{export_date.strftime('%d/%m/%Y')}." - ], - code="IMPORT_INVOICE_DATE_AFTER_EXPORT", - value={"import_date": str(imp_date), "export_date": str(export_date)}, - ) - continue - - # ── 2. Validate import line ─────────────────────────────────────────── - import_line = _fetch_import_line( - db, - import_invoice.id, - entry.import_line, - export_invoice.tenant_id, - export_invoice.company_id, + .all() ) - if import_line is None: + # 2. Calcular saldos para cada candidato y llenar available_lots + found_any_balance = False + for imp_line in candidates: + available = _net_balance_for_lot(db, imp_line.id, export_date) + + if available > 0: + found_any_balance = True + fin = imp_line.financial + lot = AvailableLot( + import_item_line_id=imp_line.id, + import_invoice_id=imp_line.invoice_id, + part_number_id=imp_line.part_number_id, + available_qty=available, + value_me=Decimal(str(fin.value_usd or 0)) if fin else None, + value_mn=Decimal(str(fin.value_mxn or 0)) if fin else None, + order_peps=_peps_order_for_lot(db, imp_line.id), + ) + entry.available_lots.append(lot) + + # 3. Ordenar por PEPS (FIFO) + entry.available_lots.sort(key=lambda x: x.order_peps) + + # 4. Manejo de Errores: Solo si no se encontró ABSOLUTAMENTE NADA de saldo + if not found_any_balance: errors.add_error( - field=f"line[{entry.export_line}].import_line", - message=( - f"La Factura de Importación: '{entry.import_invoice}' " - f"con Línea: {entry.import_line} no existe." - ), - solution=["Seleccionar otra Línea de Importación a Descargar."], - code="IMPORT_LINE_NOT_FOUND", - value={"import_invoice": entry.import_invoice, "import_line": entry.import_line}, + field=f"line[{entry.export_line}].quantity", + message=f"No se encontró saldo disponible en inventario para el número de parte: {entry.part_number}", + solution=["Verificar que existan facturas de importación procesadas con saldo."], + code="NO_BALANCE_FOUND_ANYWHERE", + value=entry.part_number, ) - continue - - # ── 3. Compute net available balance from ledger (as of export date) ─── - # Equivalent to: CantImpo - CantRetornadaTemp - CantRetornada (general) - # then CALCULA_SALDO_FECHA_EXPO (only exits on or before export_date). - available = _net_balance_for_lot(db, import_line.id, export_date) - if available <= 0: - # No balance — skip this lot (equivalent to Clarion CYCLE) - continue - - # ── 4. Build AvailableLot and attach to entry ───────────────────────── - fin = import_line.financial - lot = AvailableLot( - import_item_line_id=import_line.id, - import_invoice_id=import_invoice.id, - part_number_id=import_line.part_number_id, - available_qty=available, - value_me=Decimal(str(fin.value_usd or 0)) if fin else None, - value_mn=Decimal(str(fin.value_mxn or 0)) if fin else None, - order_peps=_peps_order_for_lot(db, import_line.id), - ) - entry.available_lots.append(lot) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/finalize_invoice.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/finalize_invoice.py index 7ea735dc..36e8b308 100644 --- a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/finalize_invoice.py +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/finalize_invoice.py @@ -3,14 +3,14 @@ finalize_invoice_no_discharge / finalize_invoice_with_discharge (TERMINA_AC_O_LP_NODES / TERMINA_AC_O_LP_NORMAL) Last step of export invoice processing. Both variants: - 1. TODO: DO REVISACLASESHABILITADAS + 1. Review enabled classes (REVISACLASESHABILITADAS). 2. Validate SisExp quantity / weight / value limits (min and max). 3. If no errors: assign invoice-level totals and mark as PROCESSED. The "with_discharge" variant additionally: - 4. DO GENERAIMPODEFINITIVA (if is_regime_change and generate_id) - 5. DO REGISTRA_DESCARGA_IMPORTACION (update returned qty/value on import lines) - 6. DO REGISTRA_DESCARGA_SERIES (flag import series as exported) + 4. Generate definitive import (GENERAIMPODEFINITIVA) (if is_regime_change and generate_id) + 5. Register discharge in import lines (REGISTRA_DESCARGA_IMPORTACION) + 6. Register series discharge (REGISTRA_DESCARGA_SERIES) The legacy 'Of LP' branch (print-preview / progress-bar UI) is not ported. """ @@ -33,7 +33,7 @@ from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus from api.v1.modules.a76.items.models import LineItem from core.exceptions import ErrorCollector from .review_limits import limit_weight, limit_value -from .generate_definitive_import import generate_definitive_import +from .generate_definitive_import import generate_definitive_import, is_regime_change_detected # --------------------------------------------------------------------------- @@ -69,118 +69,38 @@ def _review_enabled_classes( ) -# --------------------------------------------------------------------------- -# SisExp limit checks (shared by both public functions) -# --------------------------------------------------------------------------- - -def _validate_sisexp_limits( - invoice: InvoiceHeader, - total_qty: Decimal, - total_net_weight: Decimal, - total_value: Decimal, - errors: ErrorCollector, -) -> None: - """ - Validates invoice totals against the SisExp min/max limit parameters. - - TODO: Read actual SisExp parameters from the tenant system-config model. - Until then all limits default to 0 (= disabled) so no checks fire. - - Clarion names → Python (TODO): - SisExp:CantLimiteMin / SisExp:CantLimite → qty min / max - SisExp:PesoLimiteMin / SisExp:PesoLimite → weight min / max - SisExp:ValorLimiteMin / SisExp:ValorLimite → value min / max - """ - # TODO: load from SisExp tenant config - cant_limite_min: Decimal = Decimal(0) - cant_limite: Decimal = Decimal(0) - peso_limite_min: Decimal = Decimal(0) - peso_limite: Decimal = Decimal(0) - valor_limite_min: Decimal = Decimal(0) - valor_limite: Decimal = Decimal(0) - - solution = ["Consulte a su Administrador de sistema para parametrizar la factura."] - code = "PAR.EXPO" - - if cant_limite_min != 0 and cant_limite_min > total_qty: - errors.add_error( - field="invoice.total_quantity", - message=( - f"La cantidad total de la factura: {total_qty} " - f"no supera a la cantidad mínima parametrizada: {cant_limite_min}." - ), - solution=solution, code=code, - ) - if cant_limite != 0 and cant_limite < total_qty: - errors.add_error( - field="invoice.total_quantity", - message=( - f"La cantidad total de la factura: {total_qty} " - f"excede a la cantidad máxima parametrizada: {cant_limite}." - ), - solution=solution, code=code, - ) - if peso_limite_min != 0 and peso_limite_min > total_net_weight: - errors.add_error( - field="invoice.net_weight", - message=( - f"El Peso Neto total de la factura: {total_net_weight} " - f"no supera el Peso mínimo parametrizado: {peso_limite_min}." - ), - solution=solution, code=code, - ) - if peso_limite != 0 and peso_limite < total_net_weight: - errors.add_error( - field="invoice.net_weight", - message=( - f"El Peso Neto total de la factura: {total_net_weight} " - f"excede el Peso máximo parametrizado: {peso_limite}." - ), - solution=solution, code=code, - ) - if valor_limite_min != 0 and valor_limite_min > total_value: - errors.add_error( - field="invoice.total_value", - message=( - f"El Valor total de la factura: {total_value} " - f"no supera el Valor mínimo parametrizado: {valor_limite_min}." - ), - solution=solution, code=code, - ) - if valor_limite != 0 and valor_limite < total_value: - errors.add_error( - field="invoice.total_value", - message=( - f"El Valor total de la factura: {total_value} " - f"excede el Valor máximo parametrizado: {valor_limite}." - ), - solution=solution, code=code, - ) - - # --------------------------------------------------------------------------- # DO ASIGNA_VALORES_FACTURA # --------------------------------------------------------------------------- +from api.v1.modules.a76.app_settings.service import AppSettingsService +from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate +from sqlalchemy import select +from .review_limits import review_sisexpo_limits def _assign_invoice_totals( + db: Session, invoice: InvoiceHeader, lines: List[LineItem], + tenant_id: str, + company_id: str, + username: str = "SYSTEM" ) -> None: """ DO ASIGNA_VALORES_FACTURA - Aggregates line-level values (MN, ME, qty, packages, net/gross weight) - and writes the totals to the invoice header, then marks it as PROCESSED. - - Clarion equivalent: - SELECT SUM(ValorExpoMN), SUM(ValorExpoME), SUM(CantExpo), - SUM(CantBultos), SUM(PesoNeto), SUM(PesoBruto) - FROM QEqeMaq WHERE Consecutivo = - - TODO: SisGen:CalValBaseTCPedExpo = 1 → invoice.financials.exchange_rate = Loc:TipoCambio - TODO: SisGen:CalValBaseTCPedExpo = 1 → - invoice.process_log = 'Se Actualizó con el Tipo de Cambio de la Fecha de Pago de Pedimento.' - TODO: SisGen:ActSeguridad = 1 → invoice.updated_by = current_user + Calcula totales, resuelve TC base pedimento si aplica, y marca como PROCESADO. """ + settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id)) + + inv_type = (invoice.invoice_type or "").strip().upper() + op_type = (invoice.operation_type or "").strip().lower() # 'imp' or 'exp' + + # Hierarchical resolve: invoices.types.{op}.{type}.ssisgen.ssimpFormData + inv_map = settings.get("invoices", {}).get("types", {}).get(op_type, {}).get(inv_type, {}) + form_data = inv_map.get("ssisgen", {}).get("ssimpFormData", {}) or inv_map.get("qsisgen", {}).get("ssimpFormData", {}) or settings.get("ssisgen", {}) or settings.get("qsisgen", {}) + + cal_val_base_tc = int(form_data.get("calvalbasetcpedexpo") or form_data.get("CalValBaseTCPedExpo") or 0) + act_seguridad = int(form_data.get("actseguridad") or form_data.get("ActSeguridad") or 0) + total_value_mn = Decimal(0) total_value_me = Decimal(0) total_qty = Decimal(0) @@ -198,6 +118,23 @@ def _assign_invoice_totals( total_net_weight += line.quantity.net_weight or Decimal(0) total_gross_weight += line.quantity.gross_weight or Decimal(0) + # 1. Resolver TC base pedimento si aplica + if cal_val_base_tc == 1: + if invoice.compliance_mx and invoice.compliance_mx.pedimento: + ped = invoice.compliance_mx.pedimento + if ped.pedimento_dates and ped.pedimento_dates.payment_date: + payment_date = ped.pedimento_dates.payment_date + stmt = select(ExchangeRate).where( + ExchangeRate.tenant_id == int(tenant_id), + ExchangeRate.company_id == int(company_id), + ExchangeRate.date == payment_date.date()) + ex_rate_row = db.execute(stmt).scalar_one_or_none() + if ex_rate_row and invoice.financials: + invoice.financials.exchange_rate = float(ex_rate_row.value) + invoice.compliance_mx.which_exchange_rate = "TCPED" + invoice.process_log = "Se Actualizó con el Tipo de Cambio de la Fecha de Pago de Pedimento." + + # 2. Asignar totales al header if invoice.financials is not None: invoice.financials.value_mn = float(total_value_mn) invoice.financials.value_me = float(total_value_me) @@ -210,6 +147,16 @@ def _assign_invoice_totals( invoice.updated_date = datetime.date.today() invoice.status = InvoiceStatus.PROCESSED + # 3. Auditoría (ActSeguridad) + if act_seguridad == 1: + invoice.updated_by = username + from api.v1.modules.a76.audit_log.services.service import AuditService + AuditService.create_audit_log( + db=db, reference=invoice.invoice_number, procedure="ACTUALIZAR FACTURA", movement="EXPORTACION", + username=username, tenant_id=int(tenant_id), company_id=int(company_id), + table_name="invoice_header", record_id=str(invoice.id), operation_type="UPDATE" + ) + # --------------------------------------------------------------------------- # Public entry points @@ -219,60 +166,63 @@ def finalize_invoice_no_discharge( db: Session, invoice: InvoiceHeader, lines: List[LineItem], + tenant_id: str, + company_id: str, errors: ErrorCollector, + username: str = "SYSTEM", ) -> None: """ TERMINA_AC_O_LP_NODES Finalizes a NODES-type export invoice (no inventory discharge). - - Flow: - 1. Verify all line classes are active (REVISACLASESHABILITADAS). - 2. Validate SisExp limits (qty / weight / value). - 3. If no errors: write invoice totals and set status = PROCESSED. """ _review_enabled_classes(db, invoice, lines, errors) total_qty, total_net_weight = limit_weight(lines) total_value = limit_value(lines) - _validate_sisexp_limits(invoice, total_qty, total_net_weight, total_value, errors) + settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id)) + review_sisexpo_limits(invoice, settings, total_qty, total_net_weight, total_value, errors) if not errors.has_errors(): - _assign_invoice_totals(invoice, lines) + _assign_invoice_totals(db, invoice, lines, tenant_id, company_id, username) def finalize_invoice_with_discharge( db: Session, invoice: InvoiceHeader, lines: List[LineItem], + tenant_id: str, + company_id: str, errors: ErrorCollector, to_discharge: List["DownloadEntry"] | None = None, + username: str = "SYSTEM", ) -> None: - """ - TERMINA_AC_O_LP_NORMAL - Finalizes a discharge-type export invoice (AFIJO / DONAC / SCRAP / REEXP / VEMEX). - - Flow: - 1. Verify all line classes are active (REVISACLASESHABILITADAS). - 2. Validate SisExp limits (qty / weight / value). - 3. If no errors: - a. DO GENERAIMPODEFINITIVA (only if is_regime_change and generate_id) - b. TODO: DO REGISTRA_DESCARGA_IMPORTACION (write a24 discharge movements) - c. TODO: DO REGISTRA_DESCARGA_SERIES (write series discharge records) - d. Write invoice totals and set status = PROCESSED. - - Note: the legacy 'Of LP' branch (print-preview UI) is not ported. - """ + # Refrescar factura para asegurar info de compliance/pedimentos fresca + db.refresh(invoice) _review_enabled_classes(db, invoice, lines, errors) total_qty, total_net_weight = limit_weight(lines) total_value = limit_value(lines) - _validate_sisexp_limits(invoice, total_qty, total_net_weight, total_value, errors) + settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id)) + review_sisexpo_limits(invoice, settings, total_qty, total_net_weight, total_value, errors) if not errors.has_errors(): - if invoice.compliance_mx and invoice.compliance_mx.is_regime_change and invoice.generate_id: - generate_definitive_import(db, invoice, errors) + # Unified detection of Regime Change (F4, CR type, or Manual Flag) + if is_regime_change_detected(invoice, settings): + def_invoice, created = generate_definitive_import(db, invoice, to_discharge or [], settings, errors) + if created and not errors.has_errors(): + from .generate_definitive_import import ( + generate_definitive_import_all_lines, + generate_definitive_import_discharged_lines + ) + # Choice based on generate_desc_parties + if (invoice.generate_desc_parties or "").strip() == 'Todas': + generate_definitive_import_all_lines(db, invoice, def_invoice, errors) + else: + generate_definitive_import_discharged_lines( + db, invoice, def_invoice, to_discharge or [], errors + ) if to_discharge: # Write BalanceMovement (CONSUMPTION) + DischargeHeader + DischargeDetail @@ -281,4 +231,4 @@ def finalize_invoice_with_discharge( register_import_discharge(db, invoice, to_discharge) register_discharge_series(db, invoice, to_discharge) - _assign_invoice_totals(invoice, lines) + _assign_invoice_totals(db, invoice, lines, tenant_id, company_id, username) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/generate_definitive_import.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/generate_definitive_import.py index 221a7e86..b7031c98 100644 --- a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/generate_definitive_import.py +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/generate_definitive_import.py @@ -24,6 +24,8 @@ GENERAIMPODEFINITIVA Routine END """ +from decimal import Decimal +from typing import List from sqlalchemy import select from sqlalchemy.orm import Session @@ -89,27 +91,17 @@ def _create_definitive_import_header( tenant_id=exp.tenant_id, company_id=exp.company_id, system=exp.system, - operation_type=OperationType.IMPORT, - invoice_type="IMD", + operation_type=OperationType.IMP, + invoice_type="DEF", invoice_number=exp.invoice_number, invoice_date=exp.invoice_date, - updated_date=exp.invoice_date, party_count=exp.party_count, generate_id=False, status=InvoiceStatus.PENDING, - # Clients / providers - provider_id=exp.provider_id, - sold_to_header="Vendido a:", - sold_to_id=exp.sold_to_id, - shipped_to_header="Enviado a:", - shipped_to_id=exp.shipped_to_id, - customs_broker_id=exp.customs_broker_id, - customs_broker_us_id=exp.customs_broker_us_id, - - # Notes - notes=exp.notes, - notes_english=exp.notes_english, + # Comments (Mapped from original OBSERVACIONE/I) + observation_es=exp.observation_es, + observation_en=exp.observation_en, ) db.add(def_invoice) db.flush() # get def_invoice.id before creating child records @@ -134,8 +126,8 @@ def _create_definitive_import_header( # ── Compliance / pedimento ──────────────────────────────────────────── if exp_comp is not None: - from api.v1.modules.a76.invoices.models import InvoiceComplianceMX - def_comp = InvoiceComplianceMX( + from api.v1.modules.a76.invoices.models import InvoiceComplianceMx + def_comp = InvoiceComplianceMx( tenant_id=exp.tenant_id, company_id=exp.company_id, invoice_id=def_invoice.id, @@ -143,6 +135,15 @@ def _create_definitive_import_header( remesa=exp_comp.remesa, pedimento_id=exp_comp.pedimento_id, is_pedimento_pending=exp_comp.is_pedimento_pending, + # Reubicados aquí (Correcto en el esquema unificado) + provider_id=exp_comp.provider_id, + sold_to_id=exp_comp.sold_to_id, + shipped_to_id=exp_comp.shipped_to_id, + customs_broker_id=exp_comp.customs_broker_id, + customs_broker_us_id=exp_comp.customs_broker_us_id, + provider_header=exp_comp.provider_header, + sold_to_header=exp_comp.sold_to_header, + shipped_to_header=exp_comp.shipped_to_header, ) db.add(def_comp) @@ -166,11 +167,78 @@ def _create_definitive_import_header( # Public entry point # --------------------------------------------------------------------------- +def is_regime_change_detected(invoice: InvoiceHeader, settings: any = None) -> bool: + """ + Detecta si la factura debe activar la generación de Importación Definitiva (DEF). + Usa el parámetro 'incambioregdesc' como interruptor maestro. + """ + # 0. Interruptor Maestro (Configuración Global) + # Soporta Dict de JSON o Objetos Pydantic/SQLA + if settings: + # Búsqueda exhaustiva y recursiva del parámetro maestro + def find_in_obj(obj, target_key): + if isinstance(obj, dict): + for k, v in obj.items(): + if k.lower() == target_key.lower(): + return v + res = find_in_obj(v, target_key) + if res is not None: return res + elif hasattr(obj, '__dict__'): + # Soporte para objetos Pydantic/SQLAlchemy + dict_rep = obj.__dict__ if hasattr(obj, '__dict__') else {} + if hasattr(obj, 'dict') and callable(getattr(obj, 'dict')): + try: dict_rep = obj.dict() + except: pass + elif hasattr(obj, 'model_dump') and callable(getattr(obj, 'model_dump')): + try: dict_rep = obj.model_dump() + except: pass + + for k, v in dict_rep.items(): + # Ignorar propiedades privadas de SQLAlchemy + if k.startswith('_'): continue + if k.lower() == target_key.lower(): + return v + res = find_in_obj(v, target_key) + if res is not None: return res + return None + + # Buscamos el valor en todo el árbol de configuración + val = find_in_obj(settings, 'incambioregdesc') + + # LOG DE EMERGENCIA en consola para debugging + print(f"[REGIME_CHANGE_DETECT] Factura: {invoice.invoice_number}, Valor de incambioregdesc: {val}") + + # Comprobación segura (1, '1', True, 'true') + master_on = str(val).strip().lower() in ['1', 'true'] + + if not master_on: + return False + + # 1. Export + Pedimento F4 (Art. 114) + op_type = str(invoice.operation_type or "").lower() + if op_type == "exp": + if invoice.compliance_mx: + ped = invoice.compliance_mx.pedimento + if ped and (ped.pedimento_code or "").strip().upper() == "F4": + return True + + # 2. Tipo de Factura 'CR' o Flag Manual + if (invoice.invoice_type or "").strip().upper() == "CR": + return True + + if invoice.compliance_mx and invoice.compliance_mx.is_regime_change: + return True + + return False + + def generate_definitive_import( db: Session, invoice: InvoiceHeader, + to_discharge: List[DownloadEntry], + settings: any, errors: ErrorCollector, -) -> InvoiceHeader | None: +) -> tuple[InvoiceHeader, bool]: """ GENERAIMPODEFINITIVA Creates a definitive import invoice header from ``invoice`` (export) when @@ -200,22 +268,16 @@ def generate_definitive_import( InvoiceHeader.tenant_id == invoice.tenant_id, InvoiceHeader.company_id == invoice.company_id, InvoiceHeader.invoice_number == invoice.invoice_number, - InvoiceHeader.invoice_type == "IMD", + InvoiceHeader.invoice_type == "DEF", ) ).scalar_one_or_none() if existing is not None: - def_invoice = existing - else: - # ── 2. Create the definitiva header ────────────────────────────────── - def_invoice = _create_definitive_import_header(db, invoice) + return existing, False - # ── 3. Generate lines ──────────────────────────────────────────────────── - # to_discharge / all_lines must be passed by the caller after this returns. - # See generate_definitive_import_all_lines() and - # generate_definitive_import_discharged_lines() below. - - return def_invoice + # ── 2. Create the definitiva header ────────────────────────────────── + def_invoice = _create_definitive_import_header(db, invoice) + return def_invoice, True # --------------------------------------------------------------------------- @@ -229,20 +291,27 @@ def _copy_line_to_definitive( export_line: LineItem, def_invoice: InvoiceHeader, def_line_number: int, + custom_qty: Decimal | None = None, ) -> None: """ Copies a single export LineItem (and its series) into a new definitive import LineItem under ``def_invoice``. - Clarion fixed values: - EsSubPartida = 'P' → is_subitem = False - ContieneSubP = 'N' → contains_subitems = False - SubPartida = 0 → subitem_number = 0 - EsReparacion = 0 → (no repair flag needed) + If ``custom_qty`` is provided, the function scales weights and values + proportionally (Regime Change with Discharge logic). """ from api.v1.modules.a76.items.line_financials.models import LineFinancial from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem + # Calculate proportionality ratio if custom_qty is provided + original_qty = export_line.quantity.quantity if export_line.quantity else Decimal(1) + if original_qty == 0: + original_qty = Decimal(1) + + ratio = Decimal(1) + if custom_qty is not None: + ratio = custom_qty / original_qty + def_line = LineItem( tenant_id=def_invoice.tenant_id, company_id=def_invoice.company_id, @@ -257,19 +326,36 @@ def _copy_line_to_definitive( if export_line.quantity: src_q = export_line.quantity + qty_val = custom_qty if custom_qty is not None else src_q.quantity + db.add(LineQuantity( item_line_id=def_line.id, - quantity=src_q.quantity, - net_weight=src_q.net_weight, - gross_weight=src_q.gross_weight, - package_quantity=src_q.package_quantity, + quantity=qty_val, + net_weight=(src_q.net_weight * ratio) if src_q.net_weight is not None else None, + gross_weight=(src_q.gross_weight * ratio) if src_q.gross_weight is not None else None, + package_quantity=int(src_q.package_quantity * ratio) if src_q.package_quantity is not None else None, package_id=src_q.package_id, )) if export_line.financial: + src_f = export_line.financial + + # Helper to scale optional Decimal fields + def scale(val: Decimal | None) -> Decimal | None: + return (val * ratio) if val is not None else None + db.add(LineFinancial( item_line_id=def_line.id, - unit_cost_capture=export_line.financial.unit_cost_capture, + unit_cost_capture=src_f.unit_cost_capture, + unit_cost_usd=src_f.unit_cost_usd, + unit_cost_mxn=src_f.unit_cost_mxn, + # Scale total values + value_mxn=scale(src_f.value_mxn), + value_usd=scale(src_f.value_usd), + customs_value_mxn=scale(src_f.customs_value_mxn), + customs_value_usd=scale(src_f.customs_value_usd), + value_added_mxn=scale(src_f.value_added_mxn), + value_added_usd=scale(src_f.value_added_usd), )) if export_line.customs: @@ -297,7 +383,9 @@ def _copy_line_to_definitive( )) db.add(FaLineItem( - item_line_id=def_line.id, + id=def_line.id, + tenant_id=def_line.tenant_id, + company_id=def_line.company_id, is_subitem=False, contains_subitems=False, subitem_number=0, @@ -329,27 +417,51 @@ def generate_definitive_import_discharged_lines( db: Session, export_invoice: InvoiceHeader, def_invoice: InvoiceHeader, - to_discharge: list[DownloadEntry], + to_discharge: List[DownloadEntry], errors: ErrorCollector, ) -> None: """ GENERAIMPODEFINITIVA_PARTIDAS_CON_DESCARGA - Creates definitive import lines only for the lines in the discharge list, - sorted by (import_invoice, import_line). + Creates definitive import lines only for the lots consumed in the discharge, + effectively splitting export lines if they came from multiple import batches. Clarion: Sort(QueADescargar, FacturaImpo, LineaImpo) → loop """ + # Sort by import lot origin to match legacy behavior sorted_entries = sorted( to_discharge, key=lambda e: (e.import_invoice, e.import_line), ) + def_line_number = 0 for entry in sorted_entries: export_line: LineItem | None = db.get(LineItem, entry.line_item_id) if export_line is None: continue - def_line_number += 1 - _copy_line_to_definitive(db, export_line, def_invoice, def_line_number) + + # If the entry has LOTS assigned (PEPS), create one IMD line per lot consumed + lots_to_transfer = [lot for lot in entry.available_lots if lot.consumed_qty > 0] + + if lots_to_transfer: + for lot in lots_to_transfer: + def_line_number += 1 + _copy_line_to_definitive( + db, + export_line, + def_invoice, + def_line_number, + custom_qty=lot.consumed_qty + ) + else: + # Fallback: if no lot info but entry exists, use entry quantity + def_line_number += 1 + _copy_line_to_definitive( + db, + export_line, + def_invoice, + def_line_number, + custom_qty=entry.quantity + ) db.flush() diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_exchange_rate.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_exchange_rate.py index 9e3142e1..a44f4726 100644 --- a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_exchange_rate.py +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_exchange_rate.py @@ -32,6 +32,7 @@ from core.exceptions import ErrorCollector def review_exchange_rate( db: Session, invoice: InvoiceHeader, + cal_val_base_tc: int, errors: ErrorCollector, ) -> None: """ @@ -43,10 +44,12 @@ def review_exchange_rate( ---------- db : active SQLAlchemy session invoice : the export invoice being processed + cal_val_base_tc : flag from settings (1 = skip validation as TC comes from pedimento) errors : shared error collector """ - # TODO: skip when SisGen:CalValBaseTCPedExpo = 1 - # (TC is taken from pedimento payment date, validated elsewhere) + if cal_val_base_tc == 1: + # SKIP: TC is taken from pedimento payment date, resolved in main_process Step 7 + return if not invoice.financials: return diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_limits.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_limits.py index b62759f8..163177ac 100644 --- a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_limits.py +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_limits.py @@ -1,80 +1,96 @@ -""" -TOT_PAR_LIM_CANT_PESO / TOT_PAR_LIM_VALOR -Computes invoice-level totals (quantity, net weight, capture value) from all -line items and writes them back to the invoice financials. - -These totals are used downstream to enforce the SisExp limit parameters -(CantLimite, PesoLimite, ValorLimite — TODO when SisExp model is available). - -Legacy equivalents ------------------- -TOT_PAR_LIM_CANT_PESO: - SELECT SUM(CantExpo), SUM(PesoNeto) - FROM QEqeMaq - WHERE Consecutivo = - → stored in Loc:CantExpoLim, Loc:PesoNetoLim - -TOT_PAR_LIM_VALOR: - SELECT SUM(CostoUnitarioCaptura * CantExpo) - FROM QEqeMaq - WHERE Consecutivo = - → stored in Loc:ValorExpoLim -""" - +import logging from decimal import Decimal -from typing import List - +from typing import Dict, Any, List from api.v1.modules.a76.invoices.models import InvoiceHeader -from api.v1.modules.a76.items.models import LineItem from core.exceptions import ErrorCollector +logger = logging.getLogger(__name__) -def limit_weight( - lines: List[LineItem], -) -> tuple[Decimal, Decimal]: +def review_sisexpo_limits( + invoice: InvoiceHeader, + settings: Dict[str, Any], + total_qty: Decimal, + total_net_weight: Decimal, + total_value: Decimal, + errors: ErrorCollector +) -> None: """ - TOT_PAR_LIM_CANT_PESO - Sums exported quantity and net weight across all line items and Returns the totals. + Validates invoice totals against the SisExpo min/max limit parameters. + Uses robust resolution for deep nested JSON structure. + """ + invoice_type = (invoice.invoice_type or "").strip().upper() + op_type = "exp" + + # 1. Start from ssisexpo root + params = settings.get("ssisexpo", {}) + cat_name = "ssisexpo" - Returns - ------- - (total_quantity, total_net_weight) - after the call. - """ + # 2. Deep resolution if not in root (Frontend structure) + # Search for any limit field to decide if we should deep dive + has_root_limits = any(params.get(k) is not None for k in ["cantlimite", "CantLimite", "pesolimite", "PesoLimite", "valorlimite", "ValorLimite"]) + + if not has_root_limits: + invoice_map = settings.get("invoices", {}).get("types", {}).get(op_type, {}).get(invoice_type, {}) + # Buscar en ssimpFormData (shared schema) dentro de qsisgen o ssisgen + params = invoice_map.get("qsisgen", {}).get("ssimpFormData", {}) or invoice_map.get("ssisgen", {}).get("ssimpFormData", {}) or params + cat_name = f"invoices.types.{op_type}.{invoice_type}.ssimpFormData" + + # Obtener límites Máximos + limit_qty = Decimal(str(params.get("cantlimite") or params.get("CantLimite") or 0)) + limit_weight = Decimal(str(params.get("pesolimite") or params.get("PesoLimite") or 0)) + limit_value = Decimal(str(params.get("valorlimite") or params.get("ValorLimite") or 0)) + + # Obtener límites Mínimos + min_limit_qty = Decimal(str(params.get("cantlimitemin") or params.get("CantLimiteMin") or 0)) + min_limit_weight = Decimal(str(params.get("pesolimitemin") or params.get("PesoLimiteMin") or 0)) + min_limit_value = Decimal(str(params.get("valorlimitemin") or params.get("ValorLimiteMin") or 0)) + + logger.info(f"DEBUG_EXPO_LIMITS: Invoice={invoice.invoice_number} | DetectedType={invoice_type} | ResolvedCat={cat_name}") + logger.info(f"DEBUG_EXPO_LIMITS: RAW_PARAMS_FOR_VAL={params}") # Cuidado, esto puede ser largo pero nos dirá la verdad + logger.info(f"DEBUG_EXPO_LIMITS: Qty: Current={total_qty} Max={limit_qty} Min={min_limit_qty}") + logger.info(f"LIMIT_CHECK_EXPO: Weight: Current={total_net_weight} Max={limit_weight} Min={min_limit_weight}") + logger.info(f"LIMIT_CHECK_EXPO: Value: Current={total_value} Max={limit_value} Min={min_limit_value}") + + solution = ["Ajuste los valores de la factura o consulte a su Administrador para parametrizar la factura."] + code = "PAR.EXPO" + + # --- Max Validations --- + if limit_qty > 0 and total_qty > limit_qty: + errors.add_error(field="financials.total_quantity", message=f"La cantidad total ({total_qty}) excede el máximo permitido ({limit_qty}).", solution=solution, code=code) + + if limit_weight > 0 and total_net_weight > limit_weight: + errors.add_error(field="financials.net_weight", message=f"El peso neto total ({total_net_weight}) excede el máximo permitido ({limit_weight}).", solution=solution, code=code) + + if limit_value > 0 and total_value > limit_value: + errors.add_error(field="financials.value_me", message=f"El valor total en USD ({total_value}) excede el máximo permitido ({limit_value}).", solution=solution, code=code) + + # --- Min Validations --- + if min_limit_qty > 0 and total_qty < min_limit_qty: + errors.add_error(field="financials.total_quantity", message=f"La cantidad total ({total_qty}) es inferior al mínimo requerido ({min_limit_qty}).", solution=solution, code=code) + + if min_limit_weight > 0 and total_net_weight < min_limit_weight: + errors.add_error(field="financials.net_weight", message=f"El peso neto total ({total_net_weight}) es inferior al mínimo requerido ({min_limit_weight}).", solution=solution, code=code) + + if min_limit_value > 0 and total_value < min_limit_value: + errors.add_error(field="financials.value_me", message=f"El valor total en USD ({total_value}) es inferior al mínimo requerido ({min_limit_value}).", solution=solution, code=code) + + +def limit_weight(lines: List[Any]) -> tuple[Decimal, Decimal]: + """Sums exported quantity and net weight across all line items.""" total_qty = Decimal(0) total_net_weight = Decimal(0) - for line in lines: - if line.quantity is None: - continue - total_qty += line.quantity.quantity or Decimal(0) - total_net_weight += line.quantity.net_weight or Decimal(0) - + if line.quantity: + total_qty += line.quantity.quantity or Decimal(0) + total_net_weight += line.quantity.net_weight or Decimal(0) return total_qty, total_net_weight - -def limit_value( - lines: List[LineItem], -) -> Decimal: - """ - TOT_PAR_LIM_VALOR - Sums (unit_cost_capture × quantity) across all line items and writes the - result to ``invoice.financials.value_mn`` as the capture-based total value. - - Returns - ------- - total_capture_value — also available on invoice.financials after the call. - - Note: the legacy field Loc:ValorExpoLim is only used to compare against - SisExp limit parameters (TODO when SisExp model is available). - """ +def limit_value(lines: List[Any]) -> Decimal: + """Sums (unit_cost_capture × quantity) across all line items.""" total_value = Decimal(0) - for line in lines: - if line.financial is None or line.quantity is None: - continue - capture = line.financial.unit_cost_capture or Decimal(0) - qty = line.quantity.quantity or Decimal(0) - total_value += capture * qty - + if line.financial and line.quantity: + capture = line.financial.unit_cost_capture or Decimal(0) + qty = line.quantity.quantity or Decimal(0) + total_value += capture * qty return total_value diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/review_qty_series.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/review_qty_series.py index 5d4a3200..07821730 100644 --- a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/review_qty_series.py +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/review_qty_series.py @@ -25,11 +25,9 @@ from api.v1.modules.a76.general_catalogs.company.models import Company from api.v1.modules.a76.invoices.models import InvoiceHeader from api.v1.modules.a76.items.models import LineItem from api.v1.modules.a76.items.series.models import Serie +from api.v1.modules.a76.app_settings.service import AppSettingsService from core.exceptions import ErrorCollector -# TODO: Read SisGen:CantvsCantSeries from the tenant system-config model -_SISGEN_CANT_VS_CANT_SERIES: int = 0 # 0 = disabled - # RFCs where qty-vs-series validation is conditional on UOM = PZA when is_regime_change _RFC_EXCEPCION_PZA = { "IMS030409FZ0", @@ -45,6 +43,7 @@ def _validate_line_series( line: LineItem, company_rfc: str, errors: ErrorCollector, + valida_cant_series: int = 0, ) -> None: """Validates series count for a single line that has has_serial = True.""" series_count = ( @@ -66,8 +65,7 @@ def _validate_line_series( return # Rule 2: quantity vs series count check (controlled by SisGen flag) - # TODO: Replace _SISGEN_CANT_VS_CANT_SERIES with the real config value - if _SISGEN_CANT_VS_CANT_SERIES != 1: + if valida_cant_series != 1: return qty = line.quantity.quantity if line.quantity else None @@ -114,8 +112,13 @@ def review_qty_series( company = db.get(Company, company_id) company_rfc = (company.rfc or "").strip().upper() if company else "" + settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id)) + q_gen = settings.get("qsisgen", {}) + s_gen = settings.get("ssisgen", {}) + valida_cant_series = int(q_gen.get("cantvscantseries") or s_gen.get("cantvscantseries", 0)) + for line in lines: if not (line.description and line.description.has_serial): continue - _validate_line_series(db, invoice, line, company_rfc, errors) + _validate_line_series(db, invoice, line, company_rfc, errors, valida_cant_series) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/task.py b/backend/api/v1/modules/a76/invoices/exports/process/task.py index 65bda45a..4048d2af 100644 --- a/backend/api/v1/modules/a76/invoices/exports/process/task.py +++ b/backend/api/v1/modules/a76/invoices/exports/process/task.py @@ -13,7 +13,7 @@ def _progress(task: Task, current: int, status: str) -> None: @celery_app.task(bind=True, name="process_export_invoice_task") -def process_export_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id: str) -> dict: +def process_export_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id: str, username: str = "SYSTEM") -> dict: """ Procesa una factura de exportación ejecutando todas las validaciones y actualizaciones del proceso principal de exportación con reporte de progreso. @@ -30,7 +30,7 @@ def process_export_invoice_task(self: Task, invoice_id: int, tenant_id: str, com } _progress(self, 10, "Procesando factura de exportación...") - result = main_process(db, invoice, tenant_id, company_id) + result = main_process(db, invoice, tenant_id, company_id, username=username) db.commit() _progress(self, 100, "Proceso completado.") diff --git a/backend/api/v1/modules/a76/invoices/exports/revert/main_process.py b/backend/api/v1/modules/a76/invoices/exports/revert/main_process.py index f264ea27..62fc16f5 100644 --- a/backend/api/v1/modules/a76/invoices/exports/revert/main_process.py +++ b/backend/api/v1/modules/a76/invoices/exports/revert/main_process.py @@ -1,3 +1,4 @@ +from api.v1.modules.a76.app_settings.service import AppSettingsService import datetime from decimal import Decimal from typing import List, Optional @@ -28,15 +29,18 @@ def _validate_regime_change_definitive_invoice_exists( invoice: InvoiceHeader, errors: ErrorCollector, ) -> None: - """ - Clarion mapping: - If EqiFex:EsCambioRegimen='S' then count QFacImpDef where - FacturaImpoDef = FacturaExpo and ProvImpoDefCR='C'. - - Python approximation: - Search an import invoice with same invoice_number and invoice_type='IMD'. - """ - if not (invoice.compliance_mx and invoice.compliance_mx.is_regime_change): + # Robust detection logic (same as generator) + is_cr = False + if invoice.compliance_mx and invoice.compliance_mx.is_regime_change: + is_cr = True + elif (invoice.invoice_type or "").strip().upper() == "CR": + is_cr = True + elif invoice.operation_type == OperationType.EXP: + if invoice.compliance_mx and invoice.compliance_mx.pedimento: + if (invoice.compliance_mx.pedimento.pedimento_code or "").strip().upper() == "F4": + is_cr = True + + if not is_cr: return if not invoice.invoice_number: @@ -348,10 +352,7 @@ def revert_process( _ = (db, tenant_id, company_id) # reserved for future TO DO implementations sql_errors: list = [] - # INICIALIZA QUEUES (Python: collector ya llega limpio por tarea) - # TODO: Compartir QSisGen / parámetros globales del Clarion. - - # TODO: BEGIN TRAN (managed by SQLAlchemy session in task) + # PROCESO DE REVERSIÓN _todo_check_access_lock(invoice) # VERIFICAR SI HAY PARTIDAS DE EXPORTACION @@ -370,4 +371,19 @@ def revert_process( _set_invoice_unprocessed(invoice, line_count) # TODO: COMMIT/ROLLBACK TRAN + QueueErrorSQL file handling + GBitacora + + # Auditoría de Desactualización + settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id)) + q_gen = settings.get("qsisgen", {}) + s_gen = settings.get("ssisgen", {}) + act_seguridad = int(q_gen.get("actseguridad") or s_gen.get("actseguridad") or q_gen.get("ActSeguridad") or s_gen.get("ActSeguridad", 0)) + + if act_seguridad == 1: + from api.v1.modules.a76.audit_log.services.service import AuditService + AuditService.create_audit_log( + db=db, reference=invoice.invoice_number, procedure="ANULAR FACTURA", movement="DESACTUALIZACION", + username=cancelled_by or "SYSTEM", tenant_id=int(tenant_id), company_id=int(company_id), + table_name="invoice_header", record_id=str(invoice.id), operation_type="UPDATE" + ) + return sql_errors diff --git a/backend/api/v1/modules/a76/invoices/exports/validators/update.py b/backend/api/v1/modules/a76/invoices/exports/validators/update.py index 7c4b5cd4..c6371453 100644 --- a/backend/api/v1/modules/a76/invoices/exports/validators/update.py +++ b/backend/api/v1/modules/a76/invoices/exports/validators/update.py @@ -24,7 +24,7 @@ def validate_update( errors: ErrorCollector, ) -> None: """ - Valida y procesa la actualización parcial de una factura de importación temporal. + Valida y procesa la actualización parcial de una factura de exportación. Lógica: Si un campo viene con valor, se limpia/valida. Si no, se mantiene el valor existente de la factura. diff --git a/backend/api/v1/modules/a76/invoices/imports/process/main_process.py b/backend/api/v1/modules/a76/invoices/imports/process/main_process.py index c4127ee9..e672f3e8 100644 --- a/backend/api/v1/modules/a76/invoices/imports/process/main_process.py +++ b/backend/api/v1/modules/a76/invoices/imports/process/main_process.py @@ -1,4 +1,5 @@ - +import logging +logger = logging.getLogger(__name__) from datetime import date, datetime from decimal import Decimal from typing import List @@ -30,7 +31,10 @@ from .sub_process.assing_values_def_mex import ( assign_values_invoice_totals, ) from ..balance.create_balance_entries import create_balance_entries +from .sub_process.review_limits import review_limits +# Parameter Service +from api.v1.modules.a76.app_settings.service import AppSettingsService def _validate_lines( @@ -44,44 +48,34 @@ def _validate_lines( """Recorre cada partida y ejecuta las validaciones individuales.""" company = db.get(Company, invoice.company_id) company_rfc = (company.rfc or "").strip().upper() if company else "" - # Deduplicación de cupos disponibles: (permiso, ro_line, pais) → OctaveAvailableEntry octave_available: dict = {} - # Partidas a descargar: se pasa a valida_imp_regla_octava octave_desc: list = [] + for line in lines: - # Validar costo unitario capturado en partidas principales if (line.financial and line.fa_data) and not line.fa_data.is_subitem and (line.financial.unit_cost_capture or Decimal(0)) == 0: errors.add_error( field=f"line[{line.line_number}].unit_cost_capture", message="No existe el costo unitario para la Partida.", - solution=[ - f"Entrar a la partida: {line.line_number} y capturar el Costo Unitario." - ], + solution=[f"Entrar a la partida: {line.line_number} y capturar el Costo Unitario."], code="UNIT_COST_REQUIRED", ) - # Validar clase habilitada/deshabilitada if line.class_id is not None: cls: Class | None = db.get(Class, line.class_id) if cls and cls.is_active is False: errors.add_error( field=f"line[{line.line_number}].class", - message=( - f"El número de parte: {cls.class_code} esta desactivado, no se pueden hacer movimientos." - ), + message=f"El número de parte: {cls.class_code} esta desactivado, no se pueden hacer movimientos.", solution=["Seleccionar un número de parte activo."], code="CLASS_DISABLED", ) - # Validar número de parte habilitado/deshabilitado if line.part_number_id is not None: part: Part | None = db.get(Part, line.part_number_id) if part and part.is_active is False: errors.add_error( field=f"line[{line.line_number}].part_number", - message=( - f"El número de parte: {part.part_number} esta desactivado, no se pueden hacer movimientos." - ), + message=f"El número de parte: {part.part_number} esta desactivado, no se pueden hacer movimientos.", solution=["Seleccionar un número de parte activo."], code="PART_DISABLED", ) @@ -96,37 +90,26 @@ def _validate_lines( errors=errors, ) - review_series(db, line, company_rfc, errors) + review_series(db, line, company_rfc, tenant_id, company_id, errors) - # Validación de la Regla Octava if line.octave_permit: if not company.prosec: errors.add_error( field=f"line[{line.line_number}].octave_permit", - message="No se puede hacer uso de la Regla Octava, ...", + message="No se puede hacer uso de la Regla Octava...", solution=["Borrar el permiso o dar de alta el permiso PROSEC..."], code="OCTAVA_SIN_PROSEC", ) else: desc_entry = llena_impo_permiso_regla_octava( - db=db, - invoice=invoice, - line=line, - company_rfc=company_rfc, - tenant_id=tenant_id, - company_id=company_id, - errors=errors, + db=db, invoice=invoice, line=line, company_rfc=company_rfc, + tenant_id=tenant_id, company_id=company_id, errors=errors, ) if desc_entry is not None: octave_desc.append(desc_entry) available = revpermiso_regla_octava( - db=db, - invoice=invoice, - line=line, - company_rfc=company_rfc, - tenant_id=tenant_id, - company_id=company_id, - errors=errors, + db=db, invoice=invoice, line=line, company_rfc=company_rfc, + tenant_id=tenant_id, company_id=company_id, errors=errors, ) if available is not None: key = (available.octave_permit, available.ro_line, available.country_code) @@ -137,35 +120,16 @@ def _validate_lines( return octave_desc, octave_available -def _validate_sisimp_limits( - invoice: InvoiceHeader, - errors: ErrorCollector, -) -> None: - """ - Valida los límites de cantidad, peso y valor configurados en SisImp. - - TODO: Leer los parámetros SisImp desde la configuración del sistema: - - SisImp:CantLimiteMin / SisImp:CantLimite - - SisImp:PesoLimiteMin / SisImp:PesoLimite - - SisImp:ValorLimiteMin / SisImp:ValorLimite - Una vez disponibles, usar invoice.financials.total_quantity, net_weight y value_mc. - """ - # TODO: Implementar cuando SisImp esté disponible en la configuración del tenant - pass +def _validate_sisimp_limits(invoice: InvoiceHeader, settings: dict, errors: ErrorCollector) -> None: + """Valida los límites de cantidad, peso y valor configurados en SisImpo/SisDef.""" + review_limits(invoice, settings, errors) def _update_invoice_totals(invoice: InvoiceHeader, lines: List[LineItem]) -> None: - """ - Copia los totales calculados de financials/logistics al encabezado de la factura - y calcula IVA, incrementables y valores de aduanas. - - TODO: Validar SSisGen:ActSeguridad para asignar el usuario que actualizó. - TODO: Validar SSisGen:CalValBaseTCPed para registrar el mensaje de procesamiento. - """ + """Actualiza totales, IVA e incrementables en la factura.""" tc = Decimal(str(invoice.financials.exchange_rate or 0)) tc_mm = Decimal(str(invoice.financials.exchange_rate_mm or 0)) - # Calcular IVA sólo para facturas con fecha posterior al corte (78165 en Clarion = 2004-06-01 aprox.) iva_factor = Decimal(str(invoice.financials.iva_factor or 0)) if invoice.financials.iva_factor else Decimal(0) if invoice.invoice_date and invoice.invoice_date >= date(2014, 12, 31): invoice.financials.iva_mn = float(Decimal(str(invoice.financials.value_mn or 0)) * iva_factor / 100) @@ -174,83 +138,93 @@ def _update_invoice_totals(invoice: InvoiceHeader, lines: List[LineItem]) -> Non invoice.financials.iva_mn = 0.0 invoice.financials.iva_me = 0.0 - # Calcular total de incrementables por tipo de moneda freight = Decimal(str(invoice.financials.freight or 0)) insurance = Decimal(str(invoice.financials.insurance or 0)) packaging = Decimal(str(invoice.financials.packaging or 0)) other = Decimal(str(invoice.financials.other_increments or 0)) base_increm = freight + insurance + packaging + other - if invoice.financials.currency == Currency.FOREIGN: # ME + if invoice.financials.currency == Currency.FOREIGN: val_seguro = Decimal(str(invoice.financials.total_increments_me or 0)) - base_increm invoice.financials.total_increments_me = float(base_increm + val_seguro) invoice.financials.total_increments_mn = float(Decimal(str(invoice.financials.total_increments_me)) * tc) - - elif invoice.financials.currency == Currency.LOCAL: # MN + elif invoice.financials.currency == Currency.LOCAL: val_seguro = Decimal(str(invoice.financials.total_increments_mn or 0)) - base_increm invoice.financials.total_increments_mn = float(base_increm + val_seguro) - invoice.financials.total_increments_me = float( - (Decimal(str(invoice.financials.total_increments_mn)) / tc) if tc else Decimal(0) - ) - - elif invoice.financials.currency == Currency.MANUAL: # MC - val_seguro = ( - (Decimal(str(invoice.financials.total_increments_me or 0)) / tc_mm) if tc_mm else Decimal(0) - ) - base_increm + invoice.financials.total_increments_me = float((Decimal(str(invoice.financials.total_increments_mn)) / tc) if tc else Decimal(0)) + elif invoice.financials.currency == Currency.MANUAL: + val_seguro = ((Decimal(str(invoice.financials.total_increments_me or 0)) / tc_mm) if tc_mm else Decimal(0)) - base_increm invoice.financials.total_increments_me = float((base_increm + val_seguro) * tc_mm) invoice.financials.total_increments_mn = float(Decimal(str(invoice.financials.total_increments_me)) * tc) - # Marcar la factura como procesada invoice.status = InvoiceStatus.PROCESSED invoice.party_count = len(lines) - # TODO: SSisGen:ActSeguridad = 1 → invoice.updated_by = current_user - # TODO: SSisGen:CalValBaseTCPed = 1 → - # invoice.process_log = "Se Actualizó con el Tipo de Cambio de la Fecha de Pago de Pedimento." + if getattr(invoice, "_cal_val_base_tc", 0) == 1: + invoice.process_log = "Se Actualizó con el Tipo de Cambio de la Fecha de Pago de Pedimento." + if getattr(invoice, "_act_seguridad", 0) == 1 and hasattr(invoice, "_username"): + invoice.updated_by = invoice._username -def main_process(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id: str) -> dict: - """ - Proceso principal para importar facturas. - - Flujo (porta la rutina principal del legacy SCAII): - 1. Validación previa de datos (pre_validators) - 2. Tipo de cambio del pedimento (TODO: SSisGen:CalValBaseTCPed) - 3. Revisión de clases, tipo de cambio y pesos - 4. Asignación de valores por partida y totalización - 5. Validaciones per-línea (costo, clase, número de parte, Regla Octava, UMA) - 6. Validación de límites SisImp (TODO) - 7. Si no hay errores: actualizar totales e incrementables en la factura y hacer commit - 8. Si hay errores: rollback (SQLAlchemy lo maneja con la excepción) - """ +def main_process(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id: str, username: str = "SYSTEM") -> dict: + """Proceso principal para importar facturas.""" errors = ErrorCollector() - - # Paso 1: Validación previa lines = pre_validators(db, invoice, tenant_id, company_id, errors) if not lines: - errors.add_error( - field="line_items", - message="La factura debe contener al menos una partida para ser importada", - solution=["Agregue partidas a la factura antes de intentar importarla"], - code="NO_LINE_ITEMS", - ) + errors.add_error(field="line_items", message="La factura debe contener al menos una partida", solution=["Agregue partidas"], code="NO_LINE_ITEMS") errors.raise_if_errors() - # TODO: SSisGen:CalValBaseTCPed = 1 → obtener tipo de cambio de la fecha de pago del pedimento - # y asignarlo a invoice.financials.exchange_rate antes de continuar. - # invoice.which_exchange_rate = 'TCPED' (o 'TCFAC' si CalValBaseTCPed = 0) + settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id)) + + inv_type = (invoice.invoice_type or "").strip().upper() + op_type = "imp" + + # Hierarchical resolve: invoices.types.imp.{type}.ssisgen.ssimpFormData + inv_map = settings.get("invoices", {}).get("types", {}).get(op_type, {}).get(inv_type, {}) + form_data = inv_map.get("ssisgen", {}).get("ssimpFormData", {}) or inv_map.get("qsisgen", {}).get("ssimpFormData", {}) or settings.get("ssisgen", {}) or settings.get("qsisgen", {}) + + cal_val_base_tc = int(form_data.get("calvalbasetcped") or form_data.get("CalValBaseTCPed") or 0) + act_seguridad = int(form_data.get("actseguridad") or form_data.get("ActSeguridad") or 0) + logger.info(f"AUDIT_DEBUG: act_seguridad resolve result = {act_seguridad} for Invoice={invoice.invoice_number}") + + invoice._cal_val_base_tc = cal_val_base_tc + invoice._act_seguridad = act_seguridad + invoice._username = username + + exchange_rate = invoice.financials.exchange_rate if invoice.financials else 0 + which_exchange_rate = "TCFAC" + + if cal_val_base_tc == 1: + if invoice.compliance_mx and invoice.compliance_mx.pedimento: + pedimento = invoice.compliance_mx.pedimento + if pedimento.pedimento_dates and pedimento.pedimento_dates.payment_date: + payment_date = pedimento.pedimento_dates.payment_date + from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate + from sqlalchemy import select + stmt = select(ExchangeRate).where(ExchangeRate.tenant_id == int(tenant_id), ExchangeRate.company_id == int(company_id), ExchangeRate.date == payment_date.date()) + ex_rate_row = db.execute(stmt).scalar_one_or_none() + if ex_rate_row: + exchange_rate = float(ex_rate_row.value) + which_exchange_rate = "TCPED" + else: + errors.add_error(field="exchange_rate", message=f"No se encontró tipo de cambio para {payment_date.date()}.", solution=["Capturar TC en catálogos."], code="EXCHANGE_RATE_NOT_FOUND") + else: + errors.add_error(field="pedimento", message="El pedimento no tiene fecha de pago.", solution=["Capturar fecha de pago."], code="PEDIMENTO_NO_PAYMENT_DATE") + else: + errors.add_error(field="compliance_mx.pedimento", message="Se requiere pedimento para el TC.", solution=["Asignar pedimento."], code="PEDIMENTO_REQUIRED_FOR_TC") + + if invoice.compliance_mx: invoice.compliance_mx.which_exchange_rate = which_exchange_rate + if invoice.financials: invoice.financials.exchange_rate = exchange_rate + db.flush() - # Paso 2: Revisión de clases y fracciones review_classes(db, invoice, lines, tenant_id, company_id, errors) - review_exchange_rate(db, invoice, errors) + review_exchange_rate(db, invoice, cal_val_base_tc, errors) if invoice.logistics and invoice.logistics.weight_type == "kgs": review_weights_kgs(db, lines, tenant_id, company_id, errors) elif invoice.logistics and invoice.logistics.weight_type == "lbs": review_weights_lbs(db, lines, tenant_id, company_id, errors) - # Paso 3: Asignación de valores por partida y totalización de factura - # Para IMPO DEF / Compras Mexicanas se usa la versión con IVA por partida. invoice_type = (invoice.invoice_type or "").strip().upper() if invoice_type in {"DEF", "MEX"}: assign_values_iva_lines(invoice, lines) @@ -259,42 +233,30 @@ def main_process(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id assign_values_lines(invoice, lines) assign_values_invoice(invoice, lines) - # Paso 4: Validaciones per-línea octave_desc, octave_available = _validate_lines(db, invoice, lines, tenant_id, company_id, errors) - company = db.get(Company, invoice.company_id) - if company.prosec and octave_desc: - valida_imp_regla_octava( - db=db, - desc_list=octave_desc, - dis_dict=octave_available, - tenant_id=tenant_id, - company_id=company_id, - errors=errors, - ) - - # Paso 5: Límites de SisImp - _validate_sisimp_limits(invoice, errors) + valida_imp_regla_octava(db=db, desc_list=octave_desc, dis_dict=octave_available, tenant_id=tenant_id, company_id=company_id, errors=errors) + _validate_sisimp_limits(invoice, settings, errors) errors.raise_if_errors() - # Paso 6: Descontar cupos de Regla Octava sql_errors: list = [] if octave_desc: - descuenta_cupo_r_octava( - db=db, - desc_list=octave_desc, - tenant_id=tenant_id, - company_id=company_id, - sql_errors=sql_errors, - ) + descuenta_cupo_r_octava(db=db, desc_list=octave_desc, tenant_id=tenant_id, company_id=company_id, sql_errors=sql_errors) - # Paso 7: Actualizar totales, IVA e incrementables y marcar como procesada _update_invoice_totals(invoice, lines) - - # Paso 8: Generar saldos en a24.balance_movement (una entrada por partida) if invoice_type not in {"DEF", "MEX"}: create_balance_entries(db, invoice, lines) - db.flush() \ No newline at end of file + db.flush() + if act_seguridad == 1: + from api.v1.modules.a76.audit_log.services.service import AuditService + AuditService.create_audit_log( + db=db, reference=invoice.invoice_number, procedure="ACTUALIZAR FACTURA", movement="IMPORTACION", + username=username, tenant_id=int(tenant_id), company_id=int(company_id), + table_name="invoice_header", record_id=str(invoice.id), operation_type="UPDATE" + ) + + db.flush() + return {"status": "success", "invoice_id": str(invoice.id)} diff --git a/backend/api/v1/modules/a76/invoices/imports/process/pre_validators.py b/backend/api/v1/modules/a76/invoices/imports/process/pre_validators.py index c1c47b7f..b15bc7fb 100644 --- a/backend/api/v1/modules/a76/invoices/imports/process/pre_validators.py +++ b/backend/api/v1/modules/a76/invoices/imports/process/pre_validators.py @@ -3,6 +3,7 @@ from sqlalchemy.orm import Session from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus from api.v1.modules.a76.items.models import LineItem from api.v1.modules.a76.general_catalogs.fractions.warning_fractions.models import WarningFraction +from api.v1.modules.a76.app_settings.service import AppSettingsService from core.exceptions import ErrorCollector def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id: str, errors: ErrorCollector): @@ -35,10 +36,34 @@ def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_ if not invoice.compliance_mx.customs_broker_id: errors.add_required_error("compliance_mx.customs_broker_id") - #TODO: SSISGEN: Seguridad Ejemplo en: BrowseQFacImp - - # 2.- Existe tipo de cambio para la factura seleccionada - #TODO: SSISGEN: VALIDACION DEL TIPO DE CAMBIO EN BASE A LA FECHA DE PAGO DEL PEDIMENTO. + # Validación de estatus de pedimento (CERRADO / PAGADO) + if invoice.compliance_mx and invoice.compliance_mx.pedimento: + ped = invoice.compliance_mx.pedimento + if ped.pedimento_dates and ped.pedimento_dates.payment_date: + from datetime import datetime + today = datetime.now().date() + pay_date = ped.pedimento_dates.payment_date.date() + + # Obtener parámetros de seguridad + settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id)) + # Revisar actseguridad en ssisgen o qsisgen (según el sistema origen) + gen_params = settings.get("ssisgen", {}) + if not gen_params: + gen_params = settings.get("qsisgen", {}) + + act_seguridad = int(gen_params.get("actseguridad", 1)) + + # Si la fecha es futura (> hoy) o si la seguridad está desactivada (0), permitimos con advertencia + if pay_date > today or act_seguridad == 0: + pass # Permitir la actualización, el proceso continuará + else: + errors.add_error( + field="compliance_mx.pedimento", + message=f"No se puede procesar la factura porque el pedimento {ped.pedimento_number} ya se encuentra pagado el {pay_date}.", + solution=["Si requiere hacer cambios, desactive 'Solicitar Autorización para Actualizar Facturas' en la configuración general o rectifique el pedimento."], + code="PEDIMENTO_ALREADY_PAID" + ) + errors.raise_if_errors() if not invoice.financials.exchange_rate or invoice.financials.exchange_rate <= 0: diff --git a/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_exchange_rate.py b/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_exchange_rate.py index 892fa2d7..0ab291a7 100644 --- a/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_exchange_rate.py +++ b/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_exchange_rate.py @@ -11,6 +11,7 @@ from core.exceptions import ErrorCollector def review_exchange_rate( db: Session, invoice: InvoiceHeader, + cal_val_base_tc: int, errors: ErrorCollector, ) -> None: """ @@ -19,10 +20,11 @@ def review_exchange_rate( Ported from legacy REVISA_TIPOCAMBIO routine. Only runs when the system is NOT configured to use the pedimento's exchange - rate (SisGen:CalValBaseTCPed = 0), which corresponds to the TODO comment in - main_process: the caller is responsible for skipping this call when that flag - is active. + rate (SisGen:CalValBaseTCPed = 0). """ + if cal_val_base_tc == 1: + # SKIP: TC is derived from pedimento payment date + return if not invoice.invoice_date: return diff --git a/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_limits.py b/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_limits.py new file mode 100644 index 00000000..6e100527 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_limits.py @@ -0,0 +1,80 @@ +import logging +from decimal import Decimal +from typing import Dict, Any, Optional +from api.v1.modules.a76.invoices.models import InvoiceHeader +from core.exceptions import ErrorCollector + +logger = logging.getLogger(__name__) + +def review_limits( + invoice: InvoiceHeader, + settings: Dict[str, Any], + errors: ErrorCollector +) -> None: + """ + Valida que los totales de la factura no excedan los límites configurados + en SisImpo o SisDef. + """ + if not invoice.financials: + logger.warning(f"LIMIT_CHECK: No financials found for invoice {invoice.id}") + return + + # Determinamos qué categoría de parámetros usar según el tipo de factura + invoice_type = (invoice.invoice_type or "").strip().upper() + op_type = "imp" # Por ahora enfocado en importación + + # 1. Intentar obtener de la raíz (ssisimpo/ssisdef) + if invoice_type in {"DEF", "MEX"}: + params = settings.get("ssisdef", {}) + cat_name = "ssisdef" + else: + params = settings.get("ssisimpo", {}) + cat_name = "ssisimpo" + + # 2. Si no están en la raíz, intentar en la estructura profunda (invoices.types...) + # Esta es la estructura que viene del frontend según el JSON "hermoso" + if not params.get("CantLimite") and not params.get("cantlimite"): + invoice_map = settings.get("invoices", {}).get("types", {}).get(op_type, {}).get(invoice_type, {}) + # Buscar en ssimpFormData dentro de qsisgen o ssisgen + params = invoice_map.get("qsisgen", {}).get("ssimpFormData", {}) or invoice_map.get("ssisgen", {}).get("ssimpFormData", {}) or params + cat_name = f"invoices.types.{op_type}.{invoice_type}.ssimpFormData" + + # Obtener límites Máximos + limit_qty = Decimal(str(params.get("cantlimite") or params.get("CantLimite") or 0)) + limit_weight = Decimal(str(params.get("pesolimite") or params.get("PesoLimite") or 0)) + limit_value = Decimal(str(params.get("valorlimite") or params.get("ValorLimite") or 0)) + + # Obtener límites Mínimos + min_limit_qty = Decimal(str(params.get("cantlimitemin") or params.get("CantLimiteMin") or 0)) + min_limit_weight = Decimal(str(params.get("pesolimitemin") or params.get("PesoLimiteMin") or 0)) + min_limit_value = Decimal(str(params.get("valorlimitemin") or params.get("ValorLimiteMin") or 0)) + + # Totales actuales de la factura + current_qty = Decimal(str(invoice.financials.total_quantity or 0)) + current_weight = Decimal(str(invoice.financials.net_weight or 0)) + current_value = Decimal(str(invoice.financials.value_me or 0)) + + logger.info(f"LIMIT_CHECK: Invoice={invoice.invoice_number} Type={invoice_type} Cat={cat_name}") + logger.info(f"LIMIT_CHECK: Qty: Current={current_qty} Max={limit_qty} Min={min_limit_qty}") + logger.info(f"LIMIT_CHECK: Weight: Current={current_weight} Max={limit_weight} Min={min_limit_weight}") + logger.info(f"LIMIT_CHECK: Value: Current={current_value} Max={limit_value} Min={min_limit_value}") + + # --- Validaciones de Máximos --- + if limit_qty > 0 and current_qty > limit_qty: + errors.add_error(field="financials.total_quantity", message=f"La cantidad total ({current_qty}) excede el máximo permitido ({limit_qty}).", solution=["Ajuste las cantidades."], code="LIMIT_QTY_EXCEEDED") + + if limit_weight > 0 and current_weight > limit_weight: + errors.add_error(field="financials.net_weight", message=f"El peso neto total ({current_weight}) excede el máximo permitido ({limit_weight}).", solution=["Ajuste los pesos."], code="LIMIT_WEIGHT_EXCEEDED") + + if limit_value > 0 and current_value > limit_value: + errors.add_error(field="financials.value_me", message=f"El valor total en USD ({current_value}) excede el máximo permitido ({limit_value}).", solution=["Ajuste los costos."], code="LIMIT_VALUE_EXCEEDED") + + # --- Validaciones de Mínimos --- + if min_limit_qty > 0 and current_qty < min_limit_qty: + errors.add_error(field="financials.total_quantity", message=f"La cantidad total ({current_qty}) es inferior al mínimo requerido ({min_limit_qty}).", solution=["Aumente las cantidades."], code="MIN_LIMIT_QTY_NOT_MET") + + if min_limit_weight > 0 and current_weight < min_limit_weight: + errors.add_error(field="financials.net_weight", message=f"El peso neto total ({current_weight}) es inferior al mínimo requerido ({min_limit_weight}).", solution=["Aumente los pesos."], code="MIN_LIMIT_WEIGHT_NOT_MET") + + if min_limit_value > 0 and current_value < min_limit_value: + errors.add_error(field="financials.value_me", message=f"El valor total en USD ({current_value}) es inferior al mínimo requerido ({min_limit_value}).", solution=["Aumente los costos."], code="MIN_LIMIT_VALUE_NOT_MET") diff --git a/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_series.py b/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_series.py index 9ccdd84f..1e5de35d 100644 --- a/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_series.py +++ b/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_series.py @@ -6,12 +6,9 @@ from api.v1.modules.a76.general_catalogs.company.models import Company from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure from api.v1.modules.a76.items.models import LineItem from api.v1.modules.a76.items.series.models import Serie +from api.v1.modules.a76.app_settings.service import AppSettingsService from core.exceptions import ErrorCollector -# Clarion: SisGen:CantvsCantSeries -# TODO: leer desde configuración del tenant cuando SisGen esté disponible -_SISIMP_CANT_VS_CANT_SERIES: int = 0 # 0 = desactivado - # RFCs donde la validación de cantidad vs series únicamente aplica a PZA (paridad Clarion) _RFC_EXCEPCION_PZA = { "IMS030409FZ0", @@ -24,7 +21,9 @@ _RFC_EXCEPCION_PZA = { def review_series( db: Session, line: LineItem, - company_rfc: int, + company_rfc: str, + tenant_id: str, + company_id: str, errors: ErrorCollector, ) -> None: """ @@ -64,8 +63,13 @@ def review_series( return # GNiv:CantSerievsCant = 0 → el bloque series > cantidad estaba comentado en Clarion original - # TODO: leer SisGen:CantvsCantSeries desde la configuración del tenant - if _SISIMP_CANT_VS_CANT_SERIES != 1: + settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id)) + q_gen = settings.get("qsisgen", {}) + s_gen = settings.get("ssisgen", {}) + + valida_cant_series = int(q_gen.get("cantvscantseries") or s_gen.get("cantvscantseries", 0)) + + if valida_cant_series != 1: return qty = line.quantity.quantity if line.quantity else None diff --git a/backend/api/v1/modules/a76/invoices/imports/process/task.py b/backend/api/v1/modules/a76/invoices/imports/process/task.py index 4ca0360f..1cabe600 100644 --- a/backend/api/v1/modules/a76/invoices/imports/process/task.py +++ b/backend/api/v1/modules/a76/invoices/imports/process/task.py @@ -4,22 +4,10 @@ from celery import Task from core.celery_app import celery_app from core.database import CoreSessionLocal -from core.exceptions import ErrorCollector, ValidationException +from core.exceptions import ValidationException from api.v1.modules.a76.invoices.models import InvoiceHeader -from api.v1.modules.a76.general_catalogs.company.models import Company -from .pre_validators import pre_validators -from .sub_process.review_classes import review_classes -from .sub_process.review_exchange_rate import review_exchange_rate -from .sub_process.review_weights import review_weights_kgs, review_weights_lbs -from .sub_process.review_rule_octave import valida_imp_regla_octava, descuenta_cupo_r_octava -from .sub_process.assing_values import assign_values_lines, assign_values_invoice -from .sub_process.assing_values_def_mex import ( - assign_values_iva_lines, - assign_values_invoice_totals, -) -from ..balance.create_balance_entries import create_balance_entries -from .main_process import _validate_sisimp_limits, _update_invoice_totals, _validate_lines +from .main_process import main_process logger = logging.getLogger(__name__) @@ -29,7 +17,7 @@ def _progress(task: Task, current: int, status: str) -> None: @celery_app.task(bind=True, name="process_invoice_task") -def process_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id: str) -> dict: +def process_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id: str, username: str = "SYSTEM") -> dict: """ Procesa una factura de importación ejecutando todas las validaciones y actualizaciones del proceso principal (main_process) con reporte de progreso. @@ -46,95 +34,23 @@ def process_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id "errors": [], } - errors = ErrorCollector() - - # ── Paso 2: Pre-validaciones ────────────────────────────────────────── - _progress(self, 10, "Validando datos de la factura...") - lines = pre_validators(db, invoice, tenant_id, company_id, errors) - if not lines: - errors.add_error( - field="line_items", - message="La factura debe contener al menos una partida para ser importada", - solution=["Agregue partidas a la factura antes de intentar importarla"], - code="NO_LINE_ITEMS", - ) - errors.raise_if_errors() - - # ── Paso 3: Revisión clases, tipo de cambio y pesos ────────────────── - _progress(self, 30, "Revisando clases y tipo de cambio...") - review_classes(db, invoice, lines, tenant_id, company_id, errors) - review_exchange_rate(db, invoice, errors) - - if invoice.logistics and invoice.logistics.weight_type == "kgs": - review_weights_kgs(db, lines, tenant_id, company_id, errors) - elif invoice.logistics and invoice.logistics.weight_type == "lbs": - review_weights_lbs(db, lines, tenant_id, company_id, errors) - - # ── Paso 4: Asignación de valores ───────────────────────────────────── - _progress(self, 50, "Calculando valores por partida...") - raw_type = invoice.invoice_type - invoice_type = (raw_type or "").strip().upper() - logger.info( - "celery import process invoice_type: invoice_id=%s raw=%r normalized=%r document_type=%r", - invoice.id, - raw_type, - invoice_type, - getattr(invoice, "document_type", None), - ) - if invoice_type in {"DEF", "MEX"}: - assign_values_iva_lines(invoice, lines) - assign_values_invoice_totals(invoice, lines) - else: - assign_values_lines(invoice, lines) - assign_values_invoice(invoice, lines) - - # ── Paso 5: Validaciones por partida ────────────────────────────────── - _progress(self, 70, "Validando partidas...") - octave_desc, octave_available = _validate_lines( - db, invoice, lines, tenant_id, company_id, errors + # ── Paso 2: Ejecutar Proceso Principal ─────────────────────────────── + # Unificamos lógica: El task solo llama al main_process centralizado. + _progress(self, 20, "Iniciando procesamiento de factura...") + result = main_process( + db=db, + invoice=invoice, + tenant_id=tenant_id, + company_id=company_id, + username=username ) - # ── Paso 6: Regla Octava y límites SisImp ───────────────────────────── - _progress(self, 85, "Validando cupos de Regla Octava...") - company: Company | None = db.get(Company, invoice.company_id) - if company and company.prosec and octave_desc: - valida_imp_regla_octava( - db=db, - desc_list=octave_desc, - dis_dict=octave_available, - tenant_id=tenant_id, - company_id=company_id, - errors=errors, - ) - _validate_sisimp_limits(invoice, errors) - errors.raise_if_errors() - - # ── Paso 7: Descuento de cupos y actualización de totales ───────────── - _progress(self, 95, "Actualizando totales...") - sql_errors: list = [] - if octave_desc: - descuenta_cupo_r_octava( - db=db, - desc_list=octave_desc, - tenant_id=tenant_id, - company_id=company_id, - sql_errors=sql_errors, - ) - _update_invoice_totals(invoice, lines) - - # ── Paso 8: Generar saldos en a24.balance_movement ─────────────────── - _progress(self, 98, "Generando saldos de inventario...") - if invoice_type not in {"DEF", "MEX"}: - create_balance_entries(db, invoice, lines) - - db.flush() + # ── Paso 3: Confirmar transacción ───────────────────────────────────── + _progress(self, 95, "Confirmando cambios...") db.commit() - - return { - "status": "success", - "invoice_id": invoice_id, - "sql_errors": sql_errors, - } + + _progress(self, 100, "Proceso completado.") + return result except ValidationException as exc: db.rollback() @@ -145,6 +61,7 @@ def process_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id } except Exception as exc: db.rollback() + logger.error(f"Error en process_invoice_task: {str(exc)}", exc_info=True) raise exc finally: db.close() diff --git a/backend/api/v1/modules/a76/invoices/imports/revert/main_process.py b/backend/api/v1/modules/a76/invoices/imports/revert/main_process.py index 3b1824ff..fd10f2d5 100644 --- a/backend/api/v1/modules/a76/invoices/imports/revert/main_process.py +++ b/backend/api/v1/modules/a76/invoices/imports/revert/main_process.py @@ -1,3 +1,4 @@ +from api.v1.modules.a76.app_settings.service import AppSettingsService from decimal import Decimal from typing import List @@ -20,7 +21,7 @@ def _validate_returned_quantities( db: Session, invoice: InvoiceHeader, lines: List[LineItem], - errors: ErrorCollector, + errors: ErrorCollector, cancelled_by: str = "SYSTEM", ) -> None: """ Verifica que ninguna partida tenga saldos pendientes por exportaciones @@ -162,7 +163,7 @@ def revert_process( lines: List[LineItem], tenant_id: str, company_id: str, - errors: ErrorCollector, + errors: ErrorCollector, cancelled_by: str = "SYSTEM", ) -> list: """ Proceso principal de des-actualización de una factura de importación @@ -210,4 +211,19 @@ def revert_process( # activos (ya validado arriba, pero se mantiene como doble seguro). void_balance_entries(db, invoice) + + # Auditoría de Desactualización + settings = AppSettingsService.get_resolved_settings(db, int(tenant_id), int(company_id)) + q_gen = settings.get("qsisgen", {}) + s_gen = settings.get("ssisgen", {}) + act_seguridad = int(q_gen.get("actseguridad") or s_gen.get("actseguridad") or q_gen.get("ActSeguridad") or s_gen.get("ActSeguridad", 0)) + + if act_seguridad == 1: + from api.v1.modules.a76.audit_log.services.service import AuditService + AuditService.create_audit_log( + db=db, reference=invoice.invoice_number, procedure="ANULAR FACTURA", movement="DESACTUALIZACION", + username=cancelled_by or "SYSTEM", tenant_id=int(tenant_id), company_id=int(company_id), + table_name="invoice_header", record_id=str(invoice.id), operation_type="UPDATE" + ) + return sql_errors diff --git a/backend/api/v1/modules/a76/items/exports/validators/common.py b/backend/api/v1/modules/a76/items/exports/validators/common.py index 0012e7bf..579ae7d8 100644 --- a/backend/api/v1/modules/a76/items/exports/validators/common.py +++ b/backend/api/v1/modules/a76/items/exports/validators/common.py @@ -1,6 +1,6 @@ from sqlalchemy import exists from sqlalchemy.orm import Session -from api.v1.modules.a76.invoices.common.common_validators import invoice_exists_by_id +from api.v1.modules.a76.invoices.common.common_validators import invoice_id_required from core.exceptions import ErrorCollector from sqlalchemy import func @@ -32,7 +32,7 @@ def validate_common( errors: ErrorCollector, line_number: int, ): - invoice: InvoiceHeader = invoice_exists_by_id( + invoice: InvoiceHeader = invoice_id_required( db, line.invoice_id, tenant_id, company_id, errors ) line_item: LineItem = item_exists(db, line.line_number, tenant_id, company_id) diff --git a/backend/api/v1/modules/a76/items/exports/validators/create.py b/backend/api/v1/modules/a76/items/exports/validators/create.py index 37efc39d..0e5792fc 100644 --- a/backend/api/v1/modules/a76/items/exports/validators/create.py +++ b/backend/api/v1/modules/a76/items/exports/validators/create.py @@ -53,8 +53,16 @@ def validate_create( if not line.class_id: errors.add_required_error(field=f"line[{line_number}].class_id") - if not line.quantity or not line.quantity.quantity or line.quantity.quantity <= 0: + if not line.quantity or line.quantity.quantity is None: errors.add_required_error(field=f"line[{line_number}].quantity.quantity") + elif line.quantity.quantity <= 0: + errors.add_error( + field=f"line[{line_number}].quantity.quantity", + message=f"La cantidad debe ser mayor a cero (recibido: {line.quantity.quantity})", + solution=["Capturar una cantidad válida mayor a cero."], + code="INVALID_QUANTITY", + value=float(line.quantity.quantity) + ) # TODO: Añadir validacion SSisGen:CalcularCostoUnitarioEnBaseAValorTotalScaf <-- de la tabla de preferencias de el sistema # if SSisGen:CalcularCostoUnitarioEnBaseAValorTotalScaf == False: @@ -68,8 +76,16 @@ def validate_create( field=f"line[{line_number}].financial.unit_cost_capture" ) - if not line.quantity or not line.quantity.net_weight or line.quantity.net_weight <= 0: + if not line.quantity or line.quantity.net_weight is None: errors.add_required_error(field=f"line[{line_number}].quantity.net_weight") + elif line.quantity.net_weight <= 0: + errors.add_error( + field=f"line[{line_number}].quantity.net_weight", + message=f"El peso neto debe ser mayor a cero (recibido: {line.quantity.net_weight})", + solution=["Capturar un peso neto válido mayor a cero."], + code="INVALID_NET_WEIGHT", + value=float(line.quantity.net_weight) + ) if not line.customs or not line.customs.origin_country: errors.add_required_error(field=f"line[{line_number}].customs.origin_country") diff --git a/backend/api/v1/modules/a76/items/exports/validators/update.py b/backend/api/v1/modules/a76/items/exports/validators/update.py index bee0f84a..3d464082 100644 --- a/backend/api/v1/modules/a76/items/exports/validators/update.py +++ b/backend/api/v1/modules/a76/items/exports/validators/update.py @@ -184,28 +184,28 @@ def validate_update( line.order = existing_line.order # Descripciones - if not line.description.description_spanish: + if line.description.description_spanish is None: line.description.description_spanish = ( existing_line.description.description_spanish ) - if not line.description.description_english: + if line.description.description_english is None: line.description.description_english = ( existing_line.description.description_english ) - if not line.description.extra_description: + if line.description.extra_description is None: line.description.extra_description = ( existing_line.description.extra_description ) # Marca y modelo - if line.description.brand: + if line.description.brand is not None: line.description.brand = line.description.brand.upper().strip() else: line.description.brand = existing_line.description.brand - if line.description.model: + if line.description.model is not None: line.description.model = line.description.model.upper().strip() else: line.description.model = existing_line.description.model diff --git a/backend/api/v1/modules/a76/items/imports/validators/calculations.py b/backend/api/v1/modules/a76/items/imports/validators/calculations.py index dcf97d16..f6532ebb 100644 --- a/backend/api/v1/modules/a76/items/imports/validators/calculations.py +++ b/backend/api/v1/modules/a76/items/imports/validators/calculations.py @@ -81,6 +81,11 @@ def calculate_values( return currency, currency_type, exchange_rate = result + + # Safety guard: Ensure nested objects exist before calculating + if not line.financial or not line.quantity: + return + # Prioridad: currency_type para alinear con create.py y CSV if currency_type in ("USD", "ME"): currency = "foreign" diff --git a/backend/api/v1/modules/a76/items/imports/validators/common.py b/backend/api/v1/modules/a76/items/imports/validators/common.py index f4c3b6ba..421e6316 100644 --- a/backend/api/v1/modules/a76/items/imports/validators/common.py +++ b/backend/api/v1/modules/a76/items/imports/validators/common.py @@ -1,6 +1,6 @@ from sqlalchemy import exists from sqlalchemy.orm import Session -from api.v1.modules.a76.invoices.common.common_validators import invoice_exists_by_id +from api.v1.modules.a76.invoices.common.common_validators import invoice_id_required from api.v1.modules.a76.items.imports.validators.calculations import apply_calculations from core.exceptions import ErrorCollector from sqlalchemy import func @@ -36,7 +36,7 @@ def validate_common( errors: ErrorCollector, line_number: int, ): - invoice: InvoiceHeader = invoice_exists_by_id( + invoice: InvoiceHeader = invoice_id_required( db, line.invoice_id, tenant_id, company_id, errors ) line_item: LineItem = item_exists(db, line.line_number, tenant_id, company_id) diff --git a/backend/api/v1/modules/a76/items/imports/validators/create.py b/backend/api/v1/modules/a76/items/imports/validators/create.py index d24969fa..35b20bae 100644 --- a/backend/api/v1/modules/a76/items/imports/validators/create.py +++ b/backend/api/v1/modules/a76/items/imports/validators/create.py @@ -52,8 +52,16 @@ def validate_create( if not line.class_id: errors.add_required_error(field=f"line[{line_number}].class_id") - if not line.quantity or not line.quantity.quantity or line.quantity.quantity <= 0: + if not line.quantity or line.quantity.quantity is None: errors.add_required_error(field=f"line[{line_number}].quantity.quantity") + elif line.quantity.quantity <= 0: + errors.add_error( + field=f"line[{line_number}].quantity.quantity", + message=f"La cantidad debe ser mayor a cero (recibido: {line.quantity.quantity})", + solution=["Capturar una cantidad válida mayor a cero."], + code="INVALID_QUANTITY", + value=float(line.quantity.quantity) + ) # TODO: Añadir validacion SSisGen:CalcularCostoUnitarioEnBaseAValorTotalScaf <-- de la tabla de preferencias de el sistema # if SSisGen:CalcularCostoUnitarioEnBaseAValorTotalScaf == False: @@ -67,8 +75,16 @@ def validate_create( field=f"line[{line_number}].financial.unit_cost_capture" ) - if not line.quantity or not line.quantity.net_weight or line.quantity.net_weight <= 0: + if not line.quantity or line.quantity.net_weight is None: errors.add_required_error(field=f"line[{line_number}].quantity.net_weight") + elif line.quantity.net_weight <= 0: + errors.add_error( + field=f"line[{line_number}].quantity.net_weight", + message=f"El peso neto debe ser mayor a cero (recibido: {line.quantity.net_weight})", + solution=["Capturar un peso neto válido mayor a cero."], + code="INVALID_NET_WEIGHT", + value=float(line.quantity.net_weight) + ) if not line.customs or not line.customs.origin_country: errors.add_required_error(field=f"line[{line_number}].customs.origin_country") diff --git a/backend/api/v1/modules/a76/items/imports/validators/update.py b/backend/api/v1/modules/a76/items/imports/validators/update.py index 93152823..f6dbbdc6 100644 --- a/backend/api/v1/modules/a76/items/imports/validators/update.py +++ b/backend/api/v1/modules/a76/items/imports/validators/update.py @@ -183,35 +183,51 @@ def validate_update( line.order = existing_line.order # Descripciones - if not line.description.description_spanish: + if line.description.description_spanish is None: line.description.description_spanish = ( existing_line.description.description_spanish ) - if not line.description.description_english: + if line.description.description_english is None: line.description.description_english = ( existing_line.description.description_english ) - if not line.description.extra_description: + if line.description.extra_description is None: line.description.extra_description = ( existing_line.description.extra_description ) - # Marca y modelo - if line.description.brand: + # Brand and model + if line.description.brand is not None: line.description.brand = line.description.brand.upper().strip() else: line.description.brand = existing_line.description.brand - if line.description.model: + if line.description.model is not None: line.description.model = line.description.model.upper().strip() else: line.description.model = existing_line.description.model - # Subpartidas (si aplica) - # TODO: Implementar lógica de subpartidas si Loc:LevantarSubpartidas = 'S' + # --- Resolve Settings for inherited parameters --- + from api.v1.modules.a76.app_settings.service import AppSettingsService + settings = AppSettingsService.get_resolved_settings(db, tenant_id, company_id) + inv_type = (invoice.invoice_type or "").strip().upper() + op_type = (invoice.operation_type or "").strip().lower() # 'imp' or 'exp' + + # Helper to get nested value from invoices.types.{op}.{type}.ssisgen.ssimpFormData + inv_map = settings.get("invoices", {}).get("types", {}).get(op_type, {}).get(inv_type, {}) + # Prefeir ssisgen for this type, then qsisgen, then root ssimpo + form_data = inv_map.get("ssisgen", {}).get("ssimpFormData", {}) or inv_map.get("qsisgen", {}).get("ssimpFormData", {}) or settings.get("ssimpo", {}) + + # Subpartidas (si aplica) + # Clarion: LOC:LevantarSubpartidas = S + levantar_sub = bool(form_data.get("levantar_subpartidas") or form_data.get("LevantarSubpartidas") or False) + if levantar_sub: + # TODO: Add specific sub-item validation if needed (e.g. parent_line mandatory if it's a subpartida) + # Currently we just ensure the field is carried over if not provided + pass # Número de parte if not line.part_number_id: @@ -229,7 +245,9 @@ def validate_update( if not line.valuation_method: if existing_line.valuation_method: line.valuation_method = existing_line.valuation_method - # else: TODO: Tomar de SisImp:MetValor (preferencias del sistema) + else: + # Tomar de SisImp/SisDef:MetValor (preferencias del sistema) + line.valuation_method = form_data.get("metvalor") or form_data.get("MetValor") # Número de entrada if not line.description.entry_number: diff --git a/backend/api/v1/modules/a76/items/models.py b/backend/api/v1/modules/a76/items/models.py index 514da30b..6628b672 100644 --- a/backend/api/v1/modules/a76/items/models.py +++ b/backend/api/v1/modules/a76/items/models.py @@ -14,7 +14,6 @@ from core.database import Base from api.v1.modules.a76.classes.models import Class from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure -from api.v1.modules.a76.general_catalogs.identifiers.models import IdentifierDetail if TYPE_CHECKING: from .line_financials.models import LineFinancial @@ -22,8 +21,6 @@ if TYPE_CHECKING: from .line_customs.models import LineCustom from .line_descriptions.models import LineDescription from .line_references.models import LineReference - from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem - from api.v1.modules.a76.parts.models import Part from api.v1.modules.a76.invoices.models import InvoiceHeader @@ -222,7 +219,7 @@ class LineItem(Base, TenantScopedMixin, TimestampMixin): uselist=False, ) identifiers: Mapped[List["IdentifierDetail"]] = relationship( - IdentifierDetail, + "IdentifierDetail", back_populates="line", cascade="all, delete-orphan", ) @@ -231,6 +228,11 @@ class LineItem(Base, TenantScopedMixin, TimestampMixin): foreign_keys=[part_number_id], viewonly=True, ) + component_part_info: Mapped[Optional["Part"]] = relationship( + "Part", + foreign_keys=[component_part_number_id], + viewonly=True, + ) # ============================================================================ @@ -387,3 +389,14 @@ MIGRATION STRATEGY FROM ORIGINAL TABLES TO NORMALIZED SCHEMA: ) ``` """ +# ============================================================================ +# RUNTIME IMPORTS FOR MAPPER RESOLUTION +# ============================================================================ +# We import these specialized models at the bottom to ensure they are registered +# in the SQLAlchemy metadata for relationship resolution while avoiding +# circular import issues in the module head. + +from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem +from api.v1.modules.a76.parts.models import Part +from api.v1.modules.a76.items.series.models import Serie +from api.v1.modules.a76.general_catalogs.identifiers.models import IdentifierDetail diff --git a/backend/api/v1/modules/a76/items/routes.py b/backend/api/v1/modules/a76/items/routes.py index 3f8fb8a8..a1b58388 100644 --- a/backend/api/v1/modules/a76/items/routes.py +++ b/backend/api/v1/modules/a76/items/routes.py @@ -44,7 +44,7 @@ async def create_item( - Each LineItem has one LineReference """ tenant_id = validate_access_to_resource(db, company_id, current_user) - + service = ItemService() return service.create(db, item_data, tenant_id, company_id) diff --git a/backend/api/v1/modules/a76/items/schemas.py b/backend/api/v1/modules/a76/items/schemas.py index 099a8571..a1383768 100644 --- a/backend/api/v1/modules/a76/items/schemas.py +++ b/backend/api/v1/modules/a76/items/schemas.py @@ -290,13 +290,15 @@ class LineItemResponse(LineItemBase): # Part identification part_number_id: Optional[int] = Field( - None, alias="part_number", serialization_alias="part_number_id" + None, alias="part_number_id_input", serialization_alias="part_number_id" ) + part_number: Optional[str] = None component_part_number_id: Optional[int] = Field( None, - alias="component_part_number", + alias="component_part_number_id_input", serialization_alias="component_part_number_id", ) + component_part_number: Optional[str] = None class_id: Optional[int] = None # Nested data @@ -325,33 +327,65 @@ class LineItemResponse(LineItemBase): @model_validator(mode="before") @classmethod def extract_relationship_info(cls, data: Any) -> Any: - """Extract class_code, class_description and unit_of_measure_code from relationships""" + """Extract information from joined relationships to provide flat mapping for UI.""" if isinstance(data, dict): + # If already a dict, ensure description syncs to top-level if missing + desc = data.get("description", {}) + if isinstance(desc, dict): + if not data.get("part_description_es"): + data["part_description_es"] = desc.get("description_spanish") + if not data.get("part_description_en"): + data["part_description_en"] = desc.get("description_english") return data # It's an ORM object result = {} - for key in cls.model_fields.keys(): - if hasattr(data, key): - result[key] = getattr(data, key) + + # 1. Start with model attributes (columns) + if hasattr(data, "__table__"): + for k in data.__table__.columns.keys(): + result[k] = getattr(data, k, None) + else: + # Fallback for non-table objects if any + for k, v in data.__dict__.items(): + if not k.startswith("_"): + result[k] = v - # Map model field names to schema field names for aliased fields - if hasattr(data, "part_number"): - result["part_number_id"] = data.part_number - if hasattr(data, "component_part_number"): - result["component_part_number_id"] = data.component_part_number + # Alias mapping for part numbers + if hasattr(data, "part_number_id") and "part_number_id" not in result: + result["part_number_id"] = data.part_number_id + if hasattr(data, "component_part_number_id") and "component_part_number_id" not in result: + result["component_part_number_id"] = data.component_part_number_id + + # Extract part info (string part numbers) from relationship objects + if hasattr(data, "part_info") and data.part_info is not None: + result["part_number"] = getattr(data.part_info, "part_number", None) + if hasattr(data, "component_part_info") and data.component_part_info is not None: + result["component_part_number"] = getattr(data.component_part_info, "part_number", None) # Extract class info if hasattr(data, "class_info") and data.class_info is not None: - result["class_code"] = data.class_info.class_code - result["class_description"] = data.class_info.description_es + result["class_code"] = getattr(data.class_info, "class_code", None) + result["class_description"] = getattr(data.class_info, "description_es", None) # Extract unit of measure code - if ( - hasattr(data, "unit_of_measure_info") - and data.unit_of_measure_info is not None - ): - result["unit_of_measure_code"] = data.unit_of_measure_info.code + if hasattr(data, "unit_of_measure_info") and data.unit_of_measure_info is not None: + result["unit_of_measure_code"] = getattr(data.unit_of_measure_info, "code", None) + + # 2. Extract nested objects and populate redundant descriptions + # We MUST use explicit getattr for relationships to ensure SQLAlchemy loads/uses joined-loaded ones + for key in ["financial", "quantity", "customs", "description", "reference", "fa_data", "series", "identifiers"]: + val = getattr(data, key, None) + if val is not None: + result[key] = val + # Sync to top-level for description redundancy (huge boost for UI stability) + if key == "description": + result["part_description_es"] = getattr(val, "description_spanish", None) + result["part_description_en"] = getattr(val, "description_english", None) + else: + # Provide default empty dict for core containers to help frontend + if key in ["financial", "quantity", "customs", "description"]: + result[key] = {} return result diff --git a/backend/api/v1/modules/a76/items/series/models.py b/backend/api/v1/modules/a76/items/series/models.py index 3adda326..0e9bdf45 100644 --- a/backend/api/v1/modules/a76/items/series/models.py +++ b/backend/api/v1/modules/a76/items/series/models.py @@ -16,10 +16,13 @@ class Serie(Base, TenantScopedMixin, TimestampMixin): serial_numbers: Mapped[Optional[str]] = mapped_column(String(50)) # SERIEEXPO model: Mapped[Optional[str]] = mapped_column(String(50)) # MODELOEXPO sub_model: Mapped[Optional[str]] = mapped_column(String(50)) # SUBMODELOEXPO - brand: Mapped[Optional[str]] = mapped_column(String(50)) # MARCAEXPO + brand: Mapped[Optional[str]] = mapped_column(String(50)) # MARCA + # expo_brand: Mapped[Optional[str]] = mapped_column(String(50)) # MARCAEXPO number_id: Mapped[Optional[str]] = mapped_column(String(25)) # NUMIDEXPO discharge: Mapped[Optional[bool]] = mapped_column(Boolean) # MARCA serie_row: Mapped[Optional[int]] = mapped_column(Integer) # LINEASERIEIMPO <-- IN CASE OF EXPO + # import_invoice: Mapped[Optional[str]] = mapped_column(String(15)) # FACTURAIMPO + # import_line: Mapped[Optional[int]] = mapped_column(Integer) # LINEAIMPO image_path: Mapped[Optional[str]] = mapped_column(String(255)) # PATH DE IMAGEN (MEX) diff --git a/backend/api/v1/modules/a76/items/series/schemas.py b/backend/api/v1/modules/a76/items/series/schemas.py index 53d947d7..bb7b42ee 100644 --- a/backend/api/v1/modules/a76/items/series/schemas.py +++ b/backend/api/v1/modules/a76/items/series/schemas.py @@ -8,10 +8,10 @@ class SerieBase(BaseModel): model: Optional[str] = Field(None, max_length=50, description="Model (MODELOEXPO)") sub_model: Optional[str] = Field(None, max_length=50, description="Sub model (SUBMODELOEXPO)") brand: Optional[str] = Field(None, max_length=50, description="Brand (MARCA)") - expo_brad: Optional[str] = Field(None, max_length=50, description="Expo brand (MARCAEXPO)") + # expo_brand: Optional[str] = Field(None, max_length=50, description="Expo brand (MARCAEXPO)") number_id: Optional[str] = Field(None, max_length=25, description="Number ID (NUMIDEXPO)") - import_invoice: Optional[str] = Field(None, max_length=15, description="Import invoice (FACTURAIMPO)") - import_line: Optional[int] = Field(None, description="Import line (LINEAIMPO)") + # import_invoice: Optional[str] = Field(None, max_length=15, description="Import invoice (FACTURAIMPO)") + # import_line: Optional[int] = Field(None, description="Import line (LINEAIMPO)") image_path: Optional[str] = Field(None, max_length=255, description="Image path (IMAGEPATHMEX)") diff --git a/backend/api/v1/modules/a76/items/service.py b/backend/api/v1/modules/a76/items/service.py index 5b6f9477..dd18a443 100644 --- a/backend/api/v1/modules/a76/items/service.py +++ b/backend/api/v1/modules/a76/items/service.py @@ -15,7 +15,7 @@ There is no intermediate Item entity anymore. Each LineItem belongs directly to import datetime import logging from decimal import Decimal -from typing import Optional, List, Tuple +from typing import Any, Optional, List, Tuple from fastapi import HTTPException from sqlalchemy import and_, case, func, or_, select from sqlalchemy.exc import IntegrityError @@ -145,6 +145,16 @@ class ItemService: ) return None + @staticmethod + def _filter_model_data(data: dict, model_class: Any) -> dict: + """Filter a dictionary to only include keys that exist as attributes in the model class.""" + if not data: + return {} + from sqlalchemy import inspect + mapper = inspect(model_class) + valid_keys = set(mapper.columns.keys()) + return {k: v for k, v in data.items() if k in valid_keys} + @staticmethod def _create_line_nested_data( db: Session, line: LineItem, line_data, tenant_id: int, company_id: int @@ -166,7 +176,9 @@ class ItemService: else data.model_dump() ) nested_dict["item_line_id"] = line.id - db.add(model_class(**nested_dict)) + # Filter dict against model attributes + filtered_dict = ItemService._filter_model_data(nested_dict, model_class) + db.add(model_class(**filtered_dict)) # FA data uses line.id as primary key if line_data.fa_data: @@ -185,7 +197,8 @@ class ItemService: if isinstance(line_data.series, list) else [line_data.series] ) - for s in series_list: + new_series = [] + for i, s in enumerate(series_list): serie_dict = ( s.model_dump(exclude_unset=True) if hasattr(s, "model_dump") @@ -193,12 +206,16 @@ class ItemService: ) if not serie_dict: continue - serie_dict["line_item_id"] = line.id - serie_dict["tenant_id"] = tenant_id - serie_dict["company_id"] = company_id + serie_dict.update({ + "tenant_id": tenant_id, + "company_id": company_id + }) if serie_dict.get("row") is None: - serie_dict["row"] = 1 - db.add(Serie(**serie_dict)) + serie_dict["row"] = i + 1 + + # Filter dict against model attributes + filtered_s = ItemService._filter_model_data(serie_dict, Serie) + db.add(Serie(**filtered_s)) # Identifier Detail data if hasattr(line_data, "identifiers") and line_data.identifiers: @@ -207,6 +224,7 @@ class ItemService: if isinstance(line_data.identifiers, list) else [line_data.identifiers] ) + new_ids = [] for d in id_list: id_dict = ( d.model_dump(exclude_unset=True) @@ -215,10 +233,15 @@ class ItemService: ) if not id_dict: continue - id_dict["item_line_id"] = line.id - id_dict["tenant_id"] = tenant_id - id_dict["company_id"] = company_id - db.add(IdentifierDetail(**id_dict)) + id_dict.update({ + "tenant_id": tenant_id, + "company_id": company_id + }) + + # Filter dict against model attributes + filtered_id = ItemService._filter_model_data(id_dict, IdentifierDetail) + new_ids.append(IdentifierDetail(**filtered_id)) + line.identifiers = new_ids @staticmethod def _attach_series(db: Session, item: LineItem) -> None: @@ -241,6 +264,7 @@ class ItemService: ) item.identifiers = list(identifiers) + @staticmethod def get_by_id( db: Session, item_id: int, tenant_id: int, company_id: int @@ -257,6 +281,8 @@ class ItemService: joinedload(LineItem.class_info), joinedload(LineItem.unit_of_measure_info), joinedload(LineItem.fa_data), + joinedload(LineItem.part_info), + joinedload(LineItem.component_part_info), ) .filter( LineItem.id == item_id, @@ -293,6 +319,8 @@ class ItemService: joinedload(LineItem.class_info), joinedload(LineItem.unit_of_measure_info), joinedload(LineItem.fa_data), + joinedload(LineItem.part_info), + joinedload(LineItem.component_part_info), ) .filter( LineItem.tenant_id == tenant_id, @@ -366,6 +394,8 @@ class ItemService: joinedload(LineItem.description), joinedload(LineItem.reference), joinedload(LineItem.fa_data), + joinedload(LineItem.part_info), + joinedload(LineItem.component_part_info), ) .filter( LineItem.invoice_id == invoice_id, @@ -505,20 +535,42 @@ class ItemService: ) # Create the item + # Filter main item_dict against LineItem model attributes + item_dict = ItemService._filter_model_data(item_dict, LineItem) db_item = LineItem(**item_dict) db.add(db_item) db.flush() # Get the item ID - # Create all nested data + # Create nested data ItemService._create_line_nested_data( db, db_item, item_data, tenant_id, company_id ) db.commit() - db.refresh(db_item) - ItemService._attach_series(db, db_item) - ItemService._attach_identifiers(db, db_item) - return db_item + + # Eager load EVERYTHING needed for the response before returning + final_item = ( + db.query(LineItem) + .options( + joinedload(LineItem.financial), + joinedload(LineItem.quantity), + joinedload(LineItem.customs), + joinedload(LineItem.description), + joinedload(LineItem.reference), + joinedload(LineItem.class_info), + joinedload(LineItem.unit_of_measure_info), + joinedload(LineItem.fa_data), + joinedload(LineItem.part_info), + joinedload(LineItem.component_part_info), + ) + .filter(LineItem.id == db_item.id) + .first() + ) + + if final_item: + ItemService._attach_series(db, final_item) + ItemService._attach_identifiers(db, final_item) + return final_item except IntegrityError as e: db.rollback() @@ -648,53 +700,111 @@ class ItemService: "reference", "fa_data", "series", + "identifiers", }, exclude_unset=True, ) - # Update item fields + # CONDITIONAL update of nested data to prevent data loss + # Perform in-place updates for one-to-one relations, full replacement for one-to-many + + # Update item attributes + # Filter main item_dict against LineItem model attributes + item_dict = ItemService._filter_model_data(item_dict, LineItem) for key, value in item_dict.items(): setattr(db_item, key, value) - # Delete existing nested data - db.query(LineFinancial).filter( - LineFinancial.item_line_id == db_item.id - ).delete() - db.query(LineQuantity).filter( - LineQuantity.item_line_id == db_item.id - ).delete() - db.query(LineCustom).filter(LineCustom.item_line_id == db_item.id).delete() - db.query(LineDescription).filter( - LineDescription.item_line_id == db_item.id - ).delete() - db.query(LineReference).filter( - LineReference.item_line_id == db_item.id - ).delete() - db.query(FaLineItem).filter(FaLineItem.id == db_item.id).delete() - db.query(Serie).filter(Serie.line_item_id == db_item.id).delete() - db.query(IdentifierDetail).filter(IdentifierDetail.item_line_id == db_item.id).delete() - db.flush() + # 2. Update nested one-to-one objects (In-place update) + nested_relations = [ + ('financial', LineFinancial, 'item_line_id'), + ('quantity', LineQuantity, 'item_line_id'), + ('customs', LineCustom, 'item_line_id'), + ('description', LineDescription, 'item_line_id'), + ('reference', LineReference, 'item_line_id') + ] - # Create new nested data - ItemService._create_line_nested_data( - db, db_item, item_data, tenant_id, company_id - ) + for attr_name, model_class, fk_name in nested_relations: + attr_data = getattr(item_data, attr_name) + if attr_data is not None: + db_nested = getattr(db_item, attr_name) + nested_dict = attr_data.model_dump(exclude_unset=True) + if db_nested: + # Update existing + for k, v in nested_dict.items(): + setattr(db_nested, k, v) + else: + # Create new + nested_dict[fk_name] = db_item.id + new_nested = model_class(**nested_dict) + setattr(db_item, attr_name, new_nested) + db.add(new_nested) + + # 3. Handle fa_data (special case as PK is shared) + if item_data.fa_data is not None: + fa_dict = item_data.fa_data.model_dump( + exclude_unset=True, exclude={"line_item_id", "includes_subitems"} + ) + if db_item.fa_data: + for k, v in fa_dict.items(): + setattr(db_item.fa_data, k, v) + else: + fa_dict.update({ + "id": db_item.id, + "tenant_id": tenant_id, + "company_id": company_id + }) + db_item.fa_data = FaLineItem(**fa_dict) + db.add(db_item.fa_data) + + # 4. Handle one-to-many arrays (Full replacement as these are collections) + if item_data.series is not None: + # Use synchronize_session='fetch' to ensure the session knows about the deletions + db.query(Serie).filter(Serie.line_item_id == db_item.id).delete(synchronize_session='fetch') + + for s_data in item_data.series: + s_dict = s_data.model_dump(exclude_unset=True) + s_dict.update({ + "line_item_id": db_item.id, + "tenant_id": tenant_id, + "company_id": company_id + }) + if s_dict.get("row") is None: + s_dict["row"] = 1 + + # Filter dict against model attributes + filtered_s = ItemService._filter_model_data(s_dict, Serie) + db.add(Serie(**filtered_s)) + + if item_data.identifiers is not None: + db.query(IdentifierDetail).filter(IdentifierDetail.item_line_id == db_item.id).delete() + for d in item_data.identifiers: + id_dict = d.model_dump(exclude_unset=True) + id_dict.update({ + "item_line_id": db_item.id, + "tenant_id": tenant_id, + "company_id": company_id + }) + + # Filter dict against model attributes + filtered_id = ItemService._filter_model_data(id_dict, IdentifierDetail) + db.add(IdentifierDetail(**filtered_id)) + + db.flush() # Renumber all lines for this invoice to ensure consecutive numbering ItemService._renumber_all_invoice_lines(db, db_item.invoice_id) db.commit() - db.refresh(db_item) - ItemService._attach_series(db, db_item) - ItemService._attach_identifiers(db, db_item) + db.refresh(db_item, ["financial", "quantity", "customs", "description", "reference", "fa_data", "identifiers"]) return db_item except HTTPException: raise except Exception as e: db.rollback() + import traceback logger.error(f"Unexpected error updating item: {e}") - raise HTTPException(status_code=500, detail="Error updating item") + raise HTTPException(status_code=500, detail=f"Error updating item: {str(e)}") @staticmethod def delete( diff --git a/backend/api/v1/modules/a76/layouts_csv/parts/routes.py b/backend/api/v1/modules/a76/layouts_csv/parts/routes.py index 2c479dfb..ff833444 100644 --- a/backend/api/v1/modules/a76/layouts_csv/parts/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/parts/routes.py @@ -11,7 +11,7 @@ from fastapi import APIRouter, File, HTTPException, Query, UploadFile, Depends from sqlalchemy.orm import Session from typing import Dict, Any -from core.celery_app import celery_app + from core.database import get_core_db from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch @@ -109,6 +109,7 @@ async def upload_import_file( @router.get("/{job_id}/status") async def get_import_status(job_id: str): + from core.celery_app import celery_app task_result = celery_app.AsyncResult(job_id) if task_result.state == "PENDING": diff --git a/backend/api/v1/modules/a76/pedmientos/models/pedimentos.py b/backend/api/v1/modules/a76/pedmientos/models/pedimentos.py index fe104ac7..4a29b11e 100644 --- a/backend/api/v1/modules/a76/pedmientos/models/pedimentos.py +++ b/backend/api/v1/modules/a76/pedmientos/models/pedimentos.py @@ -191,6 +191,7 @@ class Pedimentos(Base, TenantScopedMixin, TimestampMixin): uselist=False, back_populates="pedimento", cascade="all, delete-orphan", + lazy="joined", ) pedimento_decrementables: Mapped["PedimentoDecrementables"] = relationship( "PedimentoDecrementables", diff --git a/backend/api/v1/modules/core/tasks_tracking/service.py b/backend/api/v1/modules/core/tasks_tracking/service.py index 4d98672a..d3485d68 100644 --- a/backend/api/v1/modules/core/tasks_tracking/service.py +++ b/backend/api/v1/modules/core/tasks_tracking/service.py @@ -5,7 +5,6 @@ from celery.result import AsyncResult from sqlalchemy import asc, desc, func, or_ from sqlalchemy.orm import Session -from core.celery_app import celery_app from .models import TaskRun, TaskStatus @@ -142,6 +141,7 @@ class TaskTrackerService: return row def sync_task(self, task_run: TaskRun) -> TaskRun: + from core.celery_app import celery_app async_result = celery_app.AsyncResult(task_run.task_id) raw_state = (async_result.state or "PENDING").upper() normalized = normalize_celery_state(raw_state) diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index d7cae18a..d1dae953 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -1,24 +1,6 @@ import os from celery import Celery -# Orden: PedimentoCode y RegimenPedimento antes de CodePedimentoRegimen (mapper) -from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode -from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento -from api.v1.modules.public.reference_data.code_pedimento_regimens.models import ( - CodePedimentoRegimen, -) -# InvoiceType debe cargarse antes de InvoiceHeader (FK invoice_header.invoice_type -> public.invoice_types.key) -from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType # noqa: F401 -# CustomsSection debe cargarse antes de InvoiceComplianceMx (FK invoice_compliance_mx.aduana -> public.customs_sections.customs_code) -from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection # noqa: F401 - -# Import models in correct order for SQLAlchemy relationship resolution -# CRITICAL: FaLineItem must be imported BEFORE LineItem -from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem # noqa: F401 -from api.v1.modules.a76.items.models import LineItem # noqa: F401 -# CRITICAL: BalanceMovement must be loaded before DischargeDetail (FK a24.balance_movement) -from api.v1.modules.a24.balance_movements.models import BalanceMovement # noqa: F401 -from api.v1.modules.a24.discharges.models import DischargeHeader, DischargeDetail # noqa: F401 valkey_url = os.getenv("VALKEY_URL", "redis://valkey:6379/0") print(f"DEBUG: Celery Broker URL: {valkey_url}") @@ -31,6 +13,29 @@ celery_app = Celery( ) celery_app.set_default() +# ---------------------------------------------------------------------------- +# Import models in correct order for SQLAlchemy relationship resolution +# MUST happen AFTER celery_app exists to avoid circular imports during +# initialization when models trigger route/task imports. +# ---------------------------------------------------------------------------- +# Orden: PedimentoCode y RegimenPedimento antes de CodePedimentoRegimen (mapper) +from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode +from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento +from api.v1.modules.public.reference_data.code_pedimento_regimens.models import ( + CodePedimentoRegimen, +) +# InvoiceType debe cargarse antes de InvoiceHeader (FK invoice_header.invoice_type -> public.invoice_types.key) +from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType # noqa: F401 +# CustomsSection debe cargarse antes de InvoiceComplianceMx (FK invoice_compliance_mx.aduana -> public.customs_sections.customs_code) +from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection # noqa: F401 + +# CRITICAL: FaLineItem must be imported BEFORE LineItem +from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem # noqa: F401 +from api.v1.modules.a76.items.models import LineItem # noqa: F401 +# CRITICAL: BalanceMovement must be loaded before DischargeDetail (FK a24.balance_movement) +from api.v1.modules.a24.balance_movements.models import BalanceMovement # noqa: F401 +from api.v1.modules.a24.discharges.models import DischargeHeader, DischargeDetail # noqa: F401 + celery_app.conf.update( include=[ "api.v1.modules.a76.reports.importacion.facturas.task", diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte index 3ba8056a..f6505bab 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte @@ -43,11 +43,19 @@ } function handlePartSelect(part: any) { - lineItem.part_number = part.id; + lineItem.part_number_id = part.id; + lineItem.part_number = part.part_number; // Store part number for display (lineItem as any).part_number_display = part.part_number; (lineItem as any).part_description_es = part.description_spanish; (lineItem as any).part_description_en = part.description_english; + + // Update actual values if descriptions container exists + if (descriptions) { + if (part.description_spanish) descriptions.description_spanish = part.description_spanish; + if (part.description_english) descriptions.description_english = part.description_english; + } + if (!lineItem.fda_key && part.fda_key) { lineItem.fda_key = part.fda_key; } diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-series.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-series.svelte index 08671076..9e93e649 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-series.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-series.svelte @@ -4,7 +4,7 @@ import { Checkbox } from '$lib/components/ui/checkbox'; import { Label } from '$lib/components/ui/label'; import { Button } from '$lib/components/ui/button'; - import { Plus, Pencil } from 'lucide-svelte'; + import { Plus, Pencil, Trash2, CheckCircle2 } from 'lucide-svelte'; import type { Item, LineDescriptions, Serie } from '$lib/api/dashboard/a76/items'; import type { Invoice } from '$lib/api/dashboard/a76/invoices'; @@ -57,6 +57,19 @@ selectedSeriesIndex = null; } + function deleteSerie(index: number) { + const arr = ensureSeriesArray(); + const newArr = arr.filter((_, i) => i !== index); + // Update row numbers for remaining series + newArr.forEach((s, i) => (s.row = i + 1)); + series = newArr; + if (selectedSeriesIndex === index) { + selectedSeriesIndex = null; + } else if (selectedSeriesIndex !== null && selectedSeriesIndex > index) { + selectedSeriesIndex--; + } + } + // Current serie being edited (reference into the array) const currentSerie = $derived( selectedSeriesIndex !== null && seriesList[selectedSeriesIndex] != null @@ -65,12 +78,7 @@ ); const internalHasSerial = $derived(descriptions?.has_serial === true); - function toggleHasSerial() { - if (descriptions) { - descriptions.has_serial = !descriptions.has_serial; - } - } - + const hasSerial = $derived(internalHasSerial); // Ensure current serie has defaults for form fields @@ -159,16 +167,16 @@ -
+
- + - Línea - Serie - Modelo - Sub modelo - Núm. ID - Acciones + Línea + Serie + Modelo + Sub modelo + Núm. ID + Acciones @@ -187,22 +195,22 @@ onclick={() => hasSerial && selectForEdit(i)} > {serie.row ?? i + 1} - + {serie.serial_numbers || '-'} - + {serie.model || '-'} - + {serie.sub_model || '-'} - {serie.number_id || '-'} - + {serie.number_id || '-'} + + {/each} @@ -221,42 +242,57 @@ {#if currentSerie && selectedSeriesIndex !== null} -
+
- - {selectedSeriesIndex >= seriesList.length - 1 && !currentSerie?.id - ? 'Nueva serie' - : `Editar serie (línea ${currentSerie.row ?? selectedSeriesIndex + 1})`} - - +
+
+ + {selectedSeriesIndex >= (seriesList.length - 1) && !currentSerie?.id + ? 'Capturando Nueva serie' + : `Editando serie (línea ${currentSerie.row ?? selectedSeriesIndex + 1})`} + +
+
+ + +
- +
{invoiceNumber || '-'}
- +
{invoiceLine || '-'}
- +
- +
- +
- +
{partNumber || '-'}
- +
- +
+
+ +
{/if} -
+
{#if !hasSerial} Los datos capturados se conservan, pero la edición queda deshabilitada mientras "Lleva serie" esté apagado. + {:else} + Se permiten hasta {maxSeriesAllowed === Infinity ? 'ilimitadas' : maxSeriesAllowed} series para esta partida. {/if}
+ + diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte index 99d52d99..4aeeaca1 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte @@ -54,12 +54,13 @@ }; function handlePartSelect(part: any) { - editingItem.part_number = part.id; + editingItem.part_number_id = part.id; + editingItem.part_number = part.part_number; // Store part number for display (editingItem as any).part_number_display = part.part_number; if (editingItem.description) { - editingItem.description.description_spanish = part.description_spanish; - editingItem.description.description_english = part.description_english; + if (part.description_spanish) editingItem.description.description_spanish = part.description_spanish; + if (part.description_english) editingItem.description.description_english = part.description_english; } if (editingItem.customs) { editingItem.customs.fraction = part.fraction; @@ -93,8 +94,10 @@ function handleCountrySelect(country: any) { if (!editingItem.customs) editingItem.customs = {} as any; - editingItem.customs.origin_country = country.m3_key || country.mex_key; - (editingItem.customs as any).origin_country_name = country.description || country.description_en; + if (editingItem.customs) { + editingItem.customs.origin_country = country.m3_key || country.mex_key; + (editingItem.customs as any).origin_country_name = country.description || country.description_en; + } } function handleFractionSelect(fraction: any) { @@ -360,16 +363,28 @@
- -
- - {#if editingItem?.description} - - {/if} + +
+
+ + {#if editingItem?.description} + + {/if} +
+
+ + {#if editingItem?.description} + + {/if} +
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte index 9cd4aa80..dd6eb2b2 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte @@ -26,7 +26,7 @@ import ItemSheetInv from './inv/item-sheet-inv.svelte'; import { itemPresetsApi, type ItemPreset } from '$lib/api/dashboard/a76/item-presets'; import { Checkbox } from '$lib/components/ui/checkbox'; - import { cleanLineData } from '$lib/utils/items-logic'; + import { cleanLineData, normalizeItemData } from '$lib/utils/items-logic'; import { getVisibility } from '$lib/config/invoice-item-visibility'; let { @@ -263,40 +263,6 @@ } } - function normalizeItemData(item: Partial): Partial { - if (!item) return {}; - const normalizedItem = { ...item }; - - if (normalizedItem.financial) { - normalizedItem.financial = { - ...normalizedItem.financial, - unit_cost_usd: normalizedItem.financial.unit_cost_usd != null ? Number(normalizedItem.financial.unit_cost_usd) : undefined, - unit_cost_mxn: normalizedItem.financial.unit_cost_mxn != null ? Number(normalizedItem.financial.unit_cost_mxn) : undefined, - value_usd: normalizedItem.financial.value_usd != null ? Number(normalizedItem.financial.value_usd) : undefined, - value_mxn: normalizedItem.financial.value_mxn != null ? Number(normalizedItem.financial.value_mxn) : undefined - }; - } else { - normalizedItem.financial = { unit_cost_usd: undefined }; - } - - if (normalizedItem.quantity) { - normalizedItem.quantity = { - ...normalizedItem.quantity, - quantity: normalizedItem.quantity.quantity != null ? Number(normalizedItem.quantity.quantity) : undefined, - net_weight: normalizedItem.quantity.net_weight != null ? Number(normalizedItem.quantity.net_weight) : undefined, - gross_weight: normalizedItem.quantity.gross_weight != null ? Number(normalizedItem.quantity.gross_weight) : undefined, - package_quantity: normalizedItem.quantity.package_quantity != null ? Number(normalizedItem.quantity.package_quantity) : undefined - }; - } else { - normalizedItem.quantity = { quantity: undefined }; - } - - if (!normalizedItem.customs) normalizedItem.customs = { origin_country: undefined, fraction_type: undefined }; - if (!normalizedItem.description) normalizedItem.description = { description_spanish: undefined }; - if (!normalizedItem.fa_data) normalizedItem.fa_data = {}; - - return normalizedItem; - } function handleAdd() { if (!showCreatePresetDialog && !invoice?.id) { diff --git a/frontend/src/lib/components/dashboard/invoices/settings/sscr-settings-form.svelte b/frontend/src/lib/components/dashboard/invoices/settings/sscr-settings-form.svelte index a2272735..7d8cfc1b 100644 --- a/frontend/src/lib/components/dashboard/invoices/settings/sscr-settings-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/settings/sscr-settings-form.svelte @@ -120,7 +120,7 @@ PackingPedClave: false, // SCAF specific - AsignaValoresTC: 'exportacion', + ValFactTC: 'TCE', pagoimpuesto: 'no', formapago: '', SolicitarPswdDesactualizar: false, @@ -206,16 +206,16 @@ Asigna Valores a partidas en Base al T.C de: (formData.AsignaValoresTC = v)} + value={formData.ValFactTC} + onValueChange={(v) => (formData.ValFactTC = v)} class="flex gap-6 justify-center mt-2" >
- +
- +
diff --git a/frontend/src/lib/components/ui/dialog/index.ts b/frontend/src/lib/components/ui/dialog/index.ts index dce1d9dc..80edaf2a 100644 --- a/frontend/src/lib/components/ui/dialog/index.ts +++ b/frontend/src/lib/components/ui/dialog/index.ts @@ -9,8 +9,8 @@ import Description from "./dialog-description.svelte"; import Trigger from "./dialog-trigger.svelte"; import Close from "./dialog-close.svelte"; -const Root = DialogPrimitive.Root; -const Portal = DialogPrimitive.Portal; +const Root = DialogPrimitive?.Root ?? (class {} as any); +const Portal = DialogPrimitive?.Portal ?? (class {} as any); export { Root, diff --git a/frontend/src/lib/utils/items-logic.ts b/frontend/src/lib/utils/items-logic.ts index de44d0e4..2b78ffc2 100644 --- a/frontend/src/lib/utils/items-logic.ts +++ b/frontend/src/lib/utils/items-logic.ts @@ -1,170 +1,246 @@ - -export interface Item { - id?: number; - [key: string]: any; -} - -// Helper function to check if an object has any meaningful values -export function hasValues(obj: any): boolean { - if (!obj || typeof obj !== 'object') return false; - - // Si el objeto está intencionalmente vacío o tiene campos que serán usados, - // es mejor dejar que el backend valide si es requerido. - const values = Object.values(obj); - if (values.length === 0) return false; - - return values.some( - (val) => - val !== undefined && - val !== null && - val !== '' && - !(typeof val === 'object' && !hasValues(val)) - ); -} - -// Clean nested data before sending to API -export function cleanLineData(line: any) { - // 1. First, deeply copy and unwrap any Svelte Proxies to ensure a clean JS object - const rawLine = JSON.parse(JSON.stringify(line)); - const cleaned: any = { ...rawLine }; - - // Helper function to convert to number or undefined - const toNumberOrUndefined = (value: any): number | undefined => { - if (value === undefined || value === null || value === '') { - return undefined; - } - const numValue = Number(value); - return !isNaN(numValue) && isFinite(numValue) ? numValue : undefined; - }; - - // Explicitly keep mandatory fields for a76 schema - cleaned.invoice_id = toNumberOrUndefined(rawLine.invoice_id); - cleaned.line_number = toNumberOrUndefined(rawLine.line_number); - - // Reconstruction approach for core objects to be 100% sure - // We ALWAYS want these objects to exist in the payload even if all fields are null - // so the backend validation layer doesn't crash (AttributeError on None) - - cleaned.financial = { - unit_cost_usd: rawLine.financial ? toNumberOrUndefined(rawLine.financial.unit_cost_usd) : undefined, - unit_cost_mxn: rawLine.financial ? toNumberOrUndefined(rawLine.financial.unit_cost_mxn) : undefined, - unit_cost_capture: rawLine.financial ? toNumberOrUndefined(rawLine.financial.unit_cost_capture) : undefined, - value_mc: rawLine.financial ? toNumberOrUndefined(rawLine.financial.value_mc) : undefined, - value_usd: rawLine.financial ? toNumberOrUndefined(rawLine.financial.value_usd) : undefined, - value_mxn: rawLine.financial ? toNumberOrUndefined(rawLine.financial.value_mxn) : undefined - }; - - cleaned.quantity = { - quantity: rawLine.quantity ? (toNumberOrUndefined(rawLine.quantity.quantity) || 0) : 0, - net_weight: rawLine.quantity ? toNumberOrUndefined(rawLine.quantity.net_weight) : undefined, - gross_weight: rawLine.quantity ? toNumberOrUndefined(rawLine.quantity.gross_weight) : undefined, - package_id: rawLine.quantity ? toNumberOrUndefined(rawLine.quantity.package_id) : undefined, - package_quantity: rawLine.quantity ? toNumberOrUndefined(rawLine.quantity.package_quantity) : undefined - // NOTE: unit_of_measure lives at the top-level LineItem, NOT inside quantity - }; - - cleaned.description = { - description_spanish: rawLine.description ? (rawLine.description.description_spanish || '') : '', - description_english: rawLine.description ? (rawLine.description.description_english || '') : '', - brand: rawLine.description ? (rawLine.description.brand || '') : '', - model: rawLine.description ? (rawLine.description.model || '') : '' - }; - - cleaned.customs = { - fraction: rawLine.customs ? (rawLine.customs.fraction || undefined) : undefined, - american_fraction: rawLine.customs ? (rawLine.customs.american_fraction || undefined) : undefined, - origin_country: rawLine.customs ? (rawLine.customs.origin_country || undefined) : undefined, - fraction_type: rawLine.customs ? (rawLine.customs.fraction_type || undefined) : undefined - }; - - // Convert integer fields (only if they look like numbers/IDs) - if (cleaned.part_number && !isNaN(Number(cleaned.part_number))) { - cleaned.part_number = toNumberOrUndefined(cleaned.part_number); - } - - cleaned.component_part_number = toNumberOrUndefined(cleaned.component_part_number); - cleaned.class_id = toNumberOrUndefined(cleaned.class_id); - - // Ensure part_number is preserved if it's a string (common in some modules) - if (rawLine.part_number && !cleaned.part_number) { - cleaned.part_number = rawLine.part_number; - } - - cleaned.unit_of_measure = toNumberOrUndefined(cleaned.unit_of_measure); - cleaned.alternate_unit = toNumberOrUndefined(cleaned.alternate_unit); - - // Remove ALL display-only and UI-specific fields that the backend schema doesn't know about - delete cleaned.class_code; - delete cleaned.class_unit_of_measure; - delete cleaned.class_description; - delete cleaned.part_description_es; - delete cleaned.part_description_en; - delete cleaned.part_number_display; // UI display field for part number string - delete cleaned.unit_code; - delete cleaned.unit_description; - delete cleaned.includes_subitems; - delete cleaned.payment_method_description; - // Remove any other unknown top-level display fields - delete (cleaned as any).class_unit_of_measure_description; - - // Remove display-only fields from nested objects - if (cleaned.customs) { - delete cleaned.customs.origin_country_name; - delete cleaned.customs.fraction_description; - // DO NOT delete if empty - backend needs the object structure - } - - if (cleaned.fa_data) { - delete cleaned.fa_data.includes_subitems; - // Keep fa_data as is otherwise (unwrapped by stringify/parse above) - } - - if (cleaned.series != null) { - const arr = Array.isArray(cleaned.series) ? cleaned.series : [cleaned.series]; - cleaned.series = arr - .map((s: any) => { - if (!s || typeof s !== 'object') return null; - const { id, line_item_id, ...rest } = s; - return hasValues(rest) ? rest : null; - }) - .filter((s: any) => s != null); - if (cleaned.series.length === 0) delete cleaned.series; - } - - return cleaned; -} - -// Normalize numeric values from strings to numbers (for editing) -export function normalizeItemData(item: Partial): Partial { - if (item) { - const normalizedItem = { ...item }; - - // Normalize financials - if (normalizedItem.financial) { - const f = normalizedItem.financial; - normalizedItem.financial = { - ...f, - unit_cost_usd: f.unit_cost_usd != null ? Number(f.unit_cost_usd) : undefined, - unit_cost_mxn: f.unit_cost_mxn != null ? Number(f.unit_cost_mxn) : undefined, - value_usd: f.value_usd != null ? Number(f.value_usd) : undefined, - value_mxn: f.value_mxn != null ? Number(f.value_mxn) : undefined - }; - } - - // Normalize quantities - if (normalizedItem.quantity) { - const q = normalizedItem.quantity; - normalizedItem.quantity = { - ...q, - quantity: q.quantity != null ? Number(q.quantity) : undefined, - net_weight: q.net_weight != null ? Number(q.net_weight) : undefined, - gross_weight: q.gross_weight != null ? Number(q.gross_weight) : undefined, - package_quantity: q.package_quantity != null ? Number(q.package_quantity) : undefined - }; - } - - return normalizedItem; - } - - return item; -} + +// Helper function to convert to number or undefined +const toNumberOrUndefined = (value: any): number | undefined => { + if (value === undefined || value === null || value === '') { + return undefined; + } + const numValue = Number(value); + return !isNaN(numValue) && isFinite(numValue) ? numValue : undefined; +}; + +// Fields that should be converted to numbers if they exist +const NUMERIC_FIELDS = [ + // Top-level + 'invoice_id', 'line_number', 'part_number_id', 'component_part_number_id', 'class_id', + 'unit_of_measure', 'alternate_unit', 'consecutive_destination', 'consecutive_aphis', + 'bom_version', 'bill_version', 'tlcan_value', 'validation_zero', 'validation_one', + 'take_component_pt', 'pallet2', 'rectification', + + // Financial + 'unit_cost_capture', 'unit_cost_usd', 'unit_cost_commercial_usd', 'unit_cost_current_usd', + 'unit_cost_depreciated_usd', 'unit_cost_subitem_usd', 'unit_cost_auxiliary_usd', + 'sales_cost_usd', 'commercial_unit_cost', 'unit_cost_mxn', 'unit_cost_commercial_mxn', + 'unit_cost_current_mxn', 'unit_cost_depreciated_mxn', 'unit_cost_subitem_mxn', + 'sales_cost_mxn', 'unit_cost_mc', 'value_mxn', 'value_commercial_mxn', 'value_updated_mxn', + 'value_subitem_mxn', 'sub_import_value_mxn', 'value_returned_mxn', 'value_depreciated_mxn', + 'customs_value_mxn', 'value_total_mxn', 'value_temp_material_mxn', 'value_def_material_mxn', + 'value_added_mxn', 'value_national_packing_mxn', 'vat_mxn', 'vat_used_mxn', + 'advalorem_line_mxn', 'value_usd', 'value_commercial_usd', 'value_updated_usd', + 'value_subitem_usd', 'sub_import_value_usd', 'value_returned_usd', 'value_depreciated_usd', + 'customs_value_usd', 'value_auxiliary_usd', 'value_total_usd', 'value_temp_material_usd', + 'value_def_material_usd', 'value_added_usd', 'value_national_packing_usd', + 'value_us_packing_usd', 'vat_usd', 'vat_used_usd', 'value_non_originating_usd', + 'value_originating_usd', 'igi_amount_usd', 'exempt_amount_usd', 'total_commercial_value', + 'advalorem_line_usd', 'value_mc', 'sub_import_value_mc', 'vat_mc', 'value_added_mc', + 'value_national_packing_mc', 'value_total_mc', 'value_temp_material_mc', 'value_def_material_mc', + + // Quantity + 'quantity', 'alternate_quantity', 'quantity_uma', 'auxiliary_quantity', + 'quantity_temp_export', 'serial_count', 'net_weight', 'gross_weight', + 'package_id', 'package_quantity', 'container_quantity', + + // Customs + 'advalorem_numeric', 'advalorem_american', 'advalorem_tlcan', 'depreciation_rate', + + // Description + 'eighth_rule_line', + + // Series + 'row', 'serie_row', 'import_line', + + // Identifiers + 'invoice_consecutive', 'part_line', + + // FA Data + 'return_import_date', 'subitem_number', 'search_line' +]; + +// UI/Display only fields that should be removed before sending to API +const UI_BLACKLIST = [ + 'class_code', 'class_unit_of_measure', 'class_description', 'part_description_es', + 'part_description_en', 'part_number_display', 'unit_code', 'unit_description', + 'includes_subitems', 'payment_method_description', 'class_unit_of_measure_description', + 'origin_country_name', 'fraction_description', 'id' // Remove ID for creates, but update handles it via URL +]; + +// Clean nested data before sending to API +export function cleanLineData(line: any) { + // 1. Deep clone to unwrap proxies and avoid mutations + const cleaned = JSON.parse(JSON.stringify(line)); + + // 2. Helper to clean an object recursively + const processObject = (obj: any) => { + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return; + + Object.keys(obj).forEach(key => { + // Remove blacklisted fields + if (UI_BLACKLIST.includes(key)) { + delete obj[key]; + return; + } + + // Convert numeric fields - ONLY if it's not one of our known containers + const containers = ['financial', 'quantity', 'customs', 'description', 'fa_data', 'reference']; + const isContainer = containers.includes(key); + + if (NUMERIC_FIELDS.includes(key) && !isContainer) { + obj[key] = toNumberOrUndefined(obj[key]); + } + + // Recurse into nested objects if it's one of our known containers + if (isContainer && obj[key]) { + processObject(obj[key]); + } + }); + }; + + // 3. Process the top level and nested objects + processObject(cleaned); + + // 4. Special handling for Series and Identifiers (arrays) + if (cleaned.series && Array.isArray(cleaned.series)) { + cleaned.series = cleaned.series.map((s: any) => { + if (typeof s !== 'object') return s; + const sClean = { ...s }; + // Remove UI IDs from series rows if they are local temporary IDs + if (typeof sClean.id === 'string' && sClean.id.startsWith('temp-')) { + delete sClean.id; + } + processObject(sClean); + return sClean; + }); + } + + if (cleaned.identifiers && Array.isArray(cleaned.identifiers)) { + cleaned.identifiers = cleaned.identifiers.map((i: any) => { + if (typeof i !== 'object') return i; + processObject(i); + return i; + }); + } + + return cleaned; +} + +// Normalize numeric values from strings to numbers (for editing) +export function normalizeItemData(item: Partial): Partial { + if (!item) return {}; + + // Deep clone to avoid side effects + const normalized = JSON.parse(JSON.stringify(item)); + + const processNormalization = (obj: any) => { + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return; + + Object.keys(obj).forEach(key => { + const containers = ['financial', 'quantity', 'customs', 'description', 'fa_data', 'reference']; + const isContainer = containers.includes(key); + + if (NUMERIC_FIELDS.includes(key) && !isContainer) { + const val = obj[key]; + if (val !== undefined && val !== null && val !== '') { + const num = Number(val); + if (!isNaN(num)) { + obj[key] = num; + } + } + } + + if (isContainer && obj[key]) { + processNormalization(obj[key]); + } + }); + }; + + // Process top level and nested objects + processNormalization(normalized); + + // Ensure mandatory containers exist to allow binding and partial updates + const mandatoryContainers = ['financial', 'quantity', 'customs', 'description', 'fa_data', 'reference']; + mandatoryContainers.forEach(container => { + if (!normalized[container]) { + normalized[container] = {}; + } + }); + + // Process arrays + if (normalized.series && Array.isArray(normalized.series)) { + normalized.series.forEach((s: any) => processNormalization(s)); + } else { + normalized.series = []; + } + + if (normalized.identifiers && Array.isArray(normalized.identifiers)) { + normalized.identifiers.forEach((i: any) => processNormalization(i)); + } else { + normalized.identifiers = []; + } + + // 5. Popular campos de visualización para que el formulario los muestre al editar + if (normalized.part_number && !normalized.part_number_display) { + normalized.part_number_display = normalized.part_number; + } + + if (normalized.unit_of_measure_code && !normalized.unit_code) { + normalized.unit_code = normalized.unit_of_measure_code; + } + + if (normalized.customs?.origin_country && !normalized.origin_country_name) { + normalized.origin_country_name = normalized.customs.origin_country; + } + + // 5. Popular campos de visualización para que el formulario los muestre al editar + if (normalized.part_number && !normalized.part_number_display) { + normalized.part_number_display = normalized.part_number; + } + + if (normalized.unit_of_measure_code && !normalized.unit_code) { + normalized.unit_code = normalized.unit_of_measure_code; + } + + if (normalized.customs && normalized.customs.origin_country && !normalized.origin_country_name) { + normalized.origin_country_name = normalized.customs.origin_country; + } + + // Solo asegurar que el objeto description exista para que sea reactivo al editar + if (!normalized.description) { + normalized.description = { + description_spanish: normalized.part_description_es || '', + description_english: normalized.part_description_en || '' + }; + } else { + // Sincronización bidireccional entre raíz (redundancia) y objeto anidado + const descES = normalized.description.description_spanish || normalized.part_description_es || ''; + const descEN = normalized.description.description_english || normalized.part_description_en || ''; + + normalized.description.description_spanish = descES; + normalized.description.description_english = descEN; + normalized.part_description_es = descES; + normalized.part_description_en = descEN; + } + + return normalized; +} + +export interface Item { + id?: number; + [key: string]: any; +} + +// Helper function to check if an object has any meaningful values +export function hasValues(obj: any): boolean { + if (!obj || typeof obj !== 'object') return false; + + // Si el objeto está intencionalmente vacío o tiene campos que serán usados, + // es mejor dejar que el backend valide si es requerido. + const values = Object.values(obj); + if (values.length === 0) return false; + + return values.some( + (val) => + val !== undefined && + val !== null && + val !== '' && + !(typeof val === 'object' && !hasValues(val)) + ); +} diff --git a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte index cebf8012..f04b8f3d 100644 --- a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte @@ -388,54 +388,63 @@ }; } + // Resolved defaults logic: check both ssisgen and qsisgen keys in the nested settings + const resolvedDefaults = $derived.by(() => { + // FIX: data.defaultSettings is already the settings object for this specific type + const settings = data.defaultSettings || {}; + const ssisgen = settings.ssisgen || {}; + const qsisgen = settings.qsisgen || {}; + return { ...qsisgen, ...ssisgen }; + }); + // Referencias a los componentes de formulario para obtener sus datos // Si estamos en modo edición (!data.isCreate) y tenemos una factura, usarla - // Si estamos en modo creación, usar defaultSettings + // Si estamos en modo creación, usar resolvedDefaults let InvoiceTopFieldsFormData = $state( !data.isCreate && data.invoice ? mapInvoiceToTopFields(data.invoice) - : mergeDefaults(topFieldsSkeleton, data.defaultSettings?.InvoiceTopFieldsFormData) + : mergeDefaults(topFieldsSkeleton, resolvedDefaults.InvoiceTopFieldsFormData) ); let generalFormData = $state( !data.isCreate && data.invoice ? mapInvoiceToGeneral(data.invoice) - : mergeDefaults(generalSkeleton, data.defaultSettings?.generalFormData) + : mergeDefaults(generalSkeleton, resolvedDefaults.generalFormData) ); let observationFormData = $state( !data.isCreate && data.invoice ? mapInvoiceToObservations(data.invoice) - : mergeDefaults(observationSkeleton, data.defaultSettings?.observationFormData) + : mergeDefaults(observationSkeleton, resolvedDefaults.observationFormData) ); let itemsFormData = $state( !data.isCreate && data.invoice ? mapInvoiceToItems(data.invoice) - : ensureItemsFormData(data.defaultSettings?.itemsFormData) + : ensureItemsFormData(resolvedDefaults.itemsFormData) ); let othersFormData = $state( !data.isCreate && data.invoice ? mapInvoiceToOthers(data.invoice) - : mergeDefaults(othersSkeleton, data.defaultSettings?.othersFormData) + : mergeDefaults(othersSkeleton, resolvedDefaults.othersFormData) ); let continuationFormData = $state( !data.isCreate && data.invoice ? mapInvoiceToContinuation(data.invoice) - : mergeDefaults(continuationSkeleton, data.defaultSettings?.continuationFormData) + : mergeDefaults(continuationSkeleton, resolvedDefaults.continuationFormData) ); // Estados para saber si existen datos previos let observationExists = $state( - !data.isCreate ? !!data.invoice : !!data.defaultSettings?.observationFormData + !data.isCreate ? !!data.invoice : !!resolvedDefaults.observationFormData ); let itemsExists = $state( !data.isCreate ? !!(data.invoice?.items && data.invoice.items.length > 0) - : !!data.defaultSettings?.itemsFormData?.items?.length + : !!resolvedDefaults.itemsFormData?.items?.length ); let othersExists = $state( - !data.isCreate ? !!data.invoice : !!data.defaultSettings?.othersFormData + !data.isCreate ? !!data.invoice : !!resolvedDefaults.othersFormData ); let continuationExists = $state( - !data.isCreate ? !!data.invoice : !!data.defaultSettings?.continuationFormData + !data.isCreate ? !!data.invoice : !!resolvedDefaults.continuationFormData ); let calculatedExchangeRate = $state( @@ -885,6 +894,9 @@ if (!res.error && res.data) { const settings = res.data.settings || {}; + const ssisgen = settings.ssisgen || {}; + const qsisgen = settings.qsisgen || {}; + const combined = { ...qsisgen, ...ssisgen }; // Mark as loaded even if empty to prevent retries for the same combination lastLoadedKey = currentKey; @@ -895,13 +907,13 @@ // Re-apply merges with new defaults InvoiceTopFieldsFormData = mergeDefaults( topFieldsSkeleton, - settings.InvoiceTopFieldsFormData + combined.InvoiceTopFieldsFormData ); - generalFormData = mergeDefaults(generalSkeleton, settings.generalFormData); - observationFormData = mergeDefaults(observationSkeleton, settings.observationFormData); - itemsFormData = ensureItemsFormData(settings.itemsFormData); - othersFormData = mergeDefaults(othersSkeleton, settings.othersFormData); - continuationFormData = mergeDefaults(continuationSkeleton, settings.continuationFormData); + generalFormData = mergeDefaults(generalSkeleton, combined.generalFormData); + observationFormData = mergeDefaults(observationSkeleton, combined.observationFormData); + itemsFormData = ensureItemsFormData(combined.itemsFormData); + othersFormData = mergeDefaults(othersSkeleton, combined.othersFormData); + continuationFormData = mergeDefaults(continuationSkeleton, combined.continuationFormData); // Ensure types remain as selected InvoiceTopFieldsFormData.invoice_type = diff --git a/frontend/src/routes/dashboard/invoices/settings/+page.server.ts b/frontend/src/routes/dashboard/invoices/settings/+page.server.ts index 9228b1e0..852ba58b 100644 --- a/frontend/src/routes/dashboard/invoices/settings/+page.server.ts +++ b/frontend/src/routes/dashboard/invoices/settings/+page.server.ts @@ -118,6 +118,34 @@ export const load: PageServerLoad = async ({ cookies, fetch, url }) => { fetch ); + const valuationMethodsPromise = authenticatedFetch( + 'v1/public/reference_data/valuation-methods/?page=1&page_size=100', + {}, + cookies, + fetch + ); + + const legendsPromise = authenticatedFetch( + `v1/a76/legends/?company_id=${companyId}&page=1&page_size=200`, + {}, + cookies, + fetch + ); + + const sealsPromise = authenticatedFetch( + `v1/a76/seals/?company_id=${companyId}&page=1&page_size=100`, + {}, + cookies, + fetch + ); + + const enclosuresPromise = authenticatedFetch( + 'v1/public/reference_data/customs-warehouses/?page=1&page_size=100', + {}, + cookies, + fetch + ); + const pedimentosPromise = authenticatedFetch( `v1/a76/pedimentos/?company_id=${companyId}&page=1&page_size=100`, {}, @@ -141,6 +169,10 @@ export const load: PageServerLoad = async ({ cookies, fetch, url }) => { customsSectionsResponse, codePedimentoRegimensResponse, transportModesResponse, + valuationMethodsResponse, + legendsResponse, + sealsResponse, + enclosuresResponse, pedimentosResponse ] = await Promise.all([ invoiceTypesPromise, @@ -157,6 +189,10 @@ export const load: PageServerLoad = async ({ cookies, fetch, url }) => { customsSectionsPromise, codePedimentoRegimensPromise, transportModesPromise, + valuationMethodsPromise, + legendsPromise, + sealsPromise, + enclosuresPromise, pedimentosPromise ]); @@ -174,6 +210,10 @@ export const load: PageServerLoad = async ({ cookies, fetch, url }) => { const customsSections = customsSectionsResponse.ok ? await customsSectionsResponse.json() : { items: [] }; const codePedimentoRegimens = codePedimentoRegimensResponse.ok ? await codePedimentoRegimensResponse.json() : { items: [] }; const transportModes = transportModesResponse.ok ? await transportModesResponse.json() : { items: [] }; + const valuationMethods = valuationMethodsResponse.ok ? await valuationMethodsResponse.json() : { items: [] }; + const legends = legendsResponse.ok ? await legendsResponse.json() : { items: [] }; + const seals = sealsResponse.ok ? await sealsResponse.json() : { items: [] }; + const enclosures = enclosuresResponse.ok ? await enclosuresResponse.json() : { items: [] }; const pedimentos = pedimentosResponse.ok ? await pedimentosResponse.json() : { items: [] }; return { @@ -191,6 +231,10 @@ export const load: PageServerLoad = async ({ cookies, fetch, url }) => { customsSections: customsSections.items || [], codePedimentoRegimens: codePedimentoRegimens.items || [], transportModes: transportModes.items || [], + valuationMethods: valuationMethods.items || [], + legends: legends.items || [], + seals: seals.items || [], + enclosure: enclosures.items || [], pedimentos: pedimentos.items || [], companyId }; diff --git a/frontend/src/routes/dashboard/invoices/settings/+page.svelte b/frontend/src/routes/dashboard/invoices/settings/+page.svelte index ed0131d8..70e1869a 100644 --- a/frontend/src/routes/dashboard/invoices/settings/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/settings/+page.svelte @@ -97,22 +97,18 @@ async function loadSettings() { const cid = companyStore?.activeCompany?.id; if (!selectedInvoiceType || !selectedOperationType || !cid) { - console.log('LOAD: Skipping - Missing Context', { selectedInvoiceType, selectedOperationType, cid }); return; } const currentKey = `${selectedOperationType}:${selectedInvoiceType}:${cid}`; if (currentKey === lastLoadedKey && !isLoading) { - console.log('LOAD: Skipping - Key match', currentKey); return; } if (isLoading) { - console.log('LOAD: Skipping - Already Loading'); return; } - console.log('LOAD: Starting...', currentKey); isLoading = true; try { const res = await api.get( @@ -122,7 +118,6 @@ if (res.status === 200 || res.data) { const settingsData = res.data; lastLoadedKey = currentKey; - console.log('LOAD: Success', { hasSettings: !!settingsData?.settings }); if (settingsData && settingsData.settings) { rawSettings = settingsData.settings; applySettings(rawSettings); @@ -131,7 +126,6 @@ resetForms(); } } else { - console.log('LOAD: Response format mismatch or failed', res.status); lastLoadedKey = currentKey; rawSettings = {}; resetForms(); @@ -142,7 +136,6 @@ rawSettings = {}; resetForms(); } finally { - console.log('LOAD: Finished'); isLoading = false; } } @@ -406,12 +399,14 @@ if (obj !== null && typeof obj === 'object') { return Object.entries(obj).reduce((acc: any, [key, value]) => { const cleaned = cleanObject(value); - if ( + // Explicitly keep 0, 0.0, and false as they are common valid settings + const isValidValue = cleaned !== null && cleaned !== undefined && cleaned !== '' && - !(typeof cleaned === 'object' && Object.keys(cleaned).length === 0) - ) { + !(typeof cleaned === 'object' && Object.keys(cleaned).length === 0); + + if (isValidValue || cleaned === 0 || cleaned === false) { acc[key] = cleaned; } return acc; @@ -516,13 +511,11 @@ // Handle explicit changes function handleOperationTypeChange(v: string) { - console.log('UI: Operation type change:', v); selectedOperationType = v; loadSettings(); } function handleInvoiceTypeChange(v: string) { - console.log('UI: Invoice type change:', v); selectedInvoiceType = v; loadSettings(); } @@ -659,9 +652,11 @@ {invoice} bind:formData={observationFormData} bind:exists={observationExists} - seals={[]} + seals={data.seals || []} + legends={data.legends || []} + valuationMethods={data.valuationMethods || []} incoterms={data.incoterms || []} - enclosure={[]} + enclosure={data.enclosure || []} operationType={operationTypeNumeric} {invoiceType} isSettings={true}