Implement invoice revert functionality and enhance progress tracking

- Added a new route for reverting invoices in the A76 module.
- Updated the pre_validators to provide clearer error messages when processing invoices.
- Enhanced the PDF progress dialog to support step-by-step progress tracking for both invoice processing and reverting.
- Introduced a confirmation dialog for reverting invoices in the dashboard.
- Updated frontend components to handle the new revert functionality and display appropriate progress messages.
This commit is contained in:
AlexeerCT
2026-03-15 22:34:11 -06:00
parent a4cf394447
commit 6fa461991f
14 changed files with 738 additions and 18 deletions

View File

@@ -26,7 +26,6 @@ import datetime
from decimal import Decimal
from enum import Enum
from typing import TYPE_CHECKING, List, Optional
from ..discharges.models import DischargeDetail
from sqlalchemy import (
BigInteger,

View File

@@ -9,12 +9,14 @@ def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_
if invoice.status == InvoiceStatus.PROCESSED:
errors.add_error(
"status",
"La factura ya fue procesada y no puede ser exportada",
solution=["Verifique el estatus de la factura antes de intentar exportarla"],
"La factura ya fue procesada y no puede volver a actualizarse. Desactualícela primero.",
solution=["Use el botón 'Desactualizar' antes de volver a procesar la factura."],
code="ALREADY_PROCESSED",
value=invoice.status,
)
errors.raise_if_errors()
return
if not invoice.invoice_date:
errors.add_required_error("invoice_date")

View File

@@ -0,0 +1,234 @@
from decimal import Decimal
from typing import List
from sqlalchemy.orm import Session
from api.v1.modules.a24.discharges.models import DischargeDetail, DischargeHeader, DischargeStatus
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 .sub_process.review_rule_octave import borra_saldos_regla_octava
# ─────────────────────────────────────────────────────────────────────────────
# Validación de cantidades retornadas con detalle de exportaciones activas
# ─────────────────────────────────────────────────────────────────────────────
def _validate_returned_quantities(
db: Session,
invoice: InvoiceHeader,
lines: List[LineItem],
errors: ErrorCollector,
) -> None:
"""
Verifica que ninguna partida tenga saldos pendientes por exportaciones
activas que la afecten.
Paridad: bloque 'REVISA CANTIDADES RETORNADAS' (Clarion SCAII).
El Clarion recorre las partidas con (CantRetornadaTemp + CantRetornada +
CantExistencia) <> 0 y luego busca en QEqeMaq (exportaciones definitivas),
QEqeMaqRep (exportaciones de reparación) y QEqiMaqRep (importaciones de
reparación) para identificar qué factura de exportación activa (Estatus='AC')
tiene esa partida descargada.
En Python los mismos vínculos viven en:
DischargeDetail.import_item_line_id → la partida de importación consumida
DischargeDetail.header → DischargeHeader
DischargeHeader.source_invoice_id → InvoiceHeader (la factura de exportación)
InvoiceHeader.status → InvoiceStatus.PROCESSED (≡ Estatus='AC')
Para cada partida con saldo se buscan DischargeDetail con status APPLIED en
una factura de exportación procesada y se reporta qué factura debe
desactualizarse primero.
"""
lines_with_balance = [
line for line in lines
if line.quantity is not None and (
(line.quantity.quantity_returned_temp or Decimal(0))
+ (line.quantity.quantity_returned or Decimal(0))
+ (line.quantity.quantity_existence or Decimal(0))
) != Decimal(0)
]
if not lines_with_balance:
return
for line in lines_with_balance:
qty_ret_temp = line.quantity.quantity_returned_temp or Decimal(0)
qty_ret = line.quantity.quantity_returned or Decimal(0)
qty_exist = line.quantity.quantity_existence or Decimal(0)
# Buscar DischargeDetail vinculados a esta partida de importación
# cuya factura de exportación esté activa (PROCESSED).
# Paridad: bucle sobre QEqeMaq/QEqeMaqRep donde Descarga=1 y
# encabezado de exportación con Estatus='AC'.
details: List[DischargeDetail] = (
db.query(DischargeDetail)
.join(DischargeDetail.header)
.filter(
DischargeDetail.import_item_line_id == line.id,
DischargeHeader.status == DischargeStatus.APPLIED,
)
.all()
)
active_details = [
d for d in details
if d.header
and d.header.source_invoice is not None
and d.header.source_invoice.status == InvoiceStatus.PROCESSED
]
if active_details:
# Reportar un error por cada factura de exportación activa distinta
# (equivale a QueErr en el Clarion).
seen_export_invoices: set = set()
for detail in active_details:
src_invoice = detail.header.source_invoice
src_number = src_invoice.invoice_number or str(src_invoice.id)
if src_number in seen_export_invoices:
continue
seen_export_invoices.add(src_number)
export_line_number = (
detail.export_line.line_number if detail.export_line else "?"
)
errors.add_error(
field=f"line[{line.line_number}].discharge",
message=(
f"La Línea: {line.line_number} se ha descargado "
f"{detail.quantity_discharged} en la factura: {src_number} "
f"de exportación con Línea: {export_line_number}."
),
solution=[
f"Desactualizar la factura: {src_number} "
"para regresar saldos a la partida."
],
code="LINE_HAS_ACTIVE_DISCHARGE",
)
else:
# La partida tiene saldo pero no hay descarga activa rastreable —
# reportar el saldo directamente para que el usuario lo investigue.
errors.add_error(
field=f"line[{line.line_number}].quantities",
message=(
f"La Línea: {line.line_number} tiene saldos pendientes "
f"(retornada: {qty_ret}, retornada temp: {qty_ret_temp}, "
f"existencia: {qty_exist}) y no se puede desactualizar."
),
solution=[
"Verifique las exportaciones que afectan a esta partida "
"y desactualícelas primero."
],
code="LINE_HAS_BALANCE",
)
# ─────────────────────────────────────────────────────────────────────────────
# Reset de la factura e inventario
# ─────────────────────────────────────────────────────────────────────────────
def _reset_invoice_financials(invoice: InvoiceHeader) -> None:
"""
Reinicia los totales financieros del encabezado de la factura a cero y
cambia el estatus a PENDING (equivalente a Estatus='NA' en Clarion).
Paridad: UPDATE QFacImp SET CantImpo=0, PesoNeto=0, PesoBruto=0,
Cantbultos=0, ValorImpoMN=0, ValorImpoME=0, ValorImpoMC=0,
ValorAduanasMN=0, ValorAduanasME=0, Estatus='NA',
ComofueProcesada='', ValorIVAMN=0, ValorIVAME=0 (Clarion SCAII).
"""
fin = invoice.financials
if fin is None:
return
fin.total_quantity = 0.0
fin.net_weight = 0.0
fin.gross_weight = 0.0
fin.total_packages = 0
fin.value_mn = 0.0
fin.value_me = 0.0
fin.value_mc = 0.0
fin.customs_value_mn = 0.0
fin.customs_value_me = 0.0
fin.iva_mn = 0.0
fin.iva_me = 0.0
invoice.status = InvoiceStatus.PENDING
invoice.process_method = None
def _reset_line_quantities(lines: List[LineItem]) -> None:
"""
Reinicia los contadores de inventario de cada partida a cero.
Paridad: UPDATE QEqiMaq SET CantRetornada=0, CantRetornadaTemp=0,
ValorRetornadoMN=0, ValorRetornadoME=0, CantExistencia=0,
ValorIVAMNUsado=0, ValorIVAMEUsado=0 (Clarion SCAII).
"""
for line in lines:
if line.quantity is not None:
line.quantity.quantity_returned = Decimal(0)
line.quantity.quantity_returned_temp = Decimal(0)
line.quantity.quantity_existence = Decimal(0)
if line.financial is not None:
line.financial.value_returned_mxn = Decimal(0)
line.financial.value_returned_usd = Decimal(0)
line.financial.vat_used_mxn = Decimal(0)
line.financial.vat_used_usd = Decimal(0)
# ─────────────────────────────────────────────────────────────────────────────
# Proceso principal de reversión
# ─────────────────────────────────────────────────────────────────────────────
def revert_process(
db: Session,
invoice: InvoiceHeader,
lines: List[LineItem],
tenant_id: str,
company_id: str,
errors: ErrorCollector,
) -> list:
"""
Proceso principal de des-actualización de una factura de importación
temporal.
Paridad: rutina principal 'DESACTUALIZAR FACTURA IMPO TEMP' (Clarion SCAII).
Flujo:
1. Verifica que ninguna partida tenga saldos activos por exportaciones
procesadas (QueueErrorAct en Clarion). Si los hay → ValidationException.
2. Si no hay errores de validación:
a. Do BORRASALDOS_REGLA_OCTAVA — revierte cupos de Regla Octava.
b. UPDATE QFacImp — reinicia totales del encabezado (status → PENDING).
c. UPDATE QEqiMaq — reinicia contadores de inventario por partida.
3. Retorna sql_errors (errores no-bloqueantes de BD, equivalente a
QueueErrorSQL en Clarion).
Raises:
ValidationException: si hay partidas con descargas activas
(equivale a Records(QueueErrorAct) <> 0).
"""
# ── Paso 1: REVISA CANTIDADES RETORNADAS ──────────────────────────────────
_validate_returned_quantities(db, invoice, lines, errors)
errors.raise_if_errors()
# ── Paso 2a: Do BORRASALDOS_REGLA_OCTAVA ─────────────────────────────────
sql_errors: list = []
borra_saldos_regla_octava(
db=db,
invoice_import=invoice.invoice_number or "",
tenant_id=tenant_id,
company_id=company_id,
sql_errors=sql_errors,
)
# ── Paso 2b: UPDATE QFacImp ───────────────────────────────────────────────
_reset_invoice_financials(invoice)
# ── Paso 2c: UPDATE QEqiMaq ───────────────────────────────────────────────
_reset_line_quantities(lines)
return sql_errors

View File

@@ -0,0 +1,42 @@
from typing import List
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 core.exceptions import ErrorCollector
def pre_validators(
db: Session,
invoice: InvoiceHeader,
tenant_id: str,
company_id: str,
errors: ErrorCollector,
) -> List[LineItem]:
"""
Validaciones previas a la reversión de una factura de importación temporal.
- Verifica que la factura esté en estatus PROCESSED.
- Carga y retorna las partidas asociadas a la factura.
"""
if invoice.status != InvoiceStatus.PROCESSED:
errors.add_error(
"status",
"La factura no fue procesada y no puede ser revertida",
solution=["Verifique el estatus de la factura antes de intentar deshacer el proceso"],
code="NOT_PROCESSED",
value=invoice.status,
)
lines: List[LineItem] = (
db.query(LineItem)
.filter(
LineItem.invoice_id == invoice.id,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id,
)
.all()
)
return lines

View File

@@ -0,0 +1,76 @@
from typing import Any, Dict
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
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 .task import revert_invoice_task
router = APIRouter()
@router.post("/invoices/{invoice_id}/revert")
def trigger_invoice_revert(
invoice_id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Inicia la des-actualización de una factura de importación temporal como
tarea Celery.
Retorna el task_id para hacer polling del progreso.
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
task = revert_invoice_task.apply_async(
args=[invoice_id, str(tenant_id), str(company_id)]
)
return {"task_id": task.id}
@router.get("/invoices/revert/{task_id}/status")
def get_invoice_revert_status(task_id: str):
"""
Consulta el estado de progreso de una tarea de des-actualización de
factura.
Retorna:
- state: 'PROCESSING' | 'SUCCESS' | 'FAILURE'
- info: { current: int, status: str } (cuando state == 'PROCESSING')
- result: dict (cuando state == 'SUCCESS' o 'FAILURE')
"""
task_result = celery_app.AsyncResult(task_id)
if task_result.state in ("PENDING", "STARTED"):
return {
"state": "PROCESSING",
"info": {"current": 0, "status": "Iniciando..."},
}
if task_result.state == "PROGRESS":
return {
"state": "PROCESSING",
"info": task_result.info or {"current": 0, "status": "Procesando..."},
}
if task_result.state == "SUCCESS":
return {
"state": "SUCCESS",
"result": task_result.result,
}
error_info = task_result.result
if isinstance(error_info, Exception):
error_msg = str(error_info)
else:
error_msg = str(error_info) if error_info else "Error desconocido"
return {
"state": "FAILURE",
"result": error_msg,
}

View File

@@ -0,0 +1,94 @@
from decimal import Decimal
from typing import List
from sqlalchemy.orm import Session
from api.v1.modules.a76.rule_octave.balances.models import OctaveBalance
from api.v1.modules.a76.rule_octave.fractions.models import FractionRuleOctave
def borra_saldos_regla_octava(
db: Session,
invoice_import: str,
tenant_id: str,
company_id: str,
sql_errors: list,
) -> None:
"""
Revierte los saldos de Regla Octava registrados al procesar una factura de
importación temporal.
Paridad: BORRASALDOS_REGLA_OCTAVA (Clarion SCAII).
Por cada registro en SSaldosReglaOctava (OctaveBalance) con origin='TEM' y
system='SCAF' que corresponda a la factura:
1. Resta de vuelta la cantidad y el valor en GFracROctava (FractionRuleOctave).
2. Elimina el registro de OctaveBalance.
Los errores de actualización se acumulan en sql_errors como dicts con las
claves 'consecutive' y 'error'.
"""
consecutive_ref = [0]
balances: List[OctaveBalance] = (
db.query(OctaveBalance)
.filter(
OctaveBalance.tenant_id == tenant_id,
OctaveBalance.company_id == company_id,
OctaveBalance.invoice_import == invoice_import,
OctaveBalance.origin == "TEM",
OctaveBalance.system == "SCAF",
)
.all()
)
for balance in balances:
fra_oct = (
db.query(FractionRuleOctave)
.filter(
FractionRuleOctave.tenant_id == tenant_id,
FractionRuleOctave.company_id == company_id,
FractionRuleOctave.permission == balance.octave_permit,
FractionRuleOctave.line == balance.line,
)
.first()
)
# REGRESAR EL SALDO AL PERMISO DE REGLA OCTAVA
# Si el permiso no existe (TryFetch falla en Clarion), se omite el PUT
# pero el DELETE del saldo se ejecuta de todas formas — paridad Clarion.
if fra_oct is not None:
qty_back = balance.quantity_stock or Decimal(0)
val_back = balance.value_me or Decimal(0)
fra_oct.quantity_used = max(
Decimal(0),
(fra_oct.quantity_used or Decimal(0)) - qty_back,
)
fra_oct.value_used = max(
Decimal(0),
(fra_oct.value_used or Decimal(0)) - val_back,
)
try:
db.flush([fra_oct])
except Exception as exc:
consecutive_ref[0] += 1
sql_errors.append({
"consecutive": consecutive_ref[0],
"error": (
f"Error al regresar el Cupo en (Permiso de Regla Octava) {exc}"
),
})
# DELETE(SSaldosReglaOctava) — siempre se intenta, igual que en Clarion
try:
db.delete(balance)
db.flush([balance])
except Exception as exc:
consecutive_ref[0] += 1
sql_errors.append({
"consecutive": consecutive_ref[0],
"error": (
f"Error al Eliminar en (SSaldosReglaOctava) {exc}"
),
})

View File

@@ -0,0 +1,82 @@
from celery import Task
from core.celery_app import celery_app
from core.database import CoreSessionLocal
from core.exceptions import ErrorCollector, ValidationException
from api.v1.modules.a76.invoices.models import InvoiceHeader
from .pre_validators import pre_validators
from .main_process import revert_process
def _progress(task: Task, current: int, status: str) -> None:
task.update_state(state="PROGRESS", meta={"current": current, "status": status})
@celery_app.task(bind=True, name="revert_invoice_task")
def revert_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id: str) -> dict:
"""
Des-actualiza una factura de importación temporal ejecutando todas las
validaciones y reversiones del proceso principal (revert/main_process) con
reporte de progreso.
"""
db = CoreSessionLocal()
try:
# ── Paso 1: Cargar factura ────────────────────────────────────────────
_progress(self, 5, "Cargando factura...")
invoice: InvoiceHeader | None = db.get(InvoiceHeader, invoice_id)
if invoice is None:
return {
"status": "error",
"message": f"Factura con id {invoice_id} no encontrada.",
"errors": [],
}
errors = ErrorCollector()
# ── Paso 2: Pre-validaciones ──────────────────────────────────────────
_progress(self, 10, "Validando estatus 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 no contiene partidas para revertir",
solution=["Verifique que la factura tenga partidas antes de intentar revertirla"],
code="NO_LINE_ITEMS",
)
errors.raise_if_errors()
# ── Paso 3: Validar cantidades y ejecutar reversión ───────────────────
_progress(self, 40, "Verificando saldos de partidas...")
sql_errors = revert_process(
db=db,
invoice=invoice,
lines=lines,
tenant_id=tenant_id,
company_id=company_id,
errors=errors,
)
# ── Paso 4: Confirmar transacción ─────────────────────────────────────
_progress(self, 95, "Confirmando cambios...")
db.flush()
db.commit()
return {
"status": "success",
"invoice_id": invoice_id,
"sql_errors": sql_errors,
}
except ValidationException as exc:
db.rollback()
return {
"status": "validation_error",
"message": exc.message,
"errors": exc.errors,
}
except Exception as exc:
db.rollback()
raise exc
finally:
db.close()

View File

@@ -11,6 +11,7 @@ from .customs_brokers.routes import router as customs_broker_router
from .general_catalogs.router import router as general_catalogs_router
from .invoices.routes import router as invoices_router
from .invoices.imports.process.routes import router as invoice_process_router
from .invoices.imports.revert.routes import router as invoice_revert_router
from .items.routes import router as items_router
from .classes.routes import router as classes_router
@@ -59,6 +60,7 @@ router = APIRouter()
router.include_router(general_catalogs_router, prefix="/a76", tags=["a76 / general_catalogs"])
router.include_router(invoices_router, prefix="/a76", tags=["a76 / invoices"])
router.include_router(invoice_process_router, prefix="/a76", tags=["a76 / invoices"])
router.include_router(invoice_revert_router, prefix="/a76", tags=["a76 / invoices"])
router.include_router(items_router, prefix="/a76", tags=["a76 / items"])
router.include_router(imports_router, prefix="/a76/imports", tags=["a76 / imports"])
router.include_router(exportacion_imports_router, prefix="/a76/imports/exportacion", tags=["a76 / imports / exportacion"])

View File

@@ -16,6 +16,9 @@ from api.v1.modules.public.reference_data.customs_sections.models import Customs
# 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}")
@@ -60,6 +63,7 @@ celery_app.conf.update(
"api.v1.modules.core.help_center.tasks",
"api.v1.modules.core.help_center.tasks",
"api.v1.modules.a76.invoices.imports.process.task",
"api.v1.modules.a76.invoices.imports.revert.task",
] # Ruta al módulo donde están las tareas
)

