From 9c0d007cf4abe9ace9f3f5883567099da64e4237 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Mon, 26 Jan 2026 17:44:16 -0600 Subject: [PATCH 1/9] Se creo la base del reporte --- .../importacion/packing_list/routes.py | 49 ++ .../importacion/packing_list/schemas.py | 88 +++ .../importacion/packing_list/service.py | 364 +++++++++++ .../reports/importacion/packing_list/task.py | 51 ++ .../packing_list/templates/packing_list.html | 615 ++++++++++++++++++ backend/api/v1/modules/a76/router.py | 8 + backend/core/celery_app.py | 3 +- .../dashboard/a76/reports/reports-invoices.ts | 44 +- .../invoices/pdf-progress-dialog.svelte | 7 +- .../routes/dashboard/invoices/+page.svelte | 32 +- frontend/test_bits.js | 7 + 11 files changed, 1257 insertions(+), 11 deletions(-) create mode 100644 backend/api/v1/modules/a76/reports/importacion/packing_list/routes.py create mode 100644 backend/api/v1/modules/a76/reports/importacion/packing_list/schemas.py create mode 100644 backend/api/v1/modules/a76/reports/importacion/packing_list/service.py create mode 100644 backend/api/v1/modules/a76/reports/importacion/packing_list/task.py create mode 100644 backend/api/v1/modules/a76/reports/importacion/packing_list/templates/packing_list.html create mode 100644 frontend/test_bits.js diff --git a/backend/api/v1/modules/a76/reports/importacion/packing_list/routes.py b/backend/api/v1/modules/a76/reports/importacion/packing_list/routes.py new file mode 100644 index 00000000..54c4dacf --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/packing_list/routes.py @@ -0,0 +1,49 @@ +from typing import Dict, Any +from fastapi import APIRouter, Depends, Query, Response, HTTPException +from sqlalchemy.orm import Session +from core.database import get_core_db +from core.security import get_current_user, validate_access_to_resource +from .service import PackingListService + +router = APIRouter() +service = PackingListService() + +from celery.result import AsyncResult +from core.celery_app import celery_app +from .task import generar_packing_list_async + +@router.get("/tasks/{task_id}") +async def get_task_status( + task_id: str, + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_core_db) +): + task_result = AsyncResult(task_id, app=celery_app) + + 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 + +@router.post("/{invoice_id}/download-async") +async def trigger_download_packing_list( + invoice_id: int, + company_id: int = Query(..., description="ID de la empresa"), + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_core_db) +): + validate_access_to_resource(db, company_id, current_user) + + task = generar_packing_list_async.delay(invoice_id, company_id) + return {"task_id": task.id, "message": "Generación iniciada"} diff --git a/backend/api/v1/modules/a76/reports/importacion/packing_list/schemas.py b/backend/api/v1/modules/a76/reports/importacion/packing_list/schemas.py new file mode 100644 index 00000000..cbbd0958 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/packing_list/schemas.py @@ -0,0 +1,88 @@ +from typing import List, Optional, Union, Any +from pydantic import BaseModel, field_validator + +class ClienteSchema(BaseModel): + header: str + nombre: str + direccion: Optional[str] = "" + num_exterior: Optional[str] = "" + num_interior: Optional[str] = "" + colonia: Optional[str] = "" + codigo_postal: Optional[str] = "" + ciudad: Optional[str] = "" + estado: Optional[str] = "" + pais: Optional[str] = "" + tax_id: str + programa: Optional[str] = "" + autorizacion: Optional[str] = "" + prosec: Optional[str] = "" + reg_emp: Optional[str] = "" + cert: Optional[str] = "" + + @field_validator('direccion', 'nombre', mode='before') + @classmethod + def prevent_none(cls, v): + return v or "" + +class FacturaSchema(BaseModel): + numero: str + fecha: str + tipo_cambio: Union[float, str] + moneda: str + pedimento: str = "" + clave_pedimento: str = "" + remesa: str = "" + acuse_electronico: str = "" + agente_aduanal: str = "" + patente: str = "" + precinto: str = "" + regimen: str = "" + transportista: str = "" + scac: str = "" + caat: str = "" + incoterm: str = "" + transporte: str = "" + num_transporte: str = "" + placas: str = "" + placas_remolque: str = "" + licencia_conductor: str = "" + aduana: str = "" + destino: str = "" + observaciones: str = "" + +class PartidaSchema(BaseModel): + numero_parte: str + descripcion: str + fraccion: str + origen: str + + advalorem:Optional[str] = "" + preferencia:Optional[str] = "" + + cantidad_importacion: Union[float, str] + unidad_medida: str + cantidad_bultos: int + clave_bultos: str + peso_neto: Union[float, str] + peso_bruto: Union[float, str] + valor_costo_unitario: Union[float, str] = "" + valor_total: Union[float, str] = "" + +class TotalesSchema(BaseModel): + cantidad_total: Union[float, str] + bultos_total: int + clave_bultos: str = "" + peso_neto_total: Union[float, str] + peso_bruto_total: Union[float, str] + valor_total_total: Union[float, str] = "" + valor_total_dolares: Union[float, str] = "" + +class PackingListSchema(BaseModel): + cliente_proveedor: ClienteSchema + cliente_vendido: ClienteSchema + cliente_enviado: ClienteSchema + factura: FacturaSchema + partidas: List[PartidaSchema] + totales: TotalesSchema + logo_b64: Optional[str] = None + diff --git a/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py b/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py new file mode 100644 index 00000000..a31a1eb2 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py @@ -0,0 +1,364 @@ +import shutil +import base64 +import pdfkit +from pathlib import Path +from decimal import Decimal +from typing import Tuple, List, Callable, Optional, Dict + +from jinja2 import Environment, FileSystemLoader, select_autoescape +from fastapi import HTTPException +from pydantic import ValidationError +from sqlalchemy.orm import Session + +# --- MODELOS --- +from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics +from api.v1.modules.a76.items.line_financials.models import LineFinancial +from api.v1.modules.a76.items.line_quantities.models import LineQuantity +from api.v1.modules.a76.items.line_items.models import LineItem +from api.v1.modules.a76.clients_and_providers.models import ( + ClientProvider, ClientProviderAddress, ClientProviderPrograms +) +from api.v1.modules.a76.parts.models import Part +from api.v1.modules.a76.pedmientos.models import Pedimentos +from api.v1.modules.a76.general_catalogs.company.models import Company +from api.v1.modules.a76.customs_brokers.models import CustomsBroker +from api.v1.modules.a76.items.models import Item + +# --- TRANSPORTATION MODELS --- +from api.v1.modules.a76.transportation.transporters.models import Transporter +from api.v1.modules.a76.transportation.vehicles.models import Vehicle +from api.v1.modules.a76.transportation.trailers.models import Trailer +from api.v1.modules.a76.transportation.drivers.models import Driver + +# --- MODELO DE FRACCIONES --- +from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction + +# --- SCHEMAS --- +from .schemas import ( + ClienteSchema, PartidaSchema, TotalesSchema, + FacturaSchema, PackingListSchema +) + +class PackingListService: + 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('packing_list.html') + + def _get_wkhtmltopdf_config(self): + # List of possible paths + paths = [ + shutil.which("wkhtmltopdf"), + "/usr/local/bin/wkhtmltopdf", + "/usr/bin/wkhtmltopdf", + "C:\\Program Files\\wkhtmltopdf\\bin\\wkhtmltopdf.exe" + ] + + path = next((p for p in paths if p and Path(p).exists()), None) + + if not path: + # If we are in dev and cannot find it, try to mock it or raise clearer error + if shutil.which("echo"): + print("WARNING: wkhtmltopdf not found, PDF generation will fail.") + raise RuntimeError(f"wkhtmltopdf binary not found. Searched in: {paths}") + + return pdfkit.configuration(wkhtmltopdf=path) + + def formatear_numero(self, valor, decimales: int = 2): + if valor is None: return 0.0 + try: + return round(float(valor), decimales) + except: return 0.0 + + def _format_fraccion_fallback(self, fraccion_raw: str) -> str: + if not fraccion_raw or len(fraccion_raw) < 8: + return fraccion_raw or "" + return f"{fraccion_raw[:4]}.{fraccion_raw[4:6]}.{fraccion_raw[6:]}" + + def _obtener_datos_cliente(self, db: Session, client_id: int, rol: str) -> ClienteSchema: + main = db.query(ClientProvider).filter(ClientProvider.id == client_id).first() + if not main: + return ClienteSchema(header=rol, nombre="Desconocido", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="MEX") + + addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == client_id).first() + prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == client_id).first() + + return ClienteSchema( + header=rol, + nombre=(main.name or main.short_name) or "S/N", + direccion=(addr.streets or "") if addr else "", + num_exterior=(addr.exterior_number or "") if addr else "", + num_interior=(addr.interior_number or "") if addr else "", + colonia=(addr.neighborhood or "") if addr else "", + codigo_postal=(addr.postal_code or "") if addr else "", + ciudad=(addr.city or "") if addr else "", + estado=(addr.state or "") if addr else "", + pais=(addr.country or "MEX") if addr else "MEX", + tax_id=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""), + programa="IMMEX" if (prog and prog.program) else "", + autorizacion=prog.program_number if prog else "", + prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "", + reg_emp=prog.val_certified_company_registry if (prog and hasattr(prog, 'val_certified_company_registry')) else ( + prog.certified_company_registry if (prog and prog.certified_company_registry) else "" + ), + cert=prog.is_certified_company if (prog and prog.is_certified_company) else "" + ) + + def get_packing_list_data(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> PackingListSchema: + try: + if progress_callback: progress_callback(10, "Buscando factura...") + 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") + + compliance = header.compliance_mx + logistics = header.logistics if header.logistics else None + financials = header.financials if header.financials else None + + if progress_callback: progress_callback(20, "Obteniendo datos de pedimento...") + pedimento_id = compliance.pedimento_id if (compliance and compliance.pedimento_id) else header.related_doc_id + pedimento = db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first() if pedimento_id else None + + if progress_callback: progress_callback(30, "Obteniendo cliente y proveedor...") + proveedor_id = compliance.provider_id if compliance else None + cliente_proveedor = self._obtener_datos_cliente(db, proveedor_id, "Proveedor / supplier:") if proveedor_id else ClienteSchema(header="Proveedor", nombre="No Asignado", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="") + + nombre_agente = "" + if compliance and compliance.customs_broker_id: + broker = db.query(CustomsBroker).filter(CustomsBroker.id == compliance.customs_broker_id).first() + if broker: nombre_agente = broker.name + + company = db.query(Company).filter(Company.id == header.company_id).first() + # Datos Default (Company/Importer) + cliente_default = ClienteSchema( + header="Importador / consignatario:", + nombre=getattr(company, 'name', "Empresa Local"), + direccion="DOMICILIO FISCAL", + num_exterior="", colonia="", codigo_postal="", ciudad="", estado="", pais="MEX", + tax_id=getattr(company, 'rfc', ""), + programa=getattr(company, 'program', "IMMEX"), autorizacion=getattr(company, 'program_number', "") + ) + + # Left Side Logic (Consignatario / Sold To) + cliente_vendido = cliente_default + if compliance and compliance.sold_to_id: + raw_header = compliance.sold_to_header or "CONSIGNATARIO" + clean_header = raw_header.replace("_", " ").capitalize() + ":" + cliente_vendido = self._obtener_datos_cliente(db, compliance.sold_to_id, clean_header) + + # Right Side Logic (Enviado A / Shipped To) + cliente_enviado = cliente_default + if compliance and compliance.shipped_to_id: + raw_header_shipped = compliance.shipped_to_header or "DESTINATARIO" + clean_header_shipped = raw_header_shipped.replace("_", " ").capitalize() + ":" + cliente_enviado = self._obtener_datos_cliente(db, compliance.shipped_to_id, clean_header_shipped) + + remesa_valor = str(compliance.remesa) if (compliance and compliance.remesa) else "" + acuse_valor = str(compliance.edocument) if (compliance and compliance.edocument) else "N/A" + + patente_val = "" + if pedimento and pedimento.license: + patente_val = pedimento.license + elif 'broker' in locals() and broker and broker.license: + patente_val = broker.license + + # --- Transport Data Fetching --- + transporte_txt = str(logistics.transport_type) if (logistics and logistics.transport_type) else "" + num_transporte_val = (logistics.trailer_num or "") if logistics else "" + + placas_val = (logistics.license_plate or "") if logistics else "" + placas_remolque_val = "" + transportista_val = (logistics.carrier_id or "") if logistics else "" + caat_val = "" + scac_val = "" + licencia_cond_val = "" + + if logistics: + # 1. Transporter (CAAT / SCAC) + if logistics.carrier_id: + transporter_obj = db.query(Transporter).filter(Transporter.transporter_key == logistics.carrier_id).first() + if transporter_obj: + caat_val = transporter_obj.caat_code or "" + scac_val = transporter_obj.transport_code or "" + transportista_val = transporter_obj.name or logistics.carrier_id + + # 2. Vehicle (Placas Tracto) + if logistics.transport_id: + veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.transport_id).first() + if veh_obj: + placas_val = veh_obj.plate_number or placas_val + elif logistics.vehicle_num: + veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.vehicle_num).first() + if veh_obj: + placas_val = veh_obj.plate_number or placas_val + + # 3. Trailer (Placas Remolque) + if logistics.trailer_num: + trl_obj = db.query(Trailer).filter(Trailer.trailer_number == logistics.trailer_num).first() + if trl_obj: + placas_remolque_val = trl_obj.plate_number or "" + + # 4. Driver (License) + if logistics.carrier_id and logistics.driver_name: + drv_obj = db.query(Driver).filter( + Driver.transporter_key == logistics.carrier_id, + Driver.driver_name == logistics.driver_name + ).first() + if drv_obj: + licencia_cond_val = drv_obj.license_number or "" + + factura_schema = FacturaSchema( + numero=header.invoice_number or "S/N", + fecha=str(header.invoice_date) if header.invoice_date else "", + tipo_cambio=float(financials.exchange_rate) if (financials and financials.exchange_rate) else (float(pedimento.exchange_rate) if pedimento and pedimento.exchange_rate else 1.0), + moneda=getattr(header, 'currency', "USD") or "USD", + incoterm=(logistics.incoterm or "") if logistics else "", + observaciones=header.observation_es or header.observation_en or "", + pedimento=f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}" if pedimento else "", + clave_pedimento=pedimento.pedimento_code if pedimento else "", + regimen=header.document_type or "", + patente=patente_val, + agente_aduanal=nombre_agente, + transporte=transporte_txt, + num_transporte=num_transporte_val, + placas=placas_val, + placas_remolque=placas_remolque_val, + transportista=transportista_val, + caat=caat_val, + scac=scac_val, + licencia_conductor=licencia_cond_val, + aduana=compliance.aduana if (compliance and compliance.aduana) else (pedimento.customs_office[:2] if (pedimento and pedimento.customs_office) else ""), + precinto=(logistics.seal_number or "") if logistics else "", + destino=(logistics.destination_goods or "") if logistics else "", + remesa=remesa_valor, acuse_electronico=acuse_valor + ) + + if progress_callback: progress_callback(50, "Procesando partidas...") + lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter(Item.invoice_id == header.id).all() + partidas_list = [] + + for line in lines: + qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first() + part_master = db.query(Part).filter(Part.id == line.part_number).first() + + desc_final = "S/D" + num_parte_final = str(line.part_number or "S/N") + fraccion_raw = "" + origen_final = "MEX" + + if part_master: + desc_final = part_master.description_spanish or part_master.description_english or "Sin Desc." + num_parte_final = part_master.part_number + fraccion_raw = part_master.fraction if part_master.fraction else "" + + if part_master.fa_data and part_master.fa_data.origin_country: + origen_final = part_master.fa_data.origin_country + + fraccion_limpia = fraccion_raw.replace(".", "").strip() + if fraccion_limpia: + fraccion_limpia = fraccion_limpia[:8].zfill(8) + + fraccion_db = db.query(TariffFraction).filter(TariffFraction.code == fraccion_limpia).first() + + fraccion_imprimir = fraccion_raw + if fraccion_db: + fraccion_imprimir = fraccion_db.fraction or fraccion_raw + else: + fraccion_imprimir = self._format_fraccion_fallback(fraccion_limpia) + + # FOR PACKING LIST: FINANCIALS ARE HIDDEN/EMPTY + v_unitario = "" + v_total = "" + + partidas_list.append(PartidaSchema( + numero_parte=num_parte_final, + descripcion=desc_final, + fraccion=fraccion_imprimir, + origen=origen_final, + advalorem="", # Hidden + preferencia="", # Hidden + cantidad_importacion=qty.quantity if qty else 0, + unidad_medida=qty.weight_unit if qty else "PZA", + cantidad_bultos=int(qty.package_quantity) if qty and qty.package_quantity else 0, + clave_bultos=(qty.package_key or "") if qty else "", + peso_neto=qty.net_weight if qty else 0, + peso_bruto=qty.gross_weight if qty else 0, + valor_costo_unitario=v_unitario, # Hidden + valor_total=v_total # Hidden + )) + + totales = self.calcular_totales(partidas_list, Decimal(factura_schema.tipo_cambio)) + + return PackingListSchema( + cliente_proveedor=cliente_proveedor, cliente_vendido=cliente_vendido, + cliente_enviado=cliente_enviado, factura=factura_schema, + partidas=partidas_list, totales=totales + ) + + except ValidationError as e: + print(f"Validation Error: {e.json()}") + raise HTTPException(status_code=500, detail=f"Schema Error: {e}") + except Exception as e: + print(f"Error Service A76: {e}") + raise HTTPException(status_code=500, detail=f"Error: {str(e)}") + + def calcular_totales(self, partidas: List[PartidaSchema], tipo_cambio: Decimal) -> TotalesSchema: + cant = sum(float(p.cantidad_importacion) for p in partidas) + # Financial totals hidden + peso_n = sum(float(p.peso_neto) for p in partidas) + peso_b = sum(float(p.peso_bruto) for p in partidas) + bultos = sum(p.cantidad_bultos for p in partidas) + + claves = [p.clave_bultos for p in partidas if p.clave_bultos] + clave_comun = max(set(claves), key=claves.count) if claves else "" + if bultos > 1 and clave_comun and not clave_comun.endswith("S"): clave_comun += "S" + + return TotalesSchema( + cantidad_total=self.formatear_numero(cant), bultos_total=bultos, clave_bultos=clave_comun, + peso_neto_total=self.formatear_numero(peso_n), peso_bruto_total=self.formatear_numero(peso_b), + valor_total_total="", valor_total_dolares="" + ) + + def generate_pdf(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> Tuple[bytes, str]: + if progress_callback: progress_callback(5, "Iniciando servicio de reporte...") + data = self.get_packing_list_data(db, invoice_id, company_id, progress_callback) + + if progress_callback: progress_callback(80, "Renderizando plantilla...") + + # LOGO LOGIC + logo_b64 = None + try: + comp_logo = db.query(Company).filter(Company.id == company_id).first() + if comp_logo and comp_logo.logo: + p = Path(comp_logo.logo) + target_path = p + if not target_path.exists(): + fallback = Path(f"app_data/logos/{company_id}") / p.name + if fallback.exists(): + target_path = fallback + + if target_path.exists(): + with open(target_path, "rb") as image_file: + encoded_string = base64.b64encode(image_file.read()).decode('utf-8') + mime = "image/png" + if target_path.suffix.lower() in ['.jpg', '.jpeg']: mime = "image/jpeg" + logo_b64 = f"data:{mime};base64,{encoded_string}" + except Exception as e: + print(f"Error loading logo: {e}") + + data.logo_b64 = logo_b64 # Assign logo to schema + + context = data.model_dump() + html_content = self.template.render(**context) + + if progress_callback: progress_callback(90, "Generando PDF final...") + + options = {'page-size': 'Letter', 'margin-top': '0.5in', 'margin-right': '0.5in', 'margin-bottom': '0.5in', 'margin-left': '0.5in', 'encoding': "UTF-8", 'enable-local-file-access': None} + pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config()) + + if progress_callback: progress_callback(100, "Completado") + + filename = f"PackingList_{data.factura.numero}.pdf" + return pdf, filename diff --git a/backend/api/v1/modules/a76/reports/importacion/packing_list/task.py b/backend/api/v1/modules/a76/reports/importacion/packing_list/task.py new file mode 100644 index 00000000..bf55991b --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/packing_list/task.py @@ -0,0 +1,51 @@ +import base64 +from core.celery_app import celery_app +from core.database import CoreSessionLocal +from .service import PackingListService + +@celery_app.task(bind=True) +def generar_packing_list_async(self, invoice_id: int, company_id: int): + """ + Tarea asíncrona para generar el Packing List + """ + db = CoreSessionLocal() + try: + service = PackingListService() + + def update_progress(percent, message): + self.update_state( + state='PROCESSING', + meta={ + 'current': percent, + 'total': 100, + 'status': message + } + ) + + pdf_bytes, filename = service.generate_pdf(db, invoice_id, company_id, update_progress) + + # Codificar a base64 para enviar por JSON + pdf_b64 = base64.b64encode(pdf_bytes).decode('utf-8') + + return { + 'status': 'success', + 'file_name': filename, + 'content': pdf_b64, + 'media_type': 'application/pdf' + } + + except Exception as e: + print(f"Error en tarea Packing List: {e}") + import traceback + traceback.print_exc() + self.update_state( + state='FAILURE', + meta={ + 'exc_type': type(e).__name__, + 'exc_message': str(e), + 'custom': 'Error generating PDF' + } + ) + raise e + finally: + db.close() diff --git a/backend/api/v1/modules/a76/reports/importacion/packing_list/templates/packing_list.html b/backend/api/v1/modules/a76/reports/importacion/packing_list/templates/packing_list.html new file mode 100644 index 00000000..06b9f725 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/packing_list/templates/packing_list.html @@ -0,0 +1,615 @@ + + + + + + Packing List - {{ factura.numero }} + + + + +
+
+
+

