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:
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
76
backend/api/v1/modules/a76/invoices/imports/revert/routes.py
Normal file
76
backend/api/v1/modules/a76/invoices/imports/revert/routes.py
Normal 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,
|
||||
}
|
||||
@@ -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}"
|
||||
),
|
||||
})
|
||||
82
backend/api/v1/modules/a76/invoices/imports/revert/task.py
Normal file
82
backend/api/v1/modules/a76/invoices/imports/revert/task.py
Normal 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()
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user