View File

@@ -515,5 +515,29 @@ export const invoicesApi = {
sql_errors?: Array<{ consecutive: number; error: string }>;
};
}>(`/v1/a76/invoices/process/${taskId}/status`);
},
revertInvoice: (invoiceId: number, companyId: number) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.post<{ task_id: string }>(
`/v1/a76/invoices/${invoiceId}/revert?${params.toString()}`,
{}
);
},
getRevertStatus: (taskId: string) => {
return api.get<{
state: 'PROCESSING' | 'SUCCESS' | 'FAILURE';
info?: { current: number; status: string };
result?: {
status: 'success' | 'validation_error' | 'error';
invoice_id?: number;
message?: string;
errors?: Array<{ field: string; message: string; code?: string; solution?: string[] }>;
sql_errors?: Array<{ consecutive: number; error: string }>;
};
}>(`/v1/a76/invoices/revert/${taskId}/status`);
}
};

View File

@@ -86,6 +86,26 @@ export function createColumns(
enableSorting: false,
enableHiding: false,
},
{
id: "processed",
header: "Procesada",
cell: ({ row }) => {
const s = row.original.status;
const isProcessed = s === "processed" || s === true;
const processedCheckSnippet = createRawSnippet<[{ checked: boolean }]>((getProps) => {
const { checked } = getProps();
return {
render: () => `<div class="flex items-center justify-center">
<input type="checkbox" class="h-4 w-4 cursor-default" ${checked ? 'checked' : ''} disabled title="${checked ? 'Procesada' : 'Pendiente'}" />
</div>`
};
});
return renderSnippet(processedCheckSnippet, { checked: isProcessed });
},
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "operation_type",
header: "Operación",

View File

@@ -12,6 +12,12 @@
export let onComplete: (result: any) => void;
export let title: string = 'Generando PDF';
/** Optional: steps for step-by-step progress (e.g. invoice processing). Each step has { label, percent } */
export let steps: { label: string; percent: number }[] = [];
/** Optional: message when complete (default: "Listo para descargar") */
export let completeMessage: string = 'Listo para descargar';
export let getStatus: ((taskId: string) => Promise<any>) | null = null;
let progress = 0;
@@ -54,11 +60,20 @@
progress = response.info.current || 0;
statusMessage = response.info.status || 'Procesando...';
} else if (response?.state === 'SUCCESS') {
progress = 100;
statusMessage = '¡Completado!';
isComplete = true;
const result = response.result;
stopPolling();
setTimeout(() => onComplete(response.result), 500);
// Si el worker reportó error de validación o error de aplicación,
// cerrar el dialog inmediatamente y dejar que onComplete muestre el toast.
if (result?.status === 'validation_error' || result?.status === 'error') {
open = false;
onComplete(result);
} else {
progress = 100;
statusMessage = '¡Completado!';
isComplete = true;
setTimeout(() => onComplete(result), 500);
}
} else if (response?.state === 'FAILURE') {
hasError = true;
const errMsg = response.result ? String(response.result) : 'Error desconocido';
@@ -98,12 +113,50 @@
</Dialog.Header>
<div class="flex flex-col gap-6 py-6">
<div class="mb-1 flex items-center justify-between text-sm">
<span class="text-muted-foreground">{statusMessage}</span>
<span class="font-medium">{progress}%</span>
</div>
<Progress value={progress} class="h-2 w-full" />
{#if steps.length > 0}
<!-- Lista de pasos con progreso -->
<div class="space-y-2">
{#each steps as step, i}
{@const isDone = progress >= step.percent}
{@const isCurrent = !isDone && (i === 0 || progress >= steps[i - 1]?.percent)}
<div
class="flex items-center gap-3 rounded-md border px-3 py-2 text-sm transition-colors {isDone
? 'border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950/30'
: isCurrent
? 'border-primary/50 bg-primary/5'
: 'border-border/50 bg-muted/30 opacity-60'}"
>
<span class="flex-shrink-0 w-6 text-center font-medium text-muted-foreground">
{i + 1}.
</span>
<span class="flex-1 {isDone ? 'text-green-700 dark:text-green-400' : ''}">
{step.label}
</span>
<span class="flex-shrink-0 font-medium tabular-nums">
{#if isDone}
<span class="text-green-600 dark:text-green-400">100%</span>
{:else if isCurrent}
<span class="text-primary">{progress}%</span>
{:else}
<span class="text-muted-foreground">0%</span>
{/if}
</span>
{#if isDone}
<CheckCircle2 class="h-4 w-4 flex-shrink-0 text-green-600" />
{:else if isCurrent}
<Loader2 class="h-4 w-4 flex-shrink-0 animate-spin text-primary" />
{/if}
</div>
{/each}
</div>
<Progress value={progress} class="h-1.5 w-full" />
{:else}
<div class="mb-1 flex items-center justify-between text-sm">
<span class="text-muted-foreground">{statusMessage}</span>
<span class="font-medium">{progress}%</span>
</div>
<Progress value={progress} class="h-2 w-full" />
{/if}
<div class="flex h-16 items-center justify-center">
{#if isComplete}
@@ -111,7 +164,7 @@
class="animate-in fade-in zoom-in flex flex-col items-center text-green-600 duration-300"
>
<CheckCircle2 size={48} />
<span class="mt-2 text-sm font-medium">Listo para descargar</span>
<span class="mt-2 text-sm font-medium">{completeMessage}</span>
</div>
{:else if hasError}
<div

View File

@@ -306,11 +306,24 @@
// Exclude sidebar from any magic scrolling
if (target.closest('[data-sidebar="sidebar"]')) return;
// Exclude checkboxes inside tables (e.g. row selection) - prevents unwanted scroll when selecting
if (
target.tagName === 'INPUT' &&
(target as HTMLInputElement).type === 'checkbox' &&
target.closest('table')
) {
return;
}
// Exclude table rows (TR) - they have tabindex for keyboard nav but clicking to select shouldn't scroll
if (target.tagName === 'TR' && target.closest('table')) {
return;
}
// Check if it's an interactive element we care about
const isInteractive =
['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A'].includes(target.tagName) ||
target.role === 'tab' ||
target.tagName === 'TR';
target.role === 'tab';
if (isInteractive) {
// Force scroll to center after a delay to override browser default behavior

View File

@@ -70,6 +70,7 @@
let isDownloadModalOpen = $state(false);
let isTransferenciaModalOpen = $state(false);
let isRevertConfirmOpen = $state(false);
// Efecto reactivo para actualizar filtros cuando cambian los query parameters en la URL
$effect(() => {
@@ -725,6 +726,49 @@
}
}
async function confirmRevertInvoice() {
if (!selectedInvoice || !companyStore.activeCompany) return;
isRevertConfirmOpen = false;
try {
const response = await invoicesApi.revertInvoice(
selectedInvoice.id,
companyStore.activeCompany.id
);
if (response.error) {
toast.error(`Error al iniciar la des-actualización: ${response.error}`);
return;
}
currentTaskId = response.data!.task_id;
currentStatusFunction = invoicesApi.getRevertStatus;
progressDialogTitle = 'Des-actualizando factura';
showProgressDialog = true;
} catch (e) {
console.error('Error al iniciar des-actualización de factura:', e);
toast.error('No se pudo iniciar la des-actualización');
}
}
// Pasos del procesamiento de factura (deben coincidir con el backend)
const invoiceProcessSteps = [
{ label: 'Cargando factura', percent: 5 },
{ label: 'Validando datos de la factura', percent: 10 },
{ label: 'Revisando clases y tipo de cambio', percent: 30 },
{ label: 'Calculando valores por partida', percent: 50 },
{ label: 'Validando partidas', percent: 70 },
{ label: 'Validando cupos de Regla Octava', percent: 85 },
{ label: 'Actualizando totales', percent: 95 }
];
const invoiceRevertSteps = [
{ label: 'Cargando factura', percent: 5 },
{ label: 'Validando estatus de la factura', percent: 10 },
{ label: 'Verificando saldos de partidas', percent: 40 },
{ label: 'Confirmando cambios', percent: 95 }
];
// Opciones de tipo de operación para el filtro
const operationTypeOptions = [
{ value: '', label: 'Todas' },
@@ -920,8 +964,39 @@
onComplete={onPdfComplete}
onClose={closeProgressDialog}
title={progressDialogTitle}
steps={
progressDialogTitle === 'Procesando factura'
? invoiceProcessSteps
: progressDialogTitle === 'Des-actualizando factura'
? invoiceRevertSteps
: []
}
completeMessage={
progressDialogTitle === 'Procesando factura'
? 'Factura procesada correctamente'
: progressDialogTitle === 'Des-actualizando factura'
? 'Factura des-actualizada correctamente'
: 'Listo para descargar'
}
/>
<AlertDialog.Root bind:open={isRevertConfirmOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Des-actualizar Factura de Importación Temporal</AlertDialog.Title>
<AlertDialog.Description>
Se va a des-actualizar la factura <strong>{selectedInvoice?.invoice_number}</strong>.
Esta operación revertirá los saldos de inventario y los cupos de Regla Octava
registrados al procesar la factura. ¿Desea continuar?
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancelar</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmRevertInvoice}>Continuar</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<AlertDialog.Root bind:open={isWinsaiiConfirmOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
@@ -1086,7 +1161,7 @@
variant="outline"
size="sm"
disabled={loading || selectedInvoiceIds.length !== 1}
onclick={() => handleUpdateStatus(false)}
onclick={() => (isRevertConfirmOpen = true)}
>
<RotateCcw class="mr-2 h-4 w-4" />
Desactualizar