PACKING LIST / LISTA DE EMPAQUE

+
+

+
+
+

+


+
+
+
+
+ {% if logo_b64 %} +
+ +
+ {% endif %} +
+

{{ cliente_proveedor.header }}

+

{{ cliente_proveedor.nombre }}

+

{{ cliente_proveedor.direccion }} + {% if cliente_proveedor.num_exterior %} Ext: {{ cliente_proveedor.num_exterior }}{% endif %} + {% if cliente_proveedor.num_interior %} Int: {{ cliente_proveedor.num_interior }}{% endif %} +

+

{{ cliente_proveedor.colonia }} {% if cliente_proveedor.codigo_postal %} CP: {{ + cliente_proveedor.codigo_postal }}{% endif %}

+

{{ cliente_proveedor.ciudad }}, {{ cliente_proveedor.estado }}, {{ cliente_proveedor.pais }}

+

TAX ID: {{ cliente_proveedor.tax_id }} + {% if cliente_proveedor.programa and cliente_proveedor.programa != 'Ninguno' %} + {{ cliente_proveedor.programa }}: {{ cliente_proveedor.autorizacion }} + {% endif %} +

+


+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

FACTURA:

+
+

{{ factura.numero }}

+
+

Fecha:

+
+

{{ factura.fecha }}

