Merge branch 'feature/descargo_peps' into development
Integración de funcionalidad de Descargo PEPS con reportes de Packing List y Aviso Consolidado: - Frontend: Agregados imports de dischargeReportsApi y ClipboardList - Frontend: Agregada función handleDownloadDescargo con cálculo PEPS - Frontend: Agregado botón condicional de Descargo PEPS (solo exportaciones) - Frontend: Integradas funciones de Aviso Consolidado y Packing List - Backend: Agregados routers y tasks de descargo y otros reportes - Backend: Configurado Celery con todas las tareas de reportes - Backend: Corregido ForeignKey en line_quantities.package_id - Resueltos conflictos manteniendo funcionalidades de ambas ramas
This commit is contained in:
@@ -8,6 +8,7 @@ from fastapi import APIRouter
|
||||
from .fa.fa_classes.routes import router as fa_classes_router
|
||||
from .fa.fa_item_lines.routes import router as fa_item_lines_router
|
||||
|
||||
|
||||
# Router principal de A24
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -52,6 +52,8 @@ class Item(Base, TenantScopedMixin, TimestampMixin):
|
||||
# Relationships (one-to-many)
|
||||
lines: Mapped[List["LineItem"]] = relationship(
|
||||
"LineItem", back_populates="item", cascade="all, delete-orphan")
|
||||
|
||||
invoice: Mapped["InvoiceHeader"] = relationship("InvoiceHeader")
|
||||
|
||||
# ============================================================================
|
||||
# SUPPORTING TABLES
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from core.celery_app import celery_app
|
||||
@@ -47,4 +46,4 @@ def generar_pdf_aviso_consolidado_exp_async(self, invoice_id: int, company_id: i
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
db.close()
|
||||
@@ -0,0 +1,77 @@
|
||||
|
||||
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 .service import FIFOAssignmentService
|
||||
from .task import generate_descarga_pdf_task # FORCE RELOAD
|
||||
from celery.result import AsyncResult
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/fifo-assign/{invoice_id}")
|
||||
def run_fifo_assignment(
|
||||
invoice_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Any = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Executes FIFO (PEPS) calculation for an Export Invoice.
|
||||
Returns the calculated discharges in JSON format.
|
||||
"""
|
||||
service = FIFOAssignmentService()
|
||||
try:
|
||||
discharges = service.calculate_fifo(db, invoice_id)
|
||||
return {
|
||||
"invoice_id": invoice_id,
|
||||
"total_discharges": len(discharges),
|
||||
"discharges": discharges
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error calculating FIFO: {str(e)}"
|
||||
)
|
||||
|
||||
@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,400 @@
|
||||
|
||||
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
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# --- MODELOS (Imported from system for Header info) ---
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceComplianceMx
|
||||
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.pedimentos import Pedimentos
|
||||
from api.v1.modules.a76.pedmientos.models.pedimento_dates import PedimentoDates
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate
|
||||
from sqlalchemy.orm import joinedload, load_only
|
||||
|
||||
# --- FIFO SERVICE (Internalized) ---
|
||||
from api.v1.modules.a76.items.series.models import Serie
|
||||
|
||||
class FIFOAssignmentService:
|
||||
"""
|
||||
Service for calculating FIFO (PEPS) assignments in real-time.
|
||||
Does not persist to database, returns calculated discharge objects.
|
||||
"""
|
||||
|
||||
def calculate_fifo(self, db: Session, invoice_id: int) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Calculates the FIFO trail for all lines in an export invoice.
|
||||
Returns a list of calculated discharges.
|
||||
"""
|
||||
# 1. Get Export Lines
|
||||
export_lines = db.query(LineItem).join(Item).filter(
|
||||
Item.invoice_id == invoice_id
|
||||
).options(
|
||||
joinedload(LineItem.quantity),
|
||||
joinedload(LineItem.description),
|
||||
joinedload(LineItem.customs),
|
||||
joinedload(LineItem.unit_of_measure_info),
|
||||
joinedload(LineItem.part_info)
|
||||
).all()
|
||||
|
||||
results = []
|
||||
self._log(f"Starting FIFO for Invoice {invoice_id}. Export Lines: {len(export_lines)}")
|
||||
|
||||
for exp_line in export_lines:
|
||||
qty_needed = float(exp_line.quantity.quantity) if exp_line.quantity and exp_line.quantity.quantity is not None else 0.0
|
||||
if qty_needed <= 0:
|
||||
continue
|
||||
|
||||
part_number = exp_line.part_number
|
||||
if not part_number:
|
||||
self._log(f"Skipping line {exp_line.id}, no part number")
|
||||
continue
|
||||
|
||||
self._log(f"Processing Exp Line {exp_line.id}, Part: {part_number}, Qty Needed: {qty_needed}")
|
||||
|
||||
# --- SERIES EXPO LOOKUP ---
|
||||
series_desc = ""
|
||||
series_count = db.query(Serie).filter(Serie.line_item_id == exp_line.id).count()
|
||||
if series_count > 0:
|
||||
series_list = db.query(Serie).filter(Serie.line_item_id == exp_line.id).all()
|
||||
parts_str = []
|
||||
for idx, s in enumerate(series_list, 1):
|
||||
line_parts = [f"{idx}) Serie: {s.serial_numbers or ''}", f"Modelo: {s.model or ''}", f"Parte: {exp_line.part_info.part_number if exp_line.part_info else ''}", f"Num ID Expo: {s.number_id or ''}", f"SubModelo: {s.sub_model or ''}"]
|
||||
parts_str.append(", ".join([p for p in line_parts if p]))
|
||||
if parts_str:
|
||||
series_desc = "\nSeries:\n" + "\n".join(parts_str)
|
||||
|
||||
# 2. Find Import Candidates (FIFO order by payment date)
|
||||
# Use outerjoin for pedimento dates to avoid filtering out candidates with missing dates
|
||||
candidates = db.query(LineItem).join(Item).join(InvoiceHeader)\
|
||||
.join(InvoiceComplianceMx).join(InvoiceComplianceMx.pedimento).outerjoin(Pedimentos.pedimento_dates)\
|
||||
.filter(
|
||||
LineItem.part_number == part_number,
|
||||
InvoiceHeader.operation_type == 'imp', # Assuming 'imp' is the value for Import based on Enum
|
||||
).order_by(
|
||||
PedimentoDates.payment_date.asc()
|
||||
).options(
|
||||
joinedload(LineItem.quantity),
|
||||
joinedload(LineItem.customs),
|
||||
joinedload(LineItem.financial),
|
||||
joinedload(LineItem.item).joinedload(Item.invoice).joinedload(InvoiceHeader.compliance_mx).joinedload(InvoiceComplianceMx.pedimento).joinedload(Pedimentos.pedimento_dates)
|
||||
).all()
|
||||
|
||||
self._log(f"Found {len(candidates)} candidates for {part_number}")
|
||||
|
||||
for imp_line in candidates:
|
||||
if qty_needed <= 0:
|
||||
break
|
||||
|
||||
imp_qty_total = float(imp_line.quantity.quantity) if imp_line.quantity and imp_line.quantity.quantity is not None else 0.0
|
||||
if imp_qty_total <= 0:
|
||||
continue
|
||||
|
||||
take = min(qty_needed, imp_qty_total)
|
||||
ratio = take / imp_qty_total if imp_qty_total > 0 else 0
|
||||
|
||||
# Calculate Proportions
|
||||
imp_weight = float(imp_line.quantity.net_weight) if imp_line.quantity and imp_line.quantity.net_weight is not None else 0.0
|
||||
# imp_val_mn = float(imp_line.customs.customs_value) if imp_line.customs and imp_line.customs.customs_value is not None else 0.0
|
||||
imp_val_me = float(imp_line.financial.customs_value_usd) if imp_line.financial and imp_line.financial.customs_value_usd is not None else 0.0
|
||||
imp_igi = float(imp_line.customs.igi_amount) if imp_line.customs and imp_line.customs.igi_amount is not None else 0.0
|
||||
|
||||
imp_inv = imp_line.item.invoice
|
||||
ped = imp_inv.compliance_mx.pedimento if imp_inv and imp_inv.compliance_mx else None
|
||||
|
||||
# --- EXCHANGE RATE VALIDATION ---
|
||||
payment_date = ped.pedimento_dates.payment_date if ped and ped.pedimento_dates else None
|
||||
exchange_rate_val = 1.0
|
||||
validation_error = None
|
||||
|
||||
if payment_date:
|
||||
er_obj = db.query(ExchangeRate).filter(ExchangeRate.date == payment_date).first()
|
||||
if er_obj:
|
||||
exchange_rate_val = float(er_obj.value)
|
||||
else:
|
||||
# Try previous day if strict match fails (mimicking SisGen:UtilizarFechaPagoPedDeUnDiaAnterior logic broadly or just flagging)
|
||||
# For now, flag it.
|
||||
validation_error = f"Tipo de Cambio no encontrado para fecha {payment_date}"
|
||||
|
||||
# Calculate Valor MN based on Clarion logic: (CantDesc * ValorImpoME / CantImpo) * TC
|
||||
# which simplifies to: Ratio * ValorImpoME * TC
|
||||
val_mn_calc = (imp_val_me * ratio) * exchange_rate_val
|
||||
|
||||
discharge = {
|
||||
"export_line_id": exp_line.id,
|
||||
"import_line_id": imp_line.id,
|
||||
"quantity": take,
|
||||
"net_weight": imp_weight * ratio,
|
||||
"value_mxn": val_mn_calc,
|
||||
"value_usd": imp_val_me * ratio,
|
||||
"igi_amount": imp_igi * ratio,
|
||||
"import_invoice": imp_inv.invoice_number if imp_inv else "N/A",
|
||||
"pedimento": ped.pedimento_number if ped else "N/A",
|
||||
"pedimento_clave": ped.pedimento_code if ped else "",
|
||||
"pedimento_date": payment_date.isoformat() if payment_date else None,
|
||||
"series_desc": series_desc,
|
||||
"validation_error": validation_error
|
||||
}
|
||||
|
||||
self._log(f"MATCH: Taking {take} from Imp Line {imp_line.id}")
|
||||
results.append(discharge)
|
||||
qty_needed -= take
|
||||
|
||||
return results
|
||||
|
||||
def _log(self, msg):
|
||||
try:
|
||||
with open("/tmp/fifo_debug.log", "a") as f:
|
||||
f.write(f"{datetime.now()}: {msg}\n")
|
||||
except: pass
|
||||
|
||||
|
||||
# --- 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".
|
||||
|
||||
linea_expo: str
|
||||
|
||||
# Errors
|
||||
error_msg: Optional[str] = None
|
||||
|
||||
# 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...")
|
||||
|
||||
# --- 1. Obtener Cabeceras (Igual que antes) ---
|
||||
header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id).first()
|
||||
if not header:
|
||||
raise HTTPException(status_code=404, detail="Factura no encontrada")
|
||||
|
||||
tenant = db.query(Tenant).filter(Tenant.id == header.tenant_id).first()
|
||||
company = db.query(Company).filter(Company.id == header.company_id).first()
|
||||
|
||||
# --- 2. Obtener Líneas de Exportación (Lo que necesitamos cubrir) ---
|
||||
if progress_callback: progress_callback(20, "Obteniendo items a exportar...")
|
||||
|
||||
export_lines = db.query(LineItem).filter(
|
||||
LineItem.item_id == Item.id,
|
||||
Item.invoice_id == invoice_id
|
||||
).options(
|
||||
joinedload(LineItem.quantity),
|
||||
joinedload(LineItem.customs),
|
||||
joinedload(LineItem.description),
|
||||
joinedload(LineItem.unit_of_measure_info),
|
||||
joinedload(LineItem.part_info)
|
||||
).join(Item).all()
|
||||
|
||||
items_reporte = []
|
||||
|
||||
# --- 3. EL ALGORITMO PEPS EN VIVO ---
|
||||
if progress_callback: progress_callback(40, "Calculando PEPS en tiempo real...")
|
||||
|
||||
fifo_service = FIFOAssignmentService()
|
||||
discharges = fifo_service.calculate_fifo(db, invoice_id)
|
||||
|
||||
print(f"DEBUG: Calculated {len(discharges)} discharges.") # FORCE PRINT
|
||||
|
||||
# Create map for faster/safer lookup
|
||||
exp_map = {l.id: l for l in export_lines}
|
||||
|
||||
for d in discharges:
|
||||
exp_id = d["export_line_id"]
|
||||
exp_line = exp_map.get(exp_id)
|
||||
|
||||
if not exp_line:
|
||||
print(f"DEBUG: Skipping discharge, Exp Line {exp_id} not found in map keys: {list(exp_map.keys())}")
|
||||
continue
|
||||
|
||||
print(f"DEBUG: Adding item to report: Imp {d['import_line_id']} -> Exp {exp_id}")
|
||||
|
||||
desc_final = exp_line.description.description_spanish if exp_line.description else "S/D"
|
||||
items_reporte.append(DischargeItemSchema(
|
||||
pedimento_numero=d["pedimento"],
|
||||
pedimento_clave=d["pedimento_clave"],
|
||||
pedimento_fecha_pago=d["pedimento_date"].split("T")[0] if d["pedimento_date"] else "",
|
||||
|
||||
factura_impo=d["import_invoice"],
|
||||
|
||||
numero_parte=exp_line.part_info.part_number if exp_line.part_info else "",
|
||||
descripcion=desc_final,
|
||||
fraccion=exp_line.customs.fraction if exp_line.customs else "",
|
||||
origen_pref_sector=f"{exp_line.customs.origin_country or ''} - {exp_line.customs.sector or ''}" if exp_line.customs else "",
|
||||
|
||||
cantidad=self.formatear_numero(d["quantity"], 3),
|
||||
unidad_medida=exp_line.unit_of_measure_info.code if exp_line.unit_of_measure_info else "PZA",
|
||||
|
||||
peso_neto=self.formatear_numero(d["net_weight"], 3),
|
||||
valor_mn=self.formatear_numero(d["value_mxn"]),
|
||||
valor_me=self.formatear_numero(d["value_usd"]),
|
||||
valor_igi=self.formatear_numero(d["igi_amount"]),
|
||||
|
||||
se_pago="",
|
||||
linea_expo=str(exp_line.line_number),
|
||||
error_msg=d.get("validation_error")
|
||||
))
|
||||
|
||||
print(f"DEBUG: Final Report Items Count: {len(items_reporte)}")
|
||||
|
||||
# --- 4. Totales y Finalización (Igual que antes) ---
|
||||
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
|
||||
|
||||
# Recalcular totales basados en la lista generada
|
||||
t_cant = sum(float(i.cantidad.replace(",","")) for i in items_reporte)
|
||||
t_peso = sum(float(i.peso_neto.replace(",","")) for i in items_reporte)
|
||||
t_mn = sum(float(i.valor_mn.replace(",","")) for i in items_reporte)
|
||||
t_me = sum(float(i.valor_me.replace(",","")) for i in items_reporte)
|
||||
t_igi = sum(float(i.valor_igi.replace(",","")) for i in items_reporte)
|
||||
|
||||
return DischargeContext(
|
||||
items=items_reporte,
|
||||
invoice_number=header.invoice_number or "SIN FOLIO",
|
||||
company_name=company.name if company else (tenant.name if tenant 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 # FORCE RELOAD 2
|
||||
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,184 @@
|
||||
<!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>Fecha de Pago</th>
|
||||
<th style="width: 10%;">Import Invoice/<br>Factura de Impo.</th>
|
||||
<th style="width: 15%; border-right: none;">Part Number/No. de Parte<br>Description/Descripción</th>
|
||||
<th style="width: 10%; border-left: none;">Fracción<br>(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>
|
||||
<!-- Loops Items -->
|
||||
{% for item in items %}
|
||||
<tr class="{{ 'row-border-bottom' if loop.last else '' }}">
|
||||
<td>
|
||||
<div style="font-weight: bold;">{{ item.pedimento_numero }}</div>
|
||||
<table style="width: 100%; border: none; margin-top: 2px; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="border: none; padding: 0; font-size: 6.5pt; text-align: left; width: 30%;">{{
|
||||
item.pedimento_clave }}</td>
|
||||
<td style="border: none; padding: 0; font-size: 6.5pt; text-align: right; width: 70%;">{{
|
||||
item.pedimento_fecha_pago }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td>{{ item.factura_impo }}</td>
|
||||
<td style="border-right: none;">
|
||||
<b>{{ item.numero_parte }}</b><br>
|
||||
{{ item.descripcion }}
|
||||
</td>
|
||||
<td style="font-size: 6pt; border-left: none;">
|
||||
{{ item.fraccion }}<br>
|
||||
{{ item.origen_pref_sector }}
|
||||
</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 %}
|
||||
|
||||
<!-- Sub Totals -->
|
||||
<tr class="totals-row">
|
||||
<td colspan="4" class="text-center">Totales de los Comp. Temporales:</td>
|
||||
<td class="text-right">{{ total_cantidad }}</td>
|
||||
<td class="text-right">{{ total_peso }}</td>
|
||||
<td class="text-right">{{ total_valor_mn }}</td>
|
||||
<td class="text-right">{{ total_valor_me }}</td>
|
||||
<td class="text-right">{{ total_igi }}</td>
|
||||
<td class="text-center"></td>
|
||||
<td class="text-center"></td>
|
||||
</tr>
|
||||
|
||||
<!-- Grand Totals -->
|
||||
<tr class="totals-row" style="border-top: 2px solid black;">
|
||||
<td colspan="4" class="text-center" style="font-size: 9pt;">TOTALES:</td>
|
||||
<td class="text-right">{{ total_cantidad }}</td>
|
||||
<td class="text-right">{{ total_peso }}</td>
|
||||
<td class="text-right">{{ total_valor_mn }}</td>
|
||||
<td class="text-right">{{ total_valor_me }}</td>
|
||||
<td class="text-right">{{ total_igi }}</td>
|
||||
<td class="text-center"></td>
|
||||
<td class="text-center"></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)
|
||||
|
||||
@@ -53,6 +53,7 @@ from .reports.importacion.facturas.routes import router as invoices_reports_rout
|
||||
from .reports.importacion.consolidados.routes import router as consolidated_reports_router
|
||||
from .reports.importacion.packing_list.routes import router as packing_list_router
|
||||
from .reports.exportacion.aviso_consolidado.routes import router as aviso_consolidado_export_router
|
||||
from .reports.exportacion.descargo.routes import router as discharge_reports_router
|
||||
|
||||
|
||||
|
||||
@@ -145,4 +146,10 @@ router.include_router(
|
||||
aviso_consolidado_export_router,
|
||||
prefix="/a76/reports/exportacion/aviso_consolidado",
|
||||
tags=["a76 / reports"]
|
||||
)
|
||||
|
||||
router.include_router(
|
||||
discharge_reports_router,
|
||||
prefix="/a76/reports/exportacion/descargo",
|
||||
tags=["a76 / reports"]
|
||||
)
|
||||
Reference in New Issue
Block a user