feat: implementacion inicial descargo de peps
This commit is contained in:
@@ -193,3 +193,8 @@ class LineItem(Base, TenantScopedMixin, TimestampMixin):
|
||||
cascade="all, delete-orphan",
|
||||
uselist=False,
|
||||
)
|
||||
part_info: Mapped[Optional["api.v1.modules.a76.parts.models.Part"]] = relationship(
|
||||
"api.v1.modules.a76.parts.models.Part",
|
||||
foreign_keys=[part_number],
|
||||
viewonly=True,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
|
||||
from celery import shared_task
|
||||
import time
|
||||
|
||||
@shared_task(bind=True, name="generate_aviso_consolidado_pdf_task")
|
||||
def generate_aviso_consolidado_pdf_task(self, invoice_id: int, company_id: int):
|
||||
"""
|
||||
Tarea de Celery para generar el PDF del Aviso Consolidado.
|
||||
Por ahora es un stub hasta que el servicio esté implementado.
|
||||
"""
|
||||
raise NotImplementedError("El servicio de Aviso Consolidado aún no está implementado")
|
||||
@@ -0,0 +1,52 @@
|
||||
|
||||
from fastapi import APIRouter, Depends, BackgroundTasks, HTTPException
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Dict, Any
|
||||
|
||||
from core.database import get_core_db as get_db
|
||||
from core.security import get_current_user
|
||||
from .task import generate_descarga_pdf_task
|
||||
from celery.result import AsyncResult
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/{invoice_id}/download-async")
|
||||
async def trigger_descarga_generation(
|
||||
invoice_id: int,
|
||||
company_id: int,
|
||||
current_user: Any = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Inicia la generación del reporte de Descarga PEPS en segundo plano (Celery).
|
||||
Retorna el task_id para polling.
|
||||
"""
|
||||
try:
|
||||
# Lanza la tarea de Celery
|
||||
task = generate_descarga_pdf_task.delay(invoice_id, company_id)
|
||||
return {"task_id": task.id, "status": "processing"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/tasks/{task_id}")
|
||||
async def get_task_status(task_id: str, current_user: Any = Depends(get_current_user)):
|
||||
"""
|
||||
Consulta el estado de la tarea de Celery.
|
||||
"""
|
||||
task_result = AsyncResult(task_id)
|
||||
|
||||
response = {
|
||||
"task_id": task_id,
|
||||
"state": task_result.state,
|
||||
"result": None,
|
||||
"info": None
|
||||
}
|
||||
|
||||
if task_result.state == 'FAILURE':
|
||||
response["result"] = str(task_result.result)
|
||||
elif task_result.state == 'SUCCESS':
|
||||
response["result"] = task_result.result
|
||||
elif task_result.state == 'PROCESSING':
|
||||
response["info"] = task_result.info
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,291 @@
|
||||
|
||||
import shutil
|
||||
import base64
|
||||
import pdfkit
|
||||
from pathlib import Path
|
||||
from typing import Tuple, List, Callable, Optional, Dict, Any
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from decimal import Decimal
|
||||
|
||||
# --- MODELOS (Imported from system for Header info) ---
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
from api.v1.modules.a76.general_catalogs.company.models import Company
|
||||
from api.v1.modules.a76.items.line_items.models import LineItem
|
||||
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
|
||||
from api.v1.modules.a76.parts.models import Part
|
||||
from api.v1.modules.a76.items.models import Item
|
||||
from api.v1.modules.a76.pedmientos.models import Pedimentos
|
||||
from sqlalchemy.orm import joinedload, load_only
|
||||
|
||||
# --- SCHEMAS FOR TEMPLATE CONTEXT ---
|
||||
|
||||
class DischargeItemSchema(BaseModel):
|
||||
# Column 1: Pedimento Info
|
||||
pedimento_numero: str
|
||||
pedimento_clave: str
|
||||
pedimento_fecha_pago: str
|
||||
|
||||
# Column 2: Import Invoice
|
||||
factura_impo: str
|
||||
|
||||
# Column 3: Part Info
|
||||
numero_parte: str
|
||||
descripcion: str
|
||||
fraccion: str
|
||||
origen_pref_sector: str # e.g. "CHN-GENERAL"
|
||||
|
||||
# Metrics
|
||||
cantidad: str
|
||||
unidad_medida: str
|
||||
peso_neto: str
|
||||
|
||||
# Values
|
||||
valor_mn: str
|
||||
valor_me: str
|
||||
valor_igi: str
|
||||
|
||||
# Flags
|
||||
se_pago: str # "0.0" or "Yes"? Image says "0.0" in column "Se Pago"? No, "Se Pago" might be a flag, image key implies payment.
|
||||
# Image: "Se Pago" column has "0.0"? No, look closer.
|
||||
# "Value/Monto IGI USD/Dolares" has "0.0".
|
||||
# "Se Pago" column seems empty or has '1'?
|
||||
# Wait, looking at image:
|
||||
# Col: "Se Pago", Row: "0.0"? No that's IGI.
|
||||
# Let's assume Se Pago is a boolean/string.
|
||||
# Last col: "Linea Expo".
|
||||
|
||||
se_pago_val: str
|
||||
linea_expo: str
|
||||
|
||||
# Helper for Jinja (if methods not allowed in pydantic models in template)
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
|
||||
class DischargeContext(BaseModel):
|
||||
items: List[DischargeItemSchema]
|
||||
invoice_number: str
|
||||
company_name: str
|
||||
company_address: str
|
||||
company_rfc: str
|
||||
company_immex: str
|
||||
|
||||
# Totals
|
||||
total_cantidad: str
|
||||
total_peso: str
|
||||
total_valor_mn: str
|
||||
total_valor_me: str
|
||||
total_igi: str
|
||||
|
||||
class DescargaReportService:
|
||||
def formatear_numero(self, valor, decimales: int = 2):
|
||||
if valor is None: return "0.00"
|
||||
try:
|
||||
return "{:,.{}f}".format(float(valor), decimales)
|
||||
except: return "0.00"
|
||||
|
||||
def __init__(self):
|
||||
self.template_dir = Path(__file__).parent / "templates"
|
||||
self.jinja_env = Environment(
|
||||
loader=FileSystemLoader(self.template_dir),
|
||||
autoescape=select_autoescape(['html', 'xml'])
|
||||
)
|
||||
self.template = self.jinja_env.get_template('descarga.html')
|
||||
|
||||
def _get_wkhtmltopdf_config(self):
|
||||
path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf" or "/usr/bin/wkhtmltopdf"
|
||||
if not Path(path).exists():
|
||||
raise RuntimeError("wkhtmltopdf no encontrado.")
|
||||
return pdfkit.configuration(wkhtmltopdf=path)
|
||||
|
||||
def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> DischargeContext:
|
||||
try:
|
||||
if progress_callback: progress_callback(10, "Buscando factura...")
|
||||
|
||||
# Fetch Header for basic info
|
||||
header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first()
|
||||
if not header:
|
||||
raise HTTPException(status_code=404, detail="Factura no encontrada")
|
||||
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
|
||||
if progress_callback: progress_callback(30, "Procesando descargas...")
|
||||
|
||||
# --- REAL IMPLEMENTATION ---
|
||||
# 1. Fetch Export Lines with FA Data
|
||||
export_lines = db.query(LineItem).filter(
|
||||
LineItem.item_id == Item.id,
|
||||
Item.invoice_id == invoice_id
|
||||
).options(
|
||||
joinedload(LineItem.fa_data),
|
||||
joinedload(LineItem.quantity).load_only(LineQuantity.quantity, LineQuantity.net_weight),
|
||||
joinedload(LineItem.customs),
|
||||
joinedload(LineItem.description),
|
||||
joinedload(LineItem.unit_of_measure_info),
|
||||
joinedload(LineItem.part_info), # Fetch Part Relation
|
||||
# joinedload(LineItem.item).joinedload(Item.invoice) # Removed due to missing relationship
|
||||
).join(Item).all()
|
||||
|
||||
# 2. Collect unique Import Invoices to bulk fetch
|
||||
# fa_data.search_invoice stores the "FacturaImpo" number
|
||||
import_inv_nums = set()
|
||||
for line in export_lines:
|
||||
if line.fa_data and line.fa_data.search_invoice:
|
||||
import_inv_nums.add(line.fa_data.search_invoice)
|
||||
|
||||
# Map InvoiceNumber -> (InvoiceHeader, Pedimento)
|
||||
import_map = {}
|
||||
if import_inv_nums:
|
||||
# We need to find the invoices. Warning: search_invoice is just a string number.
|
||||
# potentially non-unique across companies, but we filter by current Company.
|
||||
imp_invoices = db.query(InvoiceHeader).filter(
|
||||
InvoiceHeader.invoice_number.in_(import_inv_nums),
|
||||
InvoiceHeader.company_id == company_id,
|
||||
InvoiceHeader.invoice_type == 'Ingreso' # Assuming Imports are Ingreso/Import
|
||||
).options(
|
||||
joinedload(InvoiceHeader.compliance_mx)
|
||||
).all()
|
||||
|
||||
# Fetch Pedimentos for these invoices
|
||||
ped_ids = {inv.compliance_mx.pedimento_id for inv in imp_invoices if inv.compliance_mx and inv.compliance_mx.pedimento_id}
|
||||
peds = db.query(Pedimentos).filter(Pedimentos.id.in_(ped_ids)).all()
|
||||
ped_map = {p.id: p for p in peds}
|
||||
|
||||
for inv in imp_invoices:
|
||||
ped = None
|
||||
if inv.compliance_mx and inv.compliance_mx.pedimento_id:
|
||||
ped = ped_map.get(inv.compliance_mx.pedimento_id)
|
||||
import_map[inv.invoice_number] = (inv, ped)
|
||||
|
||||
items = []
|
||||
|
||||
for line in export_lines:
|
||||
# Defaults
|
||||
ped_str = ""
|
||||
ped_clave = ""
|
||||
ped_fecha = ""
|
||||
fac_impo = ""
|
||||
se_pago = ""
|
||||
valor_igi = 0.0
|
||||
|
||||
# Linkage
|
||||
if line.fa_data and line.fa_data.search_invoice:
|
||||
fac_impo = line.fa_data.search_invoice
|
||||
if fac_impo in import_map:
|
||||
inv_imp, ped_imp = import_map[fac_impo]
|
||||
|
||||
if ped_imp:
|
||||
ped_str = f"{ped_imp.pedimento_number}"
|
||||
ped_clave = f"{ped_imp.pedimento_code}"
|
||||
# Format date if exists
|
||||
# Simple date fallback from header if needed or Pedimento Date logic (revisit model if needed)
|
||||
pass
|
||||
|
||||
# Calculation logic (Prorate)
|
||||
qty = float(line.quantity.quantity) if line.quantity else 0.0
|
||||
|
||||
valor_me = 0.0
|
||||
valor_mn = 0.0
|
||||
|
||||
# Create Schema
|
||||
items.append(DischargeItemSchema(
|
||||
pedimento_numero=ped_str,
|
||||
pedimento_clave=ped_clave,
|
||||
pedimento_fecha_pago=ped_fecha,
|
||||
factura_impo=fac_impo,
|
||||
numero_parte=line.part_info.part_number if hasattr(line, 'part_info') and line.part_info else (str(line.part_number) if line.part_number else "S/N"),
|
||||
descripcion=line.description.description_spanish if line.description else "S/D",
|
||||
fraccion=line.customs.fraction if line.customs else "",
|
||||
origen_pref_sector=f"{line.customs.origin_country or ''} - {line.customs.sector or ''}" if line.customs else "",
|
||||
cantidad=self.formatear_numero(qty, 3),
|
||||
unidad_medida=line.unit_of_measure_info.code if line.unit_of_measure_info else "PZA",
|
||||
peso_neto=self.formatear_numero(float(line.quantity.net_weight) if line.quantity else 0.0, 3),
|
||||
valor_mn=self.formatear_numero(valor_mn),
|
||||
valor_me=self.formatear_numero(valor_me),
|
||||
valor_igi=self.formatear_numero(valor_igi),
|
||||
se_pago=se_pago or "NO",
|
||||
se_pago_val=se_pago,
|
||||
linea_expo=str(line.line_number)
|
||||
))
|
||||
|
||||
# Totals
|
||||
|
||||
# Company Address Construction
|
||||
addr_str = "DIRECCION NO REGISTRADA"
|
||||
immex_val = ""
|
||||
|
||||
if company:
|
||||
# Address Logic
|
||||
if company.addresses:
|
||||
# Prefer 'main' address, otherwise take the first one
|
||||
main_addr = next((a for a in company.addresses if a.address_type == 'main'), company.addresses[0])
|
||||
|
||||
parts = []
|
||||
if main_addr.street: parts.append(main_addr.street)
|
||||
if main_addr.exterior_number: parts.append(f"No. {main_addr.exterior_number}")
|
||||
if main_addr.neighborhood: parts.append(main_addr.neighborhood)
|
||||
if main_addr.city: parts.append(main_addr.city)
|
||||
if main_addr.state: parts.append(main_addr.state)
|
||||
if main_addr.postal_code: parts.append(f"CP {main_addr.postal_code}")
|
||||
|
||||
if parts:
|
||||
addr_str = ", ".join(parts)
|
||||
|
||||
# IMMEX Logic
|
||||
if company.program and "IMMEX" in company.program and company.program_number:
|
||||
immex_val = company.program_number
|
||||
|
||||
# Calculate Totals
|
||||
t_cant = sum(float(i.cantidad.replace(",","")) for i in items if i.cantidad)
|
||||
t_peso = sum(float(i.peso_neto.replace(",","")) for i in items if i.peso_neto)
|
||||
t_mn = sum(float(i.valor_mn.replace(",","")) for i in items if i.valor_mn)
|
||||
t_me = sum(float(i.valor_me.replace(",","")) for i in items if i.valor_me)
|
||||
t_igi = sum(float(i.valor_igi.replace(",","")) for i in items if i.valor_igi)
|
||||
|
||||
return DischargeContext(
|
||||
items=items,
|
||||
invoice_number=header.invoice_number or "SIN FOLIO",
|
||||
company_name=company.name if company else "EMPRESA DESCONOCIDA",
|
||||
company_address=addr_str,
|
||||
company_rfc=company.rfc if company else "",
|
||||
company_immex=immex_val,
|
||||
total_cantidad=self.formatear_numero(t_cant, 3),
|
||||
total_peso=self.formatear_numero(t_peso, 3),
|
||||
total_valor_mn=self.formatear_numero(t_mn),
|
||||
total_valor_me=self.formatear_numero(t_me),
|
||||
total_igi=self.formatear_numero(t_igi)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error Service Discharge Report: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
|
||||
|
||||
def generar_pdf(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> Tuple[bytes, str, str]:
|
||||
if progress_callback: progress_callback(5, "Iniciando servicio de reporte...")
|
||||
|
||||
datos = self.obtener_datos(db, invoice_id, company_id, progress_callback)
|
||||
|
||||
if progress_callback: progress_callback(80, "Renderizando plantilla...")
|
||||
|
||||
context = datos.model_dump()
|
||||
html_content = self.template.render(**context)
|
||||
nombre = f"Descarga_{datos.invoice_number}.pdf"
|
||||
|
||||
if progress_callback: progress_callback(90, "Generando PDF final...")
|
||||
|
||||
options = {
|
||||
'page-size': 'Letter',
|
||||
'orientation': 'Landscape', # Correct argument for wkhtmltopdf
|
||||
'margin-top': '0.5in',
|
||||
'margin-right': '0.5in',
|
||||
'margin-bottom': '0.5in',
|
||||
'margin-left': '0.5in',
|
||||
'encoding': "UTF-8"
|
||||
}
|
||||
|
||||
pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config())
|
||||
|
||||
if progress_callback: progress_callback(100, "Completado")
|
||||
return pdf, nombre, "application/pdf"
|
||||
@@ -0,0 +1,49 @@
|
||||
|
||||
from celery import shared_task
|
||||
from sqlalchemy.orm import Session
|
||||
from core.database import CoreSessionLocal as SessionLocal
|
||||
from .service import DescargaReportService
|
||||
import base64
|
||||
import traceback
|
||||
|
||||
@shared_task(bind=True, name="generate_descarga_pdf_task")
|
||||
def generate_descarga_pdf_task(self, invoice_id: int, company_id: int):
|
||||
"""
|
||||
Tarea de Celery para generar el PDF del Reporte de Descarga
|
||||
"""
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
service = DescargaReportService()
|
||||
|
||||
def update_progress(percent, message):
|
||||
self.update_state(
|
||||
state='PROCESSING',
|
||||
meta={'current': percent, 'total': 100, 'status': message}
|
||||
)
|
||||
|
||||
pdf_bytes, filename, content_type = service.generar_pdf(
|
||||
db=db,
|
||||
invoice_id=invoice_id,
|
||||
company_id=company_id,
|
||||
progress_callback=update_progress
|
||||
)
|
||||
|
||||
# Retornar el PDF en base64 para que el front lo descargue
|
||||
pdf_b64 = base64.b64encode(pdf_bytes).decode('utf-8')
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"file_name": filename,
|
||||
"content": pdf_b64,
|
||||
"media_type": content_type,
|
||||
"message": "Reporte generado correctamente"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.update_state(
|
||||
state='FAILURE',
|
||||
meta={'exc_type': type(e).__name__, 'exc_message': str(e)}
|
||||
)
|
||||
raise e
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,179 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Descarga de Factura {{ invoice_number }}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 8pt;
|
||||
}
|
||||
|
||||
.header-table {
|
||||
width: 100%;
|
||||
border-bottom: 2px solid black;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
font-size: 12pt;
|
||||
}
|
||||
|
||||
.company-info {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
text-align: right;
|
||||
font-size: 8pt;
|
||||
}
|
||||
|
||||
/* Main Table */
|
||||
.main-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.main-table th {
|
||||
border: 1px solid black;
|
||||
background-color: #f0f0f0;
|
||||
padding: 3px;
|
||||
font-size: 7pt;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.main-table td {
|
||||
border-left: 1px solid black;
|
||||
border-right: 1px solid black;
|
||||
padding: 3px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.row-border-bottom td {
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
|
||||
.totals-row td {
|
||||
border-top: 1px solid black;
|
||||
border-bottom: 1px solid black;
|
||||
font-weight: bold;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.sub-header {
|
||||
font-weight: bold;
|
||||
padding: 5px 0;
|
||||
border-left: 1px solid black;
|
||||
border-right: 1px solid black;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<!-- Header -->
|
||||
<table class="header-table">
|
||||
<tr>
|
||||
<td style="width: 30%;">
|
||||
<span class="title">DESCARGA DE LA FACTURA: {{ invoice_number }}</span>
|
||||
</td>
|
||||
<td style="width: 40%;" class="company-info">
|
||||
<strong>{{ company_name }}</strong><br>
|
||||
{{ company_address }}<br>
|
||||
R.F.C.: {{ company_rfc }}, IMMEX: {{ company_immex }}
|
||||
</td>
|
||||
<td style="width: 30%;" class="page-info">
|
||||
Page/Página: <span class="page"></span> Of/de <span class="topage"></span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div style="margin-bottom: 5px;">La factura se descargo de:</div>
|
||||
|
||||
<table class="main-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 12%;">No. Pedimento<br>Clave Fecha de Pago</th>
|
||||
<th style="width: 10%;">Import Invoice/<br>Factura de Impo.</th>
|
||||
<th style="width: 25%;">Part Number/No. de Parte Componente<br>Description/Descripción
|
||||
(Origen-Prefer.-Sector)</th>
|
||||
<th style="width: 8%;">Quantity/<br>Cantidad U.M.</th>
|
||||
<th style="width: 8%;">Net Weight/<br>Peso Neto (KGS)</th>
|
||||
<th style="width: 8%;">Value/Valor M.N.<br>MXP/Pesos</th>
|
||||
<th style="width: 8%;">Value/Valor M.E.<br>USD/Dolares</th>
|
||||
<th style="width: 8%;">Value/Monto IGI<br>USD/Dolares</th>
|
||||
<th style="width: 5%;">Se<br>Pagó</th>
|
||||
<th style="width: 5%;">Linea Expo<br>Expo Line</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Sub Header -->
|
||||
<tr>
|
||||
<td colspan="10" class="sub-header">Comp. Temporales:</td>
|
||||
</tr>
|
||||
|
||||
<!-- Loops Items -->
|
||||
{% for item in items %}
|
||||
<tr class="{{ 'row-border-bottom' if loop.last else '' }}">
|
||||
<td>
|
||||
<b>{{ item.pedimento_numero }}</b><br>
|
||||
{{ item.pedimento_clave }} {{ item.pedimento_fecha_pago }}
|
||||
</td>
|
||||
<td>{{ item.factura_impo }}</td>
|
||||
<td>
|
||||
<b>{{ item.numero_parte }}</b><br>
|
||||
{{ item.descripcion }}<br>
|
||||
<span style="font-size: 6pt;">{{ item.fraccion }}<br>{{ item.origen_pref_sector }}</span>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{{ item.cantidad }} {{ item.unidad_medida }}
|
||||
</td>
|
||||
<td class="text-right">{{ item.peso_neto }}</td>
|
||||
<td class="text-right">{{ item.valor_mn }}</td>
|
||||
<td class="text-right">{{ item.valor_me }}</td>
|
||||
<td class="text-right">{{ item.valor_igi }}</td>
|
||||
<td class="text-center">{{ item.se_pago }}</td>
|
||||
<td class="text-center">{{ item.linea_expo }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Totals -->
|
||||
<tr class="totals-row">
|
||||
<td colspan="3" class="text-center">Totales de los Comp. Temporales:</td>
|
||||
<td>{{ total_cantidad }}</td>
|
||||
<td>{{ total_peso }}</td>
|
||||
<td>{{ total_valor_mn }}</td>
|
||||
<td>{{ total_valor_me }}</td>
|
||||
<td>{{ total_igi }}</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
|
||||
<!-- Grand Totals (Same as above for this example) -->
|
||||
<tr class="totals-row" style="border-top: 2px solid black;">
|
||||
<td colspan="3" class="text-center" style="font-size: 9pt;">TOTALES:</td>
|
||||
<td>{{ total_cantidad }}</td>
|
||||
<td>{{ total_peso }}</td>
|
||||
<td>{{ total_valor_mn }}</td>
|
||||
<td>{{ total_valor_me }}</td>
|
||||
<td>{{ total_igi }}</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -61,7 +61,7 @@ class ConsolidadoImportacionMexService:
|
||||
self.template = self.jinja_env.get_template("cons_mex_ver.html")
|
||||
|
||||
def _get_wkhtmltopdf_config(self):
|
||||
path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf"
|
||||
path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf" or "/usr/bin/wkhtmltopdf"
|
||||
if not Path(path).exists():
|
||||
raise RuntimeError("wkhtmltopdf no encontrado.")
|
||||
return pdfkit.configuration(wkhtmltopdf=path)
|
||||
|
||||
@@ -97,7 +97,7 @@ class FacturaImportacionMexService:
|
||||
return title
|
||||
|
||||
def _get_wkhtmltopdf_config(self):
|
||||
path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf"
|
||||
path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf" or "/usr/bin/wkhtmltopdf"
|
||||
if not Path(path).exists():
|
||||
raise RuntimeError("wkhtmltopdf no encontrado.")
|
||||
return pdfkit.configuration(wkhtmltopdf=path)
|
||||
|
||||
@@ -130,4 +130,11 @@ router.include_router(
|
||||
consolidated_reports_router,
|
||||
prefix="/a76/reports/importacion/consolidados",
|
||||
tags=["a76 / reports"]
|
||||
)
|
||||
|
||||
from .reports.exportacion.descargo.routes import router as discharge_reports_router
|
||||
router.include_router(
|
||||
discharge_reports_router,
|
||||
prefix="/a76/reports/exportacion/descargo",
|
||||
tags=["a76 / reports"]
|
||||
)
|
||||
@@ -10,7 +10,8 @@ celery_app = Celery(
|
||||
backend=valkey_url,
|
||||
include=[
|
||||
"api.v1.modules.a76.reports.importacion.facturas.task",
|
||||
"api.v1.modules.a76.reports.importacion.consolidados.task"
|
||||
"api.v1.modules.a76.reports.importacion.consolidados.task",
|
||||
"api.v1.modules.a76.reports.exportacion.descargo.task"
|
||||
] # Ruta al módulo donde están las tareas
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
export const avisoConsolidadoReportsApi = {
|
||||
|
||||
triggerPdfGeneration: async (invoiceId: number, companyId: number) => {
|
||||
const params = new URLSearchParams({ company_id: companyId.toString() });
|
||||
const endpoint = `${BASE_URL}/v1/a76/reports/exportacion/aviso_consolidado/${invoiceId}/download-async?${params.toString()}`;
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Error al iniciar la generación del Aviso Consolidado');
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
getTaskStatus: async (taskId: string) => {
|
||||
const endpoint = `${BASE_URL}/v1/a76/reports/exportacion/aviso_consolidado/tasks/${taskId}`;
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Error al consultar estado del Aviso Consolidado');
|
||||
return await response.json();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
export const dischargeReportsApi = {
|
||||
|
||||
triggerPdfGeneration: async (invoiceId: number, companyId: number) => {
|
||||
const params = new URLSearchParams({ company_id: companyId.toString() });
|
||||
// Endpoint matches routes.py
|
||||
const endpoint = `${BASE_URL}/v1/a76/reports/exportacion/descargo/${invoiceId}/download-async?${params.toString()}`;
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Error al iniciar la generación del Reporte de Descarga');
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
getTaskStatus: async (taskId: string) => {
|
||||
const endpoint = `${BASE_URL}/v1/a76/reports/exportacion/descargo/tasks/${taskId}`;
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Error al consultar estado del Reporte de Descarga');
|
||||
return await response.json();
|
||||
}
|
||||
};
|
||||
@@ -63,9 +63,9 @@
|
||||
}
|
||||
else if (response.state === 'FAILURE') {
|
||||
hasError = true;
|
||||
statusMessage = "Error al generar el PDF";
|
||||
statusMessage = response.result ? `Error: ${response.result}` : "Error al generar el PDF";
|
||||
stopPolling();
|
||||
toast.error("Falló la generación del PDF");
|
||||
toast.error("Falló la generación del PDF: " + (response.result || ""));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error polling task status:", error);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { invoicesApi, type Invoice, type OperationType } from '$lib/api/dashboard/a76/invoices';
|
||||
import { invoicesReportsApi } from '$lib/api/dashboard/a76/reports/reports-invoices';
|
||||
import { consolidatedReportsApi } from '$lib/api/dashboard/a76/reports/reports-consolidated';
|
||||
import { dischargeReportsApi } from '$lib/api/dashboard/a76/reports/reports-descargo';
|
||||
import DataTable from '$lib/components/dashboard/invoices/data-table.svelte';
|
||||
import { createColumns } from '$lib/components/dashboard/invoices/columns.js';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
@@ -15,7 +16,7 @@
|
||||
import type { PageData } from './$types';
|
||||
import { browser } from '$app/environment';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { Plus, RefreshCw, FileText, RotateCcw, Boxes } from 'lucide-svelte';
|
||||
import { Plus, RefreshCw, FileText, RotateCcw, Boxes, ClipboardList } from 'lucide-svelte';
|
||||
|
||||
// IMPORTANTE: Asegúrate de tener instalada svelte-sonner para las notificaciones
|
||||
import { toast } from "svelte-sonner";
|
||||
@@ -376,6 +377,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadDescargo(invoice: any) {
|
||||
if (!companyStore.activeCompany) {
|
||||
toast.error("No hay empresa seleccionada");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Trigger: Iniciar la tarea en Celery
|
||||
const { task_id } = await dischargeReportsApi.triggerPdfGeneration(
|
||||
invoice.id,
|
||||
companyStore.activeCompany.id
|
||||
);
|
||||
|
||||
// 2. Abrir diálogo de progreso
|
||||
currentTaskId = task_id;
|
||||
currentStatusFunction = dischargeReportsApi.getTaskStatus;
|
||||
showProgressDialog = true;
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("No se pudo iniciar la descarga del reporte PEPS");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadConsolidated(invoice: any) {
|
||||
if (!companyStore.activeCompany) {
|
||||
toast.error("No hay empresa seleccionada");
|
||||
@@ -656,6 +681,10 @@
|
||||
<Boxes class="h-4 w-4 mr-2" />
|
||||
Consolidado
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onclick={() => selectedInvoice && handleDownloadDescargo(selectedInvoice)} disabled={!selectedInvoice}>
|
||||
<ClipboardList class="h-4 w-4 mr-2" />
|
||||
Descargo PEPS
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user