+
+

T. Cambio:

+
+

{{ factura.tipo_cambio }}

+
+

Pedimento:

+
+

{{ factura.pedimento or '' }}

+
+

Clave:

+
+

{{ factura.clave_pedimento or '' }}

+
+

Remesa:

+
+

{{ factura.remesa or '' }}

+
+

Acuse:

+
+

{{ factura.acuse_electronico or 'N/A' }}

+
+

Agente Aduanal:

+

{{ factura.agente_aduanal or '' }}

+
+

Patente: {{ factura.patente or '' }}

+
+ Regimen:{{ + factura.regimen or '' }} + +

INCOTERM:

+

{{ factura.incoterm or '' }}

+
+ {% if factura.precinto %} +

Precinto: {{ factura.precinto }}

+ {% endif %} +
+

Aduana: {{ factura.aduana or '' }}

+
+ {% if factura.destino %} +

Destino: {{ factura.destino }}

+ {% endif %} +
+
+
+ +
+
+

{{ cliente_vendido.header }}

+

{{ cliente_vendido.nombre }}

+

{{ cliente_vendido.direccion }} + {% if cliente_vendido.num_exterior %} Ext: {{ cliente_vendido.num_exterior }}{% endif %} + {% if cliente_vendido.num_interior %} Int: {{ cliente_vendido.num_interior }}{% endif %} +

+

{{ cliente_vendido.colonia }} {% if cliente_vendido.codigo_postal %} CP: {{ + cliente_vendido.codigo_postal }}{% endif %}

+

{{ cliente_vendido.ciudad }}, {{ cliente_vendido.estado }}, {{ cliente_vendido.pais }} +

+

RFC: {{ cliente_vendido.tax_id }} + {% if cliente_vendido.programa and cliente_vendido.programa != 'Ninguno' %} + {{ cliente_vendido.programa }}: {{ cliente_vendido.autorizacion }} + {% endif %} +

+

+ {% if cliente_vendido.prosec %}PROSEC: {{ cliente_vendido.prosec }} {% endif %} + {% if cliente_vendido.reg_emp %}REG EMP: {{ cliente_vendido.reg_emp }} {% endif %} + {% if cliente_vendido.cert %}CERT: {{ cliente_vendido.cert }}{% endif %} +

+
+ +
+

{{ cliente_enviado.header }}

+

{{ cliente_enviado.nombre }}

+

{{ cliente_enviado.direccion }} + {% if cliente_enviado.num_exterior %} Ext: {{ cliente_enviado.num_exterior }}{% endif %} + {% if cliente_enviado.num_interior %} Int: {{ cliente_enviado.num_interior }}{% endif %} +

+

{{ cliente_enviado.colonia }} {% if cliente_enviado.codigo_postal %} CP: {{ + cliente_enviado.codigo_postal }}{% endif %}

+

{{ cliente_enviado.ciudad }}, {{ cliente_enviado.estado }}, {{ cliente_enviado.pais }} +

+

RFC: {{ cliente_enviado.tax_id }} + {% if cliente_enviado.programa and cliente_enviado.programa != 'Ninguno' %} + {{ cliente_enviado.programa }}: {{ cliente_enviado.autorizacion }} + {% endif %} +

+

+ {% if cliente_enviado.prosec %}PROSEC: {{ cliente_enviado.prosec }} {% endif %} + {% if cliente_enviado.reg_emp %}REG EMP: {{ cliente_enviado.reg_emp }} {% endif %} + {% if cliente_enviado.cert %}CERT: {{ cliente_enviado.cert }}{% endif %} +

+
+
+


+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% for partida in partidas %} + + + + + + + + + + {% endfor %} + + + + + + + + + + + + + + + + + + + +
+

Transportista:

+
+

{{ factura.transportista or '' }}

+
+

SCAC: {{ factura.scac or '' }}

+
+

INCOTERM:

+
+

{{ factura.incoterm or '' }}

+
+

Aduana: {{ factura.aduana or '' }}

+
+

Transporte:

+
+

{{ factura.transporte or '' }}: {{ factura.num_transporte or '' }}

+
+

CAAT: {{ factura.caat or '' }}

+
+

Placas: {{ factura.placas or '' }} / Rem: {{ factura.placas_remolque or + '' }}

+
+

Chofer/Licencia:

+
+

{{ factura.licencia_conductor or 'N/A' }}

+
+

Línea

+
+

Número de Parte

+

Descripción

+
+

Comercial

+
+

Empaque

+
+

Peso (KGS)

+
+

Cantidad

+
+

U.M.

+
+

Tipo

+
+

Neto

+
+

Bruto

+
+

{{ loop.index }}

+
+

{{ partida.numero_parte }}

+

{{ partida.descripcion }}

+
+

{{ partida.cantidad_importacion }}

+
+

{{ partida.unidad_medida }}

+
+

+ {% if partida.cantidad_bultos != 0 %}{{ partida.cantidad_bultos }}{% endif %} + {{ partida.clave_bultos }} +

+
+

{{ partida.peso_neto }}

+
+

{{ partida.peso_bruto }}

+
+

+ Observaciones: + TOTALES +

+
+

{{ totales.cantidad_total }}

+
+

+ {% if totales.bultos_total != 0 %}{{ totales.bultos_total }}{% endif %} + {{ totales.clave_bultos or '' }} +

+
+

{{ totales.peso_neto_total }}

+
+

{{ totales.peso_bruto_total }}

+
+

{{ factura.observaciones }}

+
+

+

{{ cliente_proveedor.nombre }}

+


+
+


