feat: implementacion inicial descargo de peps

This commit is contained in:
2026-02-03 13:06:56 -06:00
parent d4014df5d5
commit afba0332b1
15 changed files with 701 additions and 6 deletions

View File

@@ -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,
)

View File

@@ -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")

View File

@@ -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

View File

@@ -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"

View File

@@ -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()

View File

@@ -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 }} &nbsp;&nbsp; {{ 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>

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"]
)