diff --git a/backend/api/v1/modules/a76/items/line_items/models.py b/backend/api/v1/modules/a76/items/line_items/models.py index a7603d11..5192f46f 100644 --- a/backend/api/v1/modules/a76/items/line_items/models.py +++ b/backend/api/v1/modules/a76/items/line_items/models.py @@ -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, + ) diff --git a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/__init__.py b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/task.py b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/task.py new file mode 100644 index 00000000..b1ff3adc --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/task.py @@ -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") diff --git a/backend/api/v1/modules/a76/reports/exportacion/descargo/routes.py b/backend/api/v1/modules/a76/reports/exportacion/descargo/routes.py new file mode 100644 index 00000000..5c8ee8bc --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/descargo/routes.py @@ -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 diff --git a/backend/api/v1/modules/a76/reports/exportacion/descargo/service.py b/backend/api/v1/modules/a76/reports/exportacion/descargo/service.py new file mode 100644 index 00000000..9b004101 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/descargo/service.py @@ -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" diff --git a/backend/api/v1/modules/a76/reports/exportacion/descargo/task.py b/backend/api/v1/modules/a76/reports/exportacion/descargo/task.py new file mode 100644 index 00000000..aa9d7b5e --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/descargo/task.py @@ -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() diff --git a/backend/api/v1/modules/a76/reports/exportacion/descargo/templates/descarga.html b/backend/api/v1/modules/a76/reports/exportacion/descargo/templates/descarga.html new file mode 100644 index 00000000..bb5b2c64 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/descargo/templates/descarga.html @@ -0,0 +1,179 @@ + + + +
+ +| + DESCARGA DE LA FACTURA: {{ invoice_number }} + | +
+ {{ company_name }} + {{ company_address }} + R.F.C.: {{ company_rfc }}, IMMEX: {{ company_immex }} + |
+ + Page/Página: Of/de + | +
| No. Pedimento Clave Fecha de Pago |
+ Import Invoice/ Factura de Impo. |
+ Part Number/No. de Parte Componente Description/Descripción + (Origen-Prefer.-Sector) |
+ Quantity/ Cantidad U.M. |
+ Net Weight/ Peso Neto (KGS) |
+ Value/Valor M.N. MXP/Pesos |
+ Value/Valor M.E. USD/Dolares |
+ Value/Monto IGI USD/Dolares |
+ Se Pagó |
+ Linea Expo Expo Line |
+
|---|---|---|---|---|---|---|---|---|---|
| Comp. Temporales: | +|||||||||
|
+ {{ item.pedimento_numero }} + {{ item.pedimento_clave }} {{ item.pedimento_fecha_pago }} + |
+ {{ item.factura_impo }} | +
+ {{ item.numero_parte }} + {{ item.descripcion }} + {{ item.fraccion }} {{ item.origen_pref_sector }} + |
+ + {{ item.cantidad }} {{ item.unidad_medida }} + | +{{ item.peso_neto }} | +{{ item.valor_mn }} | +{{ item.valor_me }} | +{{ item.valor_igi }} | +{{ item.se_pago }} | +{{ item.linea_expo }} | +
| Totales de los Comp. Temporales: | +{{ total_cantidad }} | +{{ total_peso }} | +{{ total_valor_mn }} | +{{ total_valor_me }} | +{{ total_igi }} | ++ | + | ||
| TOTALES: | +{{ total_cantidad }} | +{{ total_peso }} | +{{ total_valor_mn }} | +{{ total_valor_me }} | +{{ total_igi }} | ++ | + | ||