+
+ + + \ No newline at end of file diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 24220d6d..6da0ba71 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -51,6 +51,8 @@ from api.v1.modules.public.reference_data.material_types.routes import router as # --- NUEVO IMPORT PARA REPORTES DE FACTURAS --- from .reports.importacion.facturas.routes import router as invoices_reports_router from .reports.importacion.consolidados.routes import router as consolidated_reports_router +from .reports.importacion.packing_list.routes import router as packing_list_router + # Router principal @@ -130,4 +132,10 @@ router.include_router( consolidated_reports_router, prefix="/a76/reports/importacion/consolidados", tags=["a76 / reports"] +) + +router.include_router( + packing_list_router, + prefix="/a76/reports/importacion/packing-lists", + tags=["a76 / reports"] ) \ No newline at end of file diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index c118db31..bc8bd492 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -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.importacion.packing_list.task" ] # Ruta al módulo donde están las tareas ) diff --git a/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts b/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts index 33c619f7..92d12917 100644 --- a/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts +++ b/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts @@ -1,15 +1,15 @@ -const BASE_URL = import.meta.env.VITE_API_URL || ''; +const BASE_URL = import.meta.env.VITE_API_URL || ''; export const invoicesReportsApi = { - + triggerPdfGeneration: async (invoiceId: number, companyId: number) => { const params = new URLSearchParams({ company_id: companyId.toString() }); const endpoint = `${BASE_URL}/v1/a76/reports/importacion/facturas/${invoiceId}/download-async?${params.toString()}`; - + const token = localStorage.getItem('access_token'); const response = await fetch(endpoint, { - method: 'POST', + method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' @@ -17,12 +17,12 @@ export const invoicesReportsApi = { }); if (!response.ok) throw new Error('Error al iniciar la generación'); - return await response.json(); + return await response.json(); }, getTaskStatus: async (taskId: string) => { - const endpoint = `${BASE_URL}/v1/a76/reports/importacion/facturas/tasks/${taskId}`; - + const endpoint = `${BASE_URL}/v1/a76/reports/importacion/facturas/tasks/${taskId}`; + const token = localStorage.getItem('access_token'); const response = await fetch(endpoint, { method: 'GET', @@ -31,5 +31,35 @@ export const invoicesReportsApi = { if (!response.ok) throw new Error('Error al consultar estado'); return await response.json(); + }, + + triggerPackingListGeneration: async (invoiceId: number, companyId: number) => { + const params = new URLSearchParams({ company_id: companyId.toString() }); + const endpoint = `${BASE_URL}/v1/a76/reports/importacion/packing-lists/${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 de Packing List'); + return await response.json(); + }, + + getPackingListTaskStatus: async (taskId: string) => { + const endpoint = `${BASE_URL}/v1/a76/reports/importacion/packing-lists/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 de Packing List'); + return await response.json(); } }; \ No newline at end of file diff --git a/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte index 36fc7e38..685c34a4 100644 --- a/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte +++ b/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte @@ -63,9 +63,12 @@ } else if (response.state === 'FAILURE') { hasError = true; - statusMessage = "Error al generar el PDF"; + // Intenta mostrar el mensaje de error real si viene en 'result' + const errMsg = response.result ? String(response.result) : "Error desconocido"; + statusMessage = `Error: ${errMsg}`; stopPolling(); - toast.error("Falló la generación del PDF"); + toast.error(`Falló la generación: ${errMsg}`); + console.error("Task failed with result:", response); } } catch (error) { console.error("Error polling task status:", error); diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index 06086755..e4e04619 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -13,7 +13,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, Package } from 'lucide-svelte'; // IMPORTANTE: Asegúrate de tener instalada svelte-sonner para las notificaciones import { toast } from "svelte-sonner"; @@ -394,6 +394,32 @@ console.error(error); toast.error("No se pudo iniciar la descarga del consolidado"); } + + } + + async function handleDownloadPackingList(invoice: any) { + if (!companyStore.activeCompany) { + toast.error("No hay empresa seleccionada"); + return; + } + + try { + // 1. Trigger: Start task in Celery + const { task_id } = await invoicesReportsApi.triggerPackingListGeneration( + invoice.id, + companyStore.activeCompany.id + ); + + // 2. Open progress dialog + currentTaskId = task_id; + // Use the specific status function for Packing List + currentStatusFunction = invoicesReportsApi.getPackingListTaskStatus; + showProgressDialog = true; + + } catch (error) { + console.error(error); + toast.error("No se pudo iniciar la descarga del Packing List"); + } } function onPdfComplete(result: any) { @@ -633,6 +659,10 @@ Consolidado + diff --git a/frontend/test_bits.js b/frontend/test_bits.js new file mode 100644 index 00000000..6228649d --- /dev/null +++ b/frontend/test_bits.js @@ -0,0 +1,7 @@ +import { Dialog } from "bits-ui"; +console.log("Dialog is:", Dialog); +try { + console.log("Dialog.Root is:", Dialog.Root); +} catch (e) { + console.log("Error accessing Dialog.Root:", e.message); +} From 75fc4d56f945d1fea262e111ef98811ea4434dfd Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Mon, 26 Jan 2026 18:03:35 -0600 Subject: [PATCH 2/9] Confguracion de la plantilla --- .../packing_list/templates/packing_list.html | 281 ++++++++---------- 1 file changed, 124 insertions(+), 157 deletions(-) diff --git a/backend/api/v1/modules/a76/reports/importacion/packing_list/templates/packing_list.html b/backend/api/v1/modules/a76/reports/importacion/packing_list/templates/packing_list.html index 06b9f725..4ed057d9 100644 --- a/backend/api/v1/modules/a76/reports/importacion/packing_list/templates/packing_list.html +++ b/backend/api/v1/modules/a76/reports/importacion/packing_list/templates/packing_list.html @@ -313,7 +313,7 @@ -

FACTURA:

+

PACKING LIST / LISTA DE EMPAQUE:

{{ factura.numero }}

@@ -363,7 +363,7 @@ -

Agente Aduanal:

+

Mx custom broker / agente aduanal mexicano:

{{ factura.agente_aduanal or '' }}

@@ -451,164 +451,131 @@


- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {% for partida in partidas %} - - - - - - - - - - {% endfor %} - + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - + + {% for partida in partidas %} + + + + + + + + + + + {% endfor %} + + + + + + + + + + + + + + + + + + + +
-

Transportista:

-
-

{{ factura.transportista or '' }}

-
-

SCAC: {{ factura.scac or '' }}

-
-

INCOTERM:

-
-

{{ factura.incoterm or '' }}

-
-

Aduana: {{ factura.aduana or '' }}

-
-

Transporte:

-
-

{{ factura.transporte or '' }}: {{ factura.num_transporte or '' }}

-
-

CAAT: {{ factura.caat or '' }}

-
-

Placas: {{ factura.placas or '' }} / Rem: {{ factura.placas_remolque or - '' }}

-
-

Chofer/Licencia:

-
-

{{ factura.licencia_conductor or 'N/A' }}

-
-

Línea

-
-

Número de Parte

-

Descripción

-
-

Comercial

-
-

Empaque

-
-

Peso (KGS)

-
-

Cantidad

-
-

U.M.

-
-

Tipo

-
-

Neto

-
-

Bruto

-
-

{{ loop.index }}

-
-

{{ partida.numero_parte }}

-

{{ partida.descripcion }}

-
-

{{ partida.cantidad_importacion }}

-
-

{{ partida.unidad_medida }}

-
-

- {% if partida.cantidad_bultos != 0 %}{{ partida.cantidad_bultos }}{% endif %} - {{ partida.clave_bultos }} -

-
-

{{ partida.peso_neto }}

-
-

{{ partida.peso_bruto }}

-
+

Línea

+
+

Número de Parte

+

Descripción

+
+

Comercial

+
+

Empaque

+
+

Peso (KGS)

+
+

Cantidad

+
+

U.M.

+
+

Cant.

+
+

Tipo

+
+

Neto

+
+

Bruto

+
-

- Observaciones: - TOTALES -

-
-

{{ totales.cantidad_total }}

-
-

- {% if totales.bultos_total != 0 %}{{ totales.bultos_total }}{% endif %} - {{ totales.clave_bultos or '' }} -

-
-

{{ totales.peso_neto_total }}

-
-

{{ totales.peso_bruto_total }}

-
-

{{ factura.observaciones }}

-
-

-

{{ cliente_proveedor.nombre }}

-


-
-


-
+

{{ loop.index }}

+
+

{{ partida.numero_parte }}

+

{{ partida.descripcion }}

+
+

{{ partida.cantidad_importacion }}

+
+

{{ partida.unidad_medida }}

+
+

+ {% if partida.cantidad_bultos != 0 %}{{ partida.cantidad_bultos }}{% endif %} +

+
+

+ {{ partida.clave_bultos }} +

+
+

{{ partida.peso_neto }}

+
+

{{ partida.peso_bruto }}

+
+

+ Observaciones: + TOTALES +

+
+

{{ totales.cantidad_total }}

+
+

+ {% if totales.bultos_total != 0 %}{{ totales.bultos_total }}{% endif %} +

+
+

+ {{ totales.clave_bultos or '' }} +

+
+

{{ totales.peso_neto_total }}

+
+

{{ totales.peso_bruto_total }}

+
+

{{ factura.observaciones }}

+
+

+

{{ cliente_proveedor.nombre }}

+


+
+


+
From 50504be30b4c833d8b74807f6f7bc47a7508a2ec Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Tue, 27 Jan 2026 10:26:07 -0600 Subject: [PATCH 3/9] Se pusieron los datos correctos en la tabla --- .../importacion/packing_list/schemas.py | 5 + .../importacion/packing_list/service.py | 59 ++- .../packing_list/templates/packing_list.html | 369 ++++++++---------- .../dropdown-menu/dropdown-menu-root.svelte | 7 + .../ui/dropdown-menu/dropdown-menu-sub.svelte | 7 + .../lib/components/ui/dropdown-menu/index.ts | 6 +- 6 files changed, 223 insertions(+), 230 deletions(-) create mode 100644 frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-root.svelte create mode 100644 frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte diff --git a/backend/api/v1/modules/a76/reports/importacion/packing_list/schemas.py b/backend/api/v1/modules/a76/reports/importacion/packing_list/schemas.py index cbbd0958..fcaaae40 100644 --- a/backend/api/v1/modules/a76/reports/importacion/packing_list/schemas.py +++ b/backend/api/v1/modules/a76/reports/importacion/packing_list/schemas.py @@ -54,6 +54,7 @@ class PartidaSchema(BaseModel): numero_parte: str descripcion: str fraccion: str + fraccion_americana: Optional[str] = "" origen: str advalorem:Optional[str] = "" @@ -65,6 +66,8 @@ class PartidaSchema(BaseModel): clave_bultos: str peso_neto: Union[float, str] peso_bruto: Union[float, str] + peso_neto_lbs: Union[float, str] = 0.0 + peso_bruto_lbs: Union[float, str] = 0.0 valor_costo_unitario: Union[float, str] = "" valor_total: Union[float, str] = "" @@ -74,6 +77,8 @@ class TotalesSchema(BaseModel): clave_bultos: str = "" peso_neto_total: Union[float, str] peso_bruto_total: Union[float, str] + peso_neto_total_lbs: Union[float, str] = 0.0 + peso_bruto_total_lbs: Union[float, str] = 0.0 valor_total_total: Union[float, str] = "" valor_total_dolares: Union[float, str] = "" diff --git a/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py b/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py index a31a1eb2..43f0036d 100644 --- a/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py @@ -3,7 +3,7 @@ import base64 import pdfkit from pathlib import Path from decimal import Decimal -from typing import Tuple, List, Callable, Optional, Dict +from typing import Tuple, List, Callable, Optional from jinja2 import Environment, FileSystemLoader, select_autoescape from fastapi import HTTPException @@ -12,9 +12,9 @@ from sqlalchemy.orm import Session # --- MODELOS --- from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics -from api.v1.modules.a76.items.line_financials.models import LineFinancial from api.v1.modules.a76.items.line_quantities.models import LineQuantity from api.v1.modules.a76.items.line_items.models import LineItem +from api.v1.modules.a76.items.line_customs.models import LineCustom from api.v1.modules.a76.clients_and_providers.models import ( ClientProvider, ClientProviderAddress, ClientProviderPrograms ) @@ -60,7 +60,6 @@ class PackingListService: path = next((p for p in paths if p and Path(p).exists()), None) if not path: - # If we are in dev and cannot find it, try to mock it or raise clearer error if shutil.which("echo"): print("WARNING: wkhtmltopdf not found, PDF generation will fail.") raise RuntimeError(f"wkhtmltopdf binary not found. Searched in: {paths}") @@ -133,7 +132,7 @@ class PackingListService: company = db.query(Company).filter(Company.id == header.company_id).first() # Datos Default (Company/Importer) cliente_default = ClienteSchema( - header="Importador / consignatario:", + header="Importer / Consignee:", nombre=getattr(company, 'name', "Empresa Local"), direccion="DOMICILIO FISCAL", num_exterior="", colonia="", codigo_postal="", ciudad="", estado="", pais="MEX", @@ -144,15 +143,18 @@ class PackingListService: # Left Side Logic (Consignatario / Sold To) cliente_vendido = cliente_default if compliance and compliance.sold_to_id: - raw_header = compliance.sold_to_header or "CONSIGNATARIO" - clean_header = raw_header.replace("_", " ").capitalize() + ":" + raw = (compliance.sold_to_header or "").upper() + if "CONSIGN" in raw: + clean_header = "Consignee / Consignatario:" + else: + clean_header = "Sold To / Vendido a:" + cliente_vendido = self._obtener_datos_cliente(db, compliance.sold_to_id, clean_header) # Right Side Logic (Enviado A / Shipped To) cliente_enviado = cliente_default if compliance and compliance.shipped_to_id: - raw_header_shipped = compliance.shipped_to_header or "DESTINATARIO" - clean_header_shipped = raw_header_shipped.replace("_", " ").capitalize() + ":" + clean_header_shipped = "Shipped To / Enviado a:" cliente_enviado = self._obtener_datos_cliente(db, compliance.shipped_to_id, clean_header_shipped) remesa_valor = str(compliance.remesa) if (compliance and compliance.remesa) else "" @@ -241,17 +243,45 @@ class PackingListService: for line in lines: qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first() + + # --- WEIGHT CALCULATION LOGIC --- + peso_neto_kg = 0.0 + peso_bruto_kg = 0.0 + peso_neto_lb = 0.0 + peso_bruto_lb = 0.0 + + if qty: + raw_net = float(qty.net_weight or 0) + raw_gross = float(qty.gross_weight or 0) + unit = (qty.weight_unit or "KG").upper() + + if unit == "LB" or unit == "LBS": + peso_neto_lb = raw_net + peso_bruto_lb = raw_gross + peso_neto_kg = raw_net / 2.20462 + peso_bruto_kg = raw_gross / 2.20462 + else: # Default KG + peso_neto_kg = raw_net + peso_bruto_kg = raw_gross + peso_neto_lb = raw_net * 2.20462 + peso_bruto_lb = raw_gross * 2.20462 + # -------------------------------- + + custom_obj = db.query(LineCustom).filter(LineCustom.item_line_id == line.id).first() part_master = db.query(Part).filter(Part.id == line.part_number).first() desc_final = "S/D" num_parte_final = str(line.part_number or "S/N") fraccion_raw = "" origen_final = "MEX" + uom_comercial = "PZA" # Default UOM if part_master: desc_final = part_master.description_spanish or part_master.description_english or "Sin Desc." num_parte_final = part_master.part_number fraccion_raw = part_master.fraction if part_master.fraction else "" + # Commercial UOM from Part Master + uom_comercial = part_master.unit_of_measure or "PZA" if part_master.fa_data and part_master.fa_data.origin_country: origen_final = part_master.fa_data.origin_country @@ -276,15 +306,18 @@ class PackingListService: numero_parte=num_parte_final, descripcion=desc_final, fraccion=fraccion_imprimir, + fraccion_americana=custom_obj.american_fraction if custom_obj and custom_obj.american_fraction else "", origen=origen_final, advalorem="", # Hidden preferencia="", # Hidden cantidad_importacion=qty.quantity if qty else 0, - unidad_medida=qty.weight_unit if qty else "PZA", + unidad_medida=uom_comercial, # Commercial UOM (PCS, EA) cantidad_bultos=int(qty.package_quantity) if qty and qty.package_quantity else 0, clave_bultos=(qty.package_key or "") if qty else "", - peso_neto=qty.net_weight if qty else 0, - peso_bruto=qty.gross_weight if qty else 0, + peso_neto=self.formatear_numero(peso_neto_kg), + peso_bruto=self.formatear_numero(peso_bruto_kg), + peso_neto_lbs=self.formatear_numero(peso_neto_lb), + peso_bruto_lbs=self.formatear_numero(peso_bruto_lb), valor_costo_unitario=v_unitario, # Hidden valor_total=v_total # Hidden )) @@ -309,6 +342,9 @@ class PackingListService: # Financial totals hidden peso_n = sum(float(p.peso_neto) for p in partidas) peso_b = sum(float(p.peso_bruto) for p in partidas) + peso_n_lbs = sum(float(p.peso_neto_lbs) for p in partidas) + peso_b_lbs = sum(float(p.peso_bruto_lbs) for p in partidas) + bultos = sum(p.cantidad_bultos for p in partidas) claves = [p.clave_bultos for p in partidas if p.clave_bultos] @@ -318,6 +354,7 @@ class PackingListService: return TotalesSchema( cantidad_total=self.formatear_numero(cant), bultos_total=bultos, clave_bultos=clave_comun, peso_neto_total=self.formatear_numero(peso_n), peso_bruto_total=self.formatear_numero(peso_b), + peso_neto_total_lbs=self.formatear_numero(peso_n_lbs), peso_bruto_total_lbs=self.formatear_numero(peso_b_lbs), valor_total_total="", valor_total_dolares="" ) diff --git a/backend/api/v1/modules/a76/reports/importacion/packing_list/templates/packing_list.html b/backend/api/v1/modules/a76/reports/importacion/packing_list/templates/packing_list.html index 4ed057d9..a82f2aaa 100644 --- a/backend/api/v1/modules/a76/reports/importacion/packing_list/templates/packing_list.html +++ b/backend/api/v1/modules/a76/reports/importacion/packing_list/templates/packing_list.html @@ -278,8 +278,26 @@

-

-


+ + + + + + + + + + + +
+

PACKING LIST / LISTA DE EMPAQUE:

+
+

{{ factura.numero }}

+
+

MX CUSTOM BROKER / AGENTE ADUANAL MEXICANO:

+
+

{{ factura.agente_aduanal or '' }}

+
@@ -289,7 +307,7 @@
{% endif %} -
+

{{ cliente_proveedor.header }}

{{ cliente_proveedor.nombre }}

{{ cliente_proveedor.direccion }} @@ -308,97 +326,7 @@

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

PACKING LIST / LISTA DE EMPAQUE:

-
-

{{ factura.numero }}

-
-

Fecha:

-
-

{{ factura.fecha }}

-
-

T. Cambio:

-
-

{{ factura.tipo_cambio }}

-
-

Pedimento:

-
-

{{ factura.pedimento or '' }}

-
-

Clave:

-
-

{{ factura.clave_pedimento or '' }}

-
-

Remesa:

-
-

{{ factura.remesa or '' }}

-
-

Acuse:

-
-

{{ factura.acuse_electronico or 'N/A' }}

-
-

Mx custom broker / agente aduanal mexicano:

-

{{ factura.agente_aduanal or '' }}

-
-

Patente: {{ factura.patente or '' }}

-
- Regimen:{{ - factura.regimen or '' }} - -

INCOTERM:

-

{{ factura.incoterm or '' }}

-
- {% if factura.precinto %} -

Precinto: {{ factura.precinto }}

- {% endif %} -
-

Aduana: {{ factura.aduana or '' }}

-
- {% if factura.destino %} -

Destino: {{ factura.destino }}

- {% endif %} -
-
+
@@ -451,131 +379,140 @@


+ + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + + {% for partida in partidas %} + + + + + + + + + + + {% endfor %} + - - {% for partida in partidas %} - - - - - - - - - - - {% endfor %} - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + +
+

Line / Línea

+
+

Part Number / Número de Parte

+

Description / Descripción

+
+

Quantity / Cantidad

+
+

Packing / Empaque

+
+

Weight / Peso (KGS)

+
+

Qty / Cant.

+
+

U.M.

+
+

Qty / Cant.

+
+

Type / Tipo

+
+

Net / Neto

+

(LBS / KGS)

+
+

Gross / Bruto

+

(LBS / KGS)

+
-

Línea

-
-

Número de Parte

-

Descripción

-
-

Comercial

-
-

Empaque

-
-

Peso (KGS)

-
-

Cantidad

-
-

U.M.

-
-

Cant.

-
-

Tipo

-
-

Neto

-
-

Bruto

-
+

{{ loop.index }}

+
+

{{ partida.numero_parte }}

+

{{ partida.descripcion }} / {{ partida.fraccion_americana }} / {{ partida.origen }} +

+
+

{{ partida.cantidad_importacion }}

+
+

{{ partida.unidad_medida }}

+
+

+ {% if partida.cantidad_bultos != 0 %}{{ partida.cantidad_bultos }}{% endif %} +

+
+

+ {{ partida.clave_bultos }} +

+
+

{{ partida.peso_neto_lbs }}

+

{{ partida.peso_neto }}

+
+

{{ partida.peso_bruto_lbs }}

+

{{ partida.peso_bruto }}

+
-

{{ loop.index }}

-
-

{{ partida.numero_parte }}

-

{{ partida.descripcion }}

-
-

{{ partida.cantidad_importacion }}

-
-

{{ partida.unidad_medida }}

-
-

- {% if partida.cantidad_bultos != 0 %}{{ partida.cantidad_bultos }}{% endif %} -

-
-

- {{ partida.clave_bultos }} -

-
-

{{ partida.peso_neto }}

-
-

{{ partida.peso_bruto }}

-
-

- Observaciones: - TOTALES -

-
-

{{ totales.cantidad_total }}

-
-

- {% if totales.bultos_total != 0 %}{{ totales.bultos_total }}{% endif %} -

-
-

- {{ totales.clave_bultos or '' }} -

-
-

{{ totales.peso_neto_total }}

-
-

{{ totales.peso_bruto_total }}

-
-

{{ factura.observaciones }}

-
-

-

{{ cliente_proveedor.nombre }}

-


-
-


-
+

+ Observaciones: + TOTALES +

+
+

{{ totales.cantidad_total }}

+
+

+ {% if totales.bultos_total != 0 %}{{ totales.bultos_total }}{% endif %} +

+
+

+ {{ totales.clave_bultos or '' }} +

+
+

{{ totales.peso_neto_total_lbs }} LBS

+

{{ totales.peso_neto_total }} KGS

+
+

{{ totales.peso_bruto_total_lbs }} LBS

+

{{ totales.peso_bruto_total }} KGS

+
+

{{ factura.observaciones }}

+
+

+

{{ cliente_proveedor.nombre }}

+


+
+


+
diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-root.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-root.svelte new file mode 100644 index 00000000..7ac64712 --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-root.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte new file mode 100644 index 00000000..9b14b3ec --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/dropdown-menu/index.ts b/frontend/src/lib/components/ui/dropdown-menu/index.ts index 1cf9f701..9ac1bdd1 100644 --- a/frontend/src/lib/components/ui/dropdown-menu/index.ts +++ b/frontend/src/lib/components/ui/dropdown-menu/index.ts @@ -1,4 +1,4 @@ -import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; +// import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; import CheckboxItem from "./dropdown-menu-checkbox-item.svelte"; import Content from "./dropdown-menu-content.svelte"; import Group from "./dropdown-menu-group.svelte"; @@ -12,8 +12,8 @@ import Trigger from "./dropdown-menu-trigger.svelte"; import SubContent from "./dropdown-menu-sub-content.svelte"; import SubTrigger from "./dropdown-menu-sub-trigger.svelte"; import GroupHeading from "./dropdown-menu-group-heading.svelte"; -const Sub = DropdownMenuPrimitive.Sub; -const Root = DropdownMenuPrimitive.Root; +import Sub from "./dropdown-menu-sub.svelte"; +import Root from "./dropdown-menu-root.svelte"; export { CheckboxItem, From c97b9e3dafb0215419046d11d92f58eae2f9a2e1 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Tue, 27 Jan 2026 11:34:10 -0600 Subject: [PATCH 4/9] Se ajusto la tabla de quantity y de packeges, y se creo la relacion --- .../a76/general_catalogs/packages/models.py | 2 +- .../a76/items/line_quantities/models.py | 9 ++++++--- .../importacion/packing_list/service.py | 2 +- drop_column.sql | 2 ++ drop_desc_column.sql | 2 ++ fix_data.sql | 10 ++++++++++ fix_db_column.py | 18 ++++++++++++++++++ fix_schema.sql | 2 ++ 8 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 drop_column.sql create mode 100644 drop_desc_column.sql create mode 100644 fix_data.sql create mode 100644 fix_db_column.py create mode 100644 fix_schema.sql diff --git a/backend/api/v1/modules/a76/general_catalogs/packages/models.py b/backend/api/v1/modules/a76/general_catalogs/packages/models.py index f191fa94..92b07647 100644 --- a/backend/api/v1/modules/a76/general_catalogs/packages/models.py +++ b/backend/api/v1/modules/a76/general_catalogs/packages/models.py @@ -19,7 +19,7 @@ class Package(Base, TenantScopedMixin, TimestampMixin): __table_args__ = ( PrimaryKeyConstraint("id", name="packages_pkey"), UniqueConstraint("tenant_id", "company_id", "key", name="packages_key_ukey"), - {"schema": "a76"}, + {"schema": "a76", "extend_existing": True}, ) id: Mapped[int] = mapped_column(Integer, primary_key=True) diff --git a/backend/api/v1/modules/a76/items/line_quantities/models.py b/backend/api/v1/modules/a76/items/line_quantities/models.py index c7a60558..cc588e1e 100644 --- a/backend/api/v1/modules/a76/items/line_quantities/models.py +++ b/backend/api/v1/modules/a76/items/line_quantities/models.py @@ -4,6 +4,8 @@ from sqlalchemy import String, Integer, Numeric, SmallInteger, ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship from core.database import Base +from api.v1.modules.a76.general_catalogs.packages.models import Package + if TYPE_CHECKING: from ..line_items.models import LineItem @@ -38,13 +40,14 @@ class LineQuantity(Base): net_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # PESONETO gross_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # PESOBRUTO + # Packaging - package_key: Mapped[Optional[str]] = mapped_column(String(5)) # CLAVEBULTOS + package_id: Mapped[Optional[int]] = mapped_column(ForeignKey("a76.packages.id")) package_quantity: Mapped[Optional[int]] = mapped_column(Integer) # CANTBULTOS - package_description: Mapped[Optional[str]] = mapped_column(String(40)) # DESCBULTOS container_quantity: Mapped[Optional[int]] = mapped_column(SmallInteger) # CANTBULCONT container_description: Mapped[Optional[str]] = mapped_column(String(40)) # DESCCONTENEDOR box_count: Mapped[Optional[str]] = mapped_column(String(30)) # NOCAJAS # Relationship (one-to-one) - line: Mapped["LineItem"] = relationship(back_populates="quantity") \ No newline at end of file + line: Mapped["LineItem"] = relationship(back_populates="quantity") + package_info: Mapped[Optional["Package"]] = relationship(Package) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py b/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py index 43f0036d..a6163592 100644 --- a/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py @@ -313,7 +313,7 @@ class PackingListService: cantidad_importacion=qty.quantity if qty else 0, unidad_medida=uom_comercial, # Commercial UOM (PCS, EA) cantidad_bultos=int(qty.package_quantity) if qty and qty.package_quantity else 0, - clave_bultos=(qty.package_key or "") if qty else "", + clave_bultos=(qty.package_info.key if qty and qty.package_info else "") if qty else "", peso_neto=self.formatear_numero(peso_neto_kg), peso_bruto=self.formatear_numero(peso_bruto_kg), peso_neto_lbs=self.formatear_numero(peso_neto_lb), diff --git a/drop_column.sql b/drop_column.sql new file mode 100644 index 00000000..c0845c83 --- /dev/null +++ b/drop_column.sql @@ -0,0 +1,2 @@ +ALTER TABLE a76.item_line_quantities +DROP COLUMN IF EXISTS package_key; \ No newline at end of file diff --git a/drop_desc_column.sql b/drop_desc_column.sql new file mode 100644 index 00000000..e8e26aa2 --- /dev/null +++ b/drop_desc_column.sql @@ -0,0 +1,2 @@ +ALTER TABLE a76.item_line_quantities +DROP COLUMN IF EXISTS package_description; \ No newline at end of file diff --git a/fix_data.sql b/fix_data.sql new file mode 100644 index 00000000..b3003427 --- /dev/null +++ b/fix_data.sql @@ -0,0 +1,10 @@ +-- Actualizar el nuevo campo package_id usando el valor numérico guardado erróneamente en package_key +UPDATE a76.item_line_quantities +SET + package_id = CAST(package_key AS INTEGER) +WHERE + package_key ~ '^\d+$' + AND package_id IS NULL; + +-- Opcional: Limpiar el campo package_key si ya se migró (para evitar confusión futura, pero mejor dejarlo por seguridad) +-- UPDATE a76.item_line_quantities SET package_key = NULL WHERE package_id IS NOT NULL; \ No newline at end of file diff --git a/fix_db_column.py b/fix_db_column.py new file mode 100644 index 00000000..7145b2f4 --- /dev/null +++ b/fix_db_column.py @@ -0,0 +1,18 @@ +from sqlalchemy import text +from core.database import SessionLocal + +def add_column(): + db = SessionLocal() + try: + sql = text("ALTER TABLE a76.item_line_quantities ADD COLUMN IF NOT EXISTS package_id INTEGER REFERENCES a76.packages(id);") + db.execute(sql) + db.commit() + print("Successfully added package_id column.") + except Exception as e: + print(f"Error: {e}") + db.rollback() + finally: + db.close() + +if __name__ == "__main__": + add_column() diff --git a/fix_schema.sql b/fix_schema.sql new file mode 100644 index 00000000..7106b3c8 --- /dev/null +++ b/fix_schema.sql @@ -0,0 +1,2 @@ +ALTER TABLE a76.item_line_quantities +ADD COLUMN IF NOT EXISTS package_id INTEGER REFERENCES a76.packages (id); \ No newline at end of file From 782c759a66a34e5811db999de9934229c5194a0d Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Tue, 27 Jan 2026 11:37:24 -0600 Subject: [PATCH 5/9] Se borro archivo --- fix_db_column.py | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 fix_db_column.py diff --git a/fix_db_column.py b/fix_db_column.py deleted file mode 100644 index 7145b2f4..00000000 --- a/fix_db_column.py +++ /dev/null @@ -1,18 +0,0 @@ -from sqlalchemy import text -from core.database import SessionLocal - -def add_column(): - db = SessionLocal() - try: - sql = text("ALTER TABLE a76.item_line_quantities ADD COLUMN IF NOT EXISTS package_id INTEGER REFERENCES a76.packages(id);") - db.execute(sql) - db.commit() - print("Successfully added package_id column.") - except Exception as e: - print(f"Error: {e}") - db.rollback() - finally: - db.close() - -if __name__ == "__main__": - add_column() From 47c4984d3f94875b2033fbf171c5bc3980467bc5 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Thu, 29 Jan 2026 08:56:25 -0600 Subject: [PATCH 6/9] Se integro la plantilla de aviso de consolidado --- .../a76/items/line_quantities/schemas.py | 3 +- .../a76/reports/exportacion/__init__.py | 0 .../exportacion/aviso_consolidado/__init__.py | 0 .../exportacion/aviso_consolidado/routes.py | 46 +++ .../exportacion/aviso_consolidado/service.py | 305 +++++++++++++++ .../exportacion/aviso_consolidado/task.py | 50 +++ .../templates/avcon_exp.html | 357 ++++++++++++++++++ .../importacion/facturas/mex/service.py | 2 +- backend/api/v1/modules/a76/router.py | 7 + backend/core/celery_app.py | 3 +- backend/core/error_handlers.py | 3 +- .../a76/reports/reports-aviso-consolidado.ts | 35 ++ .../routes/dashboard/invoices/+page.svelte | 34 +- 13 files changed, 839 insertions(+), 6 deletions(-) create mode 100644 backend/api/v1/modules/a76/reports/exportacion/__init__.py create mode 100644 backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/__init__.py create mode 100644 backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/routes.py create mode 100644 backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/service.py create mode 100644 backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/task.py create mode 100644 backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/templates/avcon_exp.html create mode 100644 frontend/src/lib/api/dashboard/a76/reports/reports-aviso-consolidado.ts diff --git a/backend/api/v1/modules/a76/items/line_quantities/schemas.py b/backend/api/v1/modules/a76/items/line_quantities/schemas.py index 71552342..3050551e 100644 --- a/backend/api/v1/modules/a76/items/line_quantities/schemas.py +++ b/backend/api/v1/modules/a76/items/line_quantities/schemas.py @@ -27,9 +27,8 @@ class LineQuantityBase(BaseModel): gross_weight: Optional[Decimal] = Field(None, description="Gross weight (PESOBRUTO)") # Packaging - package_key: Optional[str] = Field(None, max_length=5, description="Package key (CLAVEBULTOS)") + package_id: Optional[int] = Field(None, description="Package ID (GBultos)") package_quantity: Optional[int] = Field(None, description="Package quantity (CANTBULTOS)") - package_description: Optional[str] = Field(None, max_length=40, description="Package description (DESCBULTOS)") container_quantity: Optional[int] = Field(None, description="Container quantity (CANTBULCONT)") container_description: Optional[str] = Field(None, max_length=40, description="Container description (DESCCONTENEDOR)") box_count: Optional[str] = Field(None, max_length=30, description="Box count (NOCAJAS)") diff --git a/backend/api/v1/modules/a76/reports/exportacion/__init__.py b/backend/api/v1/modules/a76/reports/exportacion/__init__.py new file mode 100644 index 00000000..e69de29b 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/routes.py b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/routes.py new file mode 100644 index 00000000..09cf878f --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/routes.py @@ -0,0 +1,46 @@ + +from typing import Dict, Any +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session +from celery.result import AsyncResult +from core.celery_app import celery_app +from core.database import get_core_db +from core.security import get_current_user, validate_access_to_resource +from .task import generar_pdf_aviso_consolidado_exp_async + +router = APIRouter() + +@router.get("/tasks/{task_id}") +async def get_task_status( + task_id: str, + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_core_db) +): + task_result = AsyncResult(task_id, app=celery_app) + + 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 + +@router.post("/{invoice_id}/download-async") +async def trigger_descarga_aviso_consolidado_exp( + invoice_id: int, + company_id: int = Query(..., description="ID de la empresa"), + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_core_db) +): + validate_access_to_resource(db, company_id, current_user) + task = generar_pdf_aviso_consolidado_exp_async.delay(invoice_id, company_id) + return {"task_id": task.id, "message": "Generación iniciada"} diff --git a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/service.py b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/service.py new file mode 100644 index 00000000..a85c0c82 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/service.py @@ -0,0 +1,305 @@ + +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 + +# --- MODELOS --- +from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceComplianceMx, InvoiceFinancials, InvoiceLogistics +from api.v1.modules.a76.general_catalogs.company.models import Company +from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos +from api.v1.modules.a76.customs_brokers.models import CustomsBroker, CustomsBrokerPersonnel +from api.v1.modules.a76.clients_and_providers.models import ClientProvider, ClientProviderAddress, ClientProviderPrograms + +# --- SCHEMAS FOR TEMPLATE CONTEXT --- +class EmpresaSchema(BaseModel): + rfc: str + razon_social: str + direccion_completa: str + tax_id: Optional[str] = None # Extra info just in case + +class PersonaSchema(BaseModel): + nombre: str + rfc: str + curp: str + +class AvisoSchema(BaseModel): + pedimento_completo: str + tipo_operacion: str + clave_pedimento: str + acus_valor: str + aduana_seccion: str + numero_remesa: str + peso_bruto: str + codigo_aceptacion: str + codigo_barras_b64: Optional[str] = None + clave_seccion: str + marcas_numeros_bultos: str + candados: List[str] + vehiculo_placas: str + vehiculo_tipo: str + observaciones: str + numero_certificado: str + tipo_documento: str # NEW: Invoice Type + firma_electronica: str + +class AvisoConsolidadoContext(BaseModel): + aviso: AvisoSchema + empresa: EmpresaSchema + agente: PersonaSchema + mandatario: PersonaSchema + +class AvisoConsolidadoExportacionService: + 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('avcon_exp.html') + + def _get_wkhtmltopdf_config(self): + path = shutil.which("wkhtmltopdf") or "/usr/local/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) -> AvisoConsolidadoContext: + try: + if progress_callback: progress_callback(10, "Buscando factura...") + + # Fetch minimal real data if possible, or use placeholders as requested + header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first() + if not header: + # We can't strictly raise 404 if we want to support testing with non-existent IDs for pure UI check, + # but valid workflow requires a real invoice. Raising 404 is better practice. + 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, "Preparando datos...") + + # --- FETCHING REAL DATA --- + + # 1. Compliance & Pedimento + compliance = header.compliance_mx + pedimento = None + if compliance and compliance.pedimento_id: + pedimento = db.query(Pedimentos).filter(Pedimentos.id == compliance.pedimento_id).first() + + # Pedimento Completo Construction + pedimento_txt = "S/P" + clave_ped = "" + if pedimento: + # Format: YY OFF LIC NUMBER + year = pedimento.year or "" + office = pedimento.customs_office or "" + lic = pedimento.license or "" + num = pedimento.pedimento_number or "" + pedimento_txt = f"{year} {office} {lic} {num}" + clave_ped = pedimento.pedimento_code or "" + + # 2. Company Address + direccion_empresa = "DOMICILIO NO REGISTRADO" + if company and company.addresses: + # Try to find fiscal address or first available + addr = company.addresses[0] # Default + # TODO: Check if there's a specific flag for fiscal address in submodel + + parts = [] + if addr.street: parts.append(addr.street) + if addr.exterior_number: parts.append(f"No. {addr.exterior_number}") + if addr.interior_number: parts.append(f"Int. {addr.interior_number}") + if addr.neighborhood: parts.append(f"Col. {addr.neighborhood}") + if addr.postal_code: parts.append(f"CP {addr.postal_code}") + if addr.city: parts.append(addr.city) + if addr.state: parts.append(addr.state) + if addr.country: parts.append(addr.country) + + if parts: + direccion_empresa = ", ".join(parts).upper() + + # Determine Mexican Entity based on Operation Type + # IMP -> Client (Sold To/Consignee) + # EXP -> Company (Tenant) + + target_entity_data = { + "rfc": getattr(company, 'rfc', "") or "", + "razon_social": getattr(company, 'name', "") or "", + "direccion_completa": direccion_empresa + } + + op_type = header.operation_type.upper() if header.operation_type else "EXP" + + if op_type == "IMP" and compliance and compliance.sold_to_id: + # Fetch Client Data + client_id = compliance.sold_to_id + client_obj = db.query(ClientProvider).filter(ClientProvider.id == client_id).first() + if client_obj: + # Fetch Address + c_addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == client_id).first() + # Fetch Fiscal Data (RFC) + c_prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == client_id).first() + + c_rfc = "" + if c_prog and c_prog.tax_id: c_rfc = c_prog.tax_id + elif hasattr(client_obj, 'rfc'): c_rfc = client_obj.rfc + + c_dir_str = "DOMICILIO NO REGISTRADO" + if c_addr: + parts_c = [] + if c_addr.streets: parts_c.append(c_addr.streets) + if c_addr.exterior_number: parts_c.append(f"No. {c_addr.exterior_number}") + if c_addr.neighborhood: parts_c.append(f"Col. {c_addr.neighborhood}") + if c_addr.city: parts_c.append(c_addr.city) + if c_addr.state: parts_c.append(c_addr.state) + if c_addr.postal_code: parts_c.append(f"CP {c_addr.postal_code}") + if parts_c: + c_dir_str = ", ".join(parts_c).upper() + + target_entity_data = { + "rfc": c_rfc or "", + "razon_social": client_obj.name or client_obj.short_name or "", + "direccion_completa": c_dir_str + } + + empresa = EmpresaSchema( + rfc=target_entity_data["rfc"], + razon_social=target_entity_data["razon_social"], + direccion_completa=target_entity_data["direccion_completa"] + ) + + + + # 3. Datos Aviso (Invoice/Compliance/Logistics/Financials) + financials = header.financials + logistics = header.logistics + + # Peso Bruto + peso_bruto_val = "0.0" + if financials and financials.gross_weight: + peso_bruto_val = f"{financials.gross_weight:,.2f}" + elif pedimento and pedimento.gross_weight: + peso_bruto_val = f"{pedimento.gross_weight:,.2f}" + + # Candados (Seals) + candados_list = [] + if logistics and logistics.seal_number: + # Split by comma or space if multiple + candados_list = [s.strip() for s in logistics.seal_number.replace(',', ' ').split() if s.strip()] + + # Vehiculo + placas_val = "" + tipo_veh_val = "" + if logistics: + placas_val = logistics.license_plate or logistics.vehicle_num or logistics.trailer_num or "" + tipo_veh_val = logistics.transport_type or "" + + aviso = AvisoSchema( + pedimento_completo=pedimento_txt, + tipo_operacion=header.operation_type.upper() if header.operation_type else "EXP", + clave_pedimento=clave_ped, + acus_valor=compliance.edocument if (compliance and compliance.edocument) else "", + aduana_seccion=compliance.aduana if (compliance and compliance.aduana) else "", + numero_remesa=str(compliance.remesa) if (compliance and compliance.remesa) else "", + peso_bruto=peso_bruto_val, + codigo_aceptacion="", # TODO: Clarify source. Using empty for now or Edocument? + codigo_barras_b64=None, + clave_seccion=compliance.aduana if (compliance and compliance.aduana) else "", # Using Aduana as Section Key + marcas_numeros_bultos=f"{financials.bundle_count} BULTOS" if (financials and financials.bundle_count) else "1 BULTOS", + candados=candados_list, + vehiculo_placas=placas_val, + vehiculo_tipo=tipo_veh_val, + observaciones=header.observation_es or "", + numero_certificado=compliance.certificate_number if (compliance and compliance.certificate_number) else "", + tipo_documento=header.document_type or "FACTURA", # Default + firma_electronica=compliance.electronic_signature if (compliance and compliance.electronic_signature) else "" + ) + + # 4. Agente Aduanal + nombre_agente = "" + rfc_agente = "" + curp_agente = "" + + if compliance and compliance.customs_broker_id: + broker = db.query(CustomsBroker).filter(CustomsBroker.id == compliance.customs_broker_id).first() + if broker: + nombre_agente = broker.name or "" + rfc_agente = broker.tax_id or "" + curp_agente = broker.personal_id or "" + + agente = PersonaSchema( + nombre=nombre_agente, + rfc=rfc_agente, + curp=curp_agente + ) + + # 5. Mandatario (CustomsBrokerPersonnel) + mandatario = PersonaSchema(nombre="", rfc="", curp="") + + if broker: + # Try to find personnel associated with this broker + # Using direct query to ensure specific order if needed, typically just the first valid one + personnel = db.query(CustomsBrokerPersonnel).filter( + CustomsBrokerPersonnel.customs_broker_id == broker.id + ).first() + + if personnel: + # Construct name if main field is empty + full_name = personnel.name + if not full_name: + parts = [] + if personnel.first_name: parts.append(personnel.first_name) + if personnel.last_name: parts.append(personnel.last_name) + if personnel.middle_name: parts.append(personnel.middle_name) + full_name = " ".join(parts) + + mandatario = PersonaSchema( + nombre=full_name or "", + rfc=personnel.tax_id or "", + curp=personnel.personal_id or "" + ) + + return AvisoConsolidadoContext( + aviso=aviso, + empresa=empresa, + agente=agente, + mandatario=mandatario + ) + + except Exception as e: + print(f"Error Service A76 Export Aviso Consolidado: {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"AvisoConsolidado_Exp_{invoice_id}.pdf" + + if progress_callback: progress_callback(90, "Generando PDF final...") + + options = { + 'page-size': 'Letter', + 'margin-top': '0.5in', + 'margin-right': '0.5in', + 'margin-bottom': '0.5in', + 'margin-left': '0.5in', + 'encoding': "UTF-8", + 'enable-local-file-access': None + } + + 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/aviso_consolidado/task.py b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/task.py new file mode 100644 index 00000000..651919d4 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/task.py @@ -0,0 +1,50 @@ + +import base64 +import logging +from core.celery_app import celery_app +from core.database import CoreSessionLocal + +from .service import AvisoConsolidadoExportacionService + +logger = logging.getLogger(__name__) + +@celery_app.task(name="generar_pdf_aviso_consolidado_exp_async", bind=True) +def generar_pdf_aviso_consolidado_exp_async(self, invoice_id: int, company_id: int): + # 1. Abrimos conexión a la DB + db = CoreSessionLocal() + try: + logger.info(f"Worker procesando Aviso Consolidado Exp {invoice_id}...") + + # 2. Instanciamos el servicio + service = AvisoConsolidadoExportacionService() + + # Update state to PROCESSING + self.update_state(state='PROCESSING', meta={'current': 5, 'total': 100, 'status': 'Iniciando generación...'}) + + def progress_callback(progress: int, status: str): + self.update_state(state='PROCESSING', meta={'current': progress, 'total': 100, 'status': status}) + + # 3. Generamos los bytes del PDF + pdf_bytes, nombre, media_type = service.generar_pdf( + db=db, + invoice_id=invoice_id, + company_id=company_id, + progress_callback=progress_callback + ) + + # 4. Codificamos a base64 + pdf_base64 = base64.b64encode(pdf_bytes).decode('utf-8') + + return { + "status": "success", + "file_name": nombre, + "content": pdf_base64, + "media_type": media_type + } + + except Exception as e: + logger.error(f"Error en Celery Worker Aviso Consolidado Exp: {str(e)}") + return {"status": "error", "message": str(e)} + + finally: + db.close() diff --git a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/templates/avcon_exp.html b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/templates/avcon_exp.html new file mode 100644 index 00000000..64a41575 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/templates/avcon_exp.html @@ -0,0 +1,357 @@ + + + + + + Aviso Consolidado - {{ aviso.pedimento_completo }} + + + + + + + + + +
+

AVISO CONSOLIDADO

+
+

Página 1 de 1

+
+ + + + + + + + +
+ NUM. PEDIMENTO: + {{ aviso.pedimento_completo }} + + T. OPER: + {{ aviso.tipo_operacion }} + + CVE. PEDIMENTO: + {{ aviso.clave_pedimento }} + + CERTIFICACIONES +
+ TIPO: {{ aviso.tipo_documento }} +
+ + + + + + + + + + + + + + + + + +
+ NUMERO DE ACUSE DE VALOR: + {{ aviso.acus_valor }} + +

 

+
+ ADUANA E/S: + {{ aviso.aduana_seccion }} + + NUM. REMESA: + {{ aviso.numero_remesa }} + + PESO BRUTO: + {{ aviso.peso_bruto }} +
DATOS DEL IMPORTADOR/EXPORTADOR
+
+

RFC:

+

{{ empresa.rfc }}

+
+
+

NOMBRE, DENOMINACION O RAZON SOCIAL:

+

{{ empresa.razon_social }}

+

{{ empresa.direccion_completa }}

+
+
+ + + + + + + +
+

CODIGO DE ACEPTACION:

+

{{ aviso.codigo_aceptacion }}

+
+

CODIGO DE BARRAS

+
+ {% if aviso.codigo_barras_b64 %} + + {% else %} +


+ {% endif %} +
+
+

CLAVE DE LA SECCION ADUANERA DE DESPACHO:

+

{{ aviso.clave_seccion }}

+
+ + + + + + + + +
MARCAS, NUMEROS Y TOTAL DE BULTOS: +
+

{{ aviso.marcas_numeros_bultos }}

+
+ + + + + + + + + + +
NUMERO DE CANDADO: + {{ aviso.candados[0] if aviso.candados|length > 0 }}{{ aviso.candados[1] if aviso.candados|length > 1 }}{{ aviso.candados[2] if aviso.candados|length > 2 }}{{ aviso.candados[3] if aviso.candados|length > 3 }}{{ aviso.candados[4] if aviso.candados|length > 4 }}
+ + + + + + + + + + + + + + +
1RA. REVISION +
2DA. REVISION +
+ + + + + + + + +
NUMERO/TIPO:{{ aviso.vehiculo_placas }}{{ aviso.vehiculo_tipo }}
+ + + + + + + + +
OBSERVACIONES
+

{{ aviso.observaciones }}

+
+ + + + + +
+

AGENTE ADUANAL, APODERADO ADUANAL:

+ +
+ NOMBRE: + {{ agente.nombre }} +
+ +
+
+ RFC: + {{ agente.rfc }} +
+
+ CURP: + {{ agente.curp }} +
+
+ +
+ MANDATARIO/PERSONA AUTORIZADA: +
+ +
+ NOMBRE: + {{ mandatario.nombre }} +
+ +
+
+ RFC: + {{ mandatario.rfc }} +
+
+ CURP: + {{ mandatario.curp }} +
+
+ +
+ NUMERO DE SERIE DEL CERTIFICADO: + {{ aviso.numero_certificado }} +
+
+ e.firma: +

{{ aviso.firma_electronica }}

+
+
+ +

*********************************************************************** FIN DE LA + IMPRESION ***********************************************************************

+ + + + \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py index 200a3942..dee88bdd 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py @@ -316,7 +316,7 @@ class FacturaImportacionMexService: cantidad_importacion=self.formatear_numero(qty.quantity if qty else 0), unidad_medida=qty.weight_unit if qty else "PZA", cantidad_bultos=int(qty.package_quantity) if qty and qty.package_quantity else 0, - clave_bultos=(qty.package_key or "") if qty else "", + clave_bultos=(qty.package_info.key if (qty and qty.package_info and qty.package_info.key) else "PZA"), peso_neto=self.formatear_numero(qty.net_weight if qty else 0), peso_bruto=self.formatear_numero(qty.gross_weight if qty else 0), valor_costo_unitario=self.formatear_numero(v_unitario), diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 6da0ba71..01c8c57d 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -52,6 +52,7 @@ from api.v1.modules.public.reference_data.material_types.routes import router as from .reports.importacion.facturas.routes import router as invoices_reports_router 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 @@ -138,4 +139,10 @@ router.include_router( packing_list_router, prefix="/a76/reports/importacion/packing-lists", tags=["a76 / reports"] +) + +router.include_router( + aviso_consolidado_export_router, + prefix="/a76/reports/exportacion/aviso_consolidado", + tags=["a76 / reports"] ) \ No newline at end of file diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index bc8bd492..3e41aa5f 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -11,7 +11,8 @@ celery_app = Celery( include=[ "api.v1.modules.a76.reports.importacion.facturas.task", "api.v1.modules.a76.reports.importacion.consolidados.task", - "api.v1.modules.a76.reports.importacion.packing_list.task" + "api.v1.modules.a76.reports.importacion.packing_list.task", + "api.v1.modules.a76.reports.exportacion.aviso_consolidado.task" ] # Ruta al módulo donde están las tareas ) diff --git a/backend/core/error_handlers.py b/backend/core/error_handlers.py index 4fe62025..e1f7783a 100644 --- a/backend/core/error_handlers.py +++ b/backend/core/error_handlers.py @@ -7,6 +7,7 @@ from typing import Any, Dict from fastapi import Request, status from fastapi.responses import JSONResponse +from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError from sqlalchemy.exc import IntegrityError, SQLAlchemyError @@ -37,7 +38,7 @@ async def base_exception_handler( return JSONResponse( status_code=exc.status_code, - content=exc.to_dict(), + content=jsonable_encoder(exc.to_dict()), ) diff --git a/frontend/src/lib/api/dashboard/a76/reports/reports-aviso-consolidado.ts b/frontend/src/lib/api/dashboard/a76/reports/reports-aviso-consolidado.ts new file mode 100644 index 00000000..363b1daf --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/reports/reports-aviso-consolidado.ts @@ -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(); + } +}; diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index e4e04619..146cd50b 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -372,6 +372,8 @@ } } + import { avisoConsolidadoReportsApi } from '$lib/api/dashboard/a76/reports/reports-aviso-consolidado'; + async function handleDownloadConsolidated(invoice: any) { if (!companyStore.activeCompany) { toast.error("No hay empresa seleccionada"); @@ -379,7 +381,7 @@ } try { - // 1. Trigger: Iniciar la tarea en Celery (Consolidado) + // 1. Trigger: Iniciar la tarea en Celery (Consolidado Importación) const { task_id } = await consolidatedReportsApi.triggerPdfGeneration( invoice.id, companyStore.activeCompany.id @@ -394,7 +396,30 @@ console.error(error); toast.error("No se pudo iniciar la descarga del consolidado"); } + } + async function handleDownloadAvisoConsolidado(invoice: any) { + if (!companyStore.activeCompany) { + toast.error("No hay empresa seleccionada"); + return; + } + + try { + // 1. Trigger: Iniciar la tarea en Celery (Aviso Consolidado Exportación) + const { task_id } = await avisoConsolidadoReportsApi.triggerPdfGeneration( + invoice.id, + companyStore.activeCompany.id + ); + + // 2. Abrir diálogo de progreso + currentTaskId = task_id; + currentStatusFunction = avisoConsolidadoReportsApi.getTaskStatus; + showProgressDialog = true; + + } catch (error) { + console.error(error); + toast.error("No se pudo iniciar la descarga del Aviso Consolidado"); + } } async function handleDownloadPackingList(invoice: any) { @@ -655,10 +680,17 @@ Factura + + + +