Merge branch 'feature/packing_list' into development

This commit is contained in:
2026-02-03 17:36:41 -06:00
26 changed files with 2420 additions and 16 deletions

View File

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

View File

@@ -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
@@ -37,13 +39,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")
line: Mapped["LineItem"] = relationship(back_populates="quantity")
package_info: Mapped[Optional["Package"]] = relationship(Package)

View File

@@ -26,9 +26,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)")

View File

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

View File

@@ -0,0 +1,658 @@
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 io import BytesIO
import pdf417gen
# --- 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.pedmientos.models.pedimento_validation import PedimentoValidation
from api.v1.modules.a76.customs_brokers.models import CustomsBroker, CustomsBrokerPersonnel
from api.v1.modules.a76.items.models import Item
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:
with open("/tmp/barcode_debug.log", "a") as f: f.write(f"ENTER obtener_datos ID={invoice_id}\n")
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. Importer/Exporter Data (Clarion 100% Match)
# Logic:
# IF EqiFex:EsCambioRegimen = 'S' THEN
# CliPro:Cliente = EqiFex:VendidoA
# ELSE
# CliPro:Cliente = EqiFex:Proveedor
# END
target_entity_data = {
"rfc": "",
"razon_social": "",
"direccion_completa": "DOMICILIO NO REGISTRADO"
}
target_client_id = None
if compliance:
if compliance.is_regime_change:
target_client_id = compliance.sold_to_id
else:
target_client_id = compliance.provider_id
if target_client_id:
client_obj = db.query(ClientProvider).filter(ClientProvider.id == target_client_id).first()
if client_obj:
# Fetch Address
c_addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == target_client_id).first()
# Fetch Fiscal Data (RFC)
c_prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == target_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.interior_number: parts_c.append(f"Int. {c_addr.interior_number}")
if c_addr.neighborhood: parts_c.append(f"Col. {c_addr.neighborhood}")
if c_addr.postal_code: parts_c.append(f"CP {c_addr.postal_code}")
if c_addr.city: parts_c.append(c_addr.city)
if c_addr.state: parts_c.append(c_addr.state)
if c_addr.country: parts_c.append(c_addr.country)
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"]
)
# Destino/Origen (Clarion: Loc:DestinoOrigen = 'Destino/Origen: '&EqiFex:DestinoOrigenCOVE)
destino_origen_str = ""
if compliance and compliance.origin_destination_cove:
# Assuming enum value or string is what we want.
# If it's an Enum object, accessing .value is safer.
val = compliance.origin_destination_cove
if hasattr(val, 'value'): val = val.value
destino_origen_str = f"Destino/Origen: {val}"
# 3. Datos Aviso (Invoice/Compliance/Logistics/Financials)
financials = header.financials
logistics = header.logistics
# Fetch Items associated with this invoice (MOVED UP FOR WEIGHT CALCULATION)
items = db.query(Item).filter(Item.invoice_id == invoice_id).all()
# Peso Bruto
peso_bruto_val = "0.0"
calculated_gross_weight = 0.0
# Calculate sum from items first
if items:
for item in items:
if item.lines:
for line in item.lines:
if line.quantity and line.quantity.gross_weight:
try:
calculated_gross_weight += float(line.quantity.gross_weight)
except (ValueError, TypeError):
pass
if financials and financials.gross_weight and float(financials.gross_weight) > 0:
peso_bruto_val = f"{financials.gross_weight:,.2f}"
elif calculated_gross_weight > 0:
peso_bruto_val = f"{calculated_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 / Contenedor Logic (Replicating Clarion)
# Clarion Logic:
# 1. Check for explicit `DatosVehiculo`.
# 2. Check for `EsFerrocarril`.
# 3. Build string from Trailer + Transport.
# Since we don't have a direct "DatosVehiculo" text field in Logistics (usually), we construct it.
# However, we'll check if `license_plate` is being used as a catch-all or if we should build it.
vehiculo_str = ""
tipo_display = ""
# Basic Logistics Data
l_trailer = logistics.trailer_num.strip() if (logistics and logistics.trailer_num) else ""
l_placa = logistics.license_plate.strip() if (logistics and logistics.license_plate) else ""
l_trans_type = logistics.transport_type.strip() if (logistics and logistics.transport_type) else ""
l_vehicle_num = logistics.vehicle_num.strip() if (logistics and logistics.vehicle_num) else ""
l_container_types = logistics.container_types.strip() if (logistics and logistics.container_types) else ""
# Check for Ferrocarril explicitly
is_rail = False
if "FERRO" in l_trans_type.upper() or "RAIL" in l_trans_type.upper():
is_rail = True
# --- LOGIC NUMERO / TIPO ---
final_numero = ""
final_tipo = ""
# 1. Container Logic (Clarion: ContenedoresTipo parsing)
# Format expected: "CONTENEDOR|TIPO,CONTENEDOR2|TIPO2..."
if l_container_types:
# Take first container
first_cont_group = l_container_types.split(',')[0] # Split by comma
if '|' in first_cont_group:
parts = first_cont_group.split('|')
final_numero = parts[0].strip()
final_tipo = parts[1].strip()
else:
# Fallback if no pipe
final_numero = first_cont_group.strip()
final_tipo = "CONT" # Default?
# 2. Transport = Container Logic
elif l_trans_type.upper() == "CONTENEDOR":
# Use trailer num as container num
if l_trailer:
final_numero = l_trailer
# Try to find Type? In Clarion it does a DB lookup into GTrailers.ClaveContenedor
# We assume 'CONTENEDOR' or a default if not found in simplified logic
final_tipo = "CONT"
# 3. Trailer/General Logic (Fallback)
if not final_numero:
# Construct valid string
parts_veh = []
if l_trailer:
parts_veh.append(f"TRAILER: {l_trailer}")
if not tipo_display: tipo_display = "TRAILER"
if l_trans_type and l_trans_type.upper() != "NINGUNO":
if is_rail:
if l_vehicle_num:
parts_veh.append(f"CONTENEDOR: {l_vehicle_num}")
tipo_display = "FERROCARRIL"
else:
segment = l_trans_type
if l_vehicle_num: segment += f": {l_vehicle_num}"
parts_veh.append(segment)
if not tipo_display: tipo_display = l_trans_type
if not parts_veh and l_placa:
parts_veh.append(f"PLACAS: {l_placa}")
final_numero = ", ".join(parts_veh).upper()
final_tipo = tipo_display.upper()
# Codigo de Aceptacion aka Acuse de Validacion
codigo_aceptacion_val = ""
if pedimento and pedimento.pedimento_validation:
# Assuming relationship "pedimento_validation" exists on Pedimentos model (lazy loaded)
# Or we can query it if relationship is scalar 'uselist=False'
if pedimento.pedimento_validation.validation_ack:
codigo_aceptacion_val = pedimento.pedimento_validation.validation_ack
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.upper() 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=codigo_aceptacion_val,
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=final_numero,
vehiculo_tipo=final_tipo,
observaciones=(header.observation_es or "") + ("\n" + destino_origen_str if destino_origen_str else ""),
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 ""
)
# --- BARCODE GENERATION (PDF417) ---
# Replicating Clarion "LLENADOCODIGODEBARRAS" logic
try:
# Debug Log
debug_log = []
debug_log.append(f"Processing Invoice {invoice_id}")
# 1. Patente (4 Digits) - From Pedimento or Compliance
patente_txt = pedimento.license if pedimento and pedimento.license else ""
if not patente_txt and pedimento_txt:
# Fallback parsing "YY OFF LIC NUMBER" -> LIC is index 2 (0, 1, 2)
try:
parts = pedimento_txt.split()
if len(parts) >= 3: patente_txt = parts[2]
except: pass
# 2. Pedimento Number (7 Digits)
pedimento_num = pedimento.pedimento_number if pedimento and pedimento.pedimento_number else ""
if not pedimento_num and pedimento_txt:
try:
parts = pedimento_txt.split()
if len(parts) >= 4: pedimento_num = parts[3]
except: pass
# 3. Recinto (3 chars) - Default to 000 if invalid/missing as per Clarion 'ELSE LINEPRINT('000'...'
# Clarion: Loc:Recinto = EqiFex:Recinto
recinto_txt = "000"
if compliance and compliance.enclosure:
recinto_txt = compliance.enclosure[:3]
if not recinto_txt: recinto_txt = "000"
# 4. E-Document
edoc_txt = aviso.acus_valor # Already uppercased
# 5. Num Contenedor (Rail) or 000...
# Clarion: IF Loc:EsFerrocarril = 'SI' ... LINEPRINT(CLIP(Loc:NumContenedor)) ELSE LINEPRINT('0000000000000')
# We reused logic for 'vehiculo_placas' and 'vehiculo_tipo' earlier.
# Let's re-evaluate "EsFerrocarril" logic safely
is_rail_bar = False
if "FERRO" in final_tipo.upper() or "RAIL" in final_tipo.upper():
is_rail_bar = True
field_5 = "0000000000000"
if is_rail_bar:
# We extracted container into 'parts_veh' earlier but let's grab from raw if possible or from aviso?
# In our logic above: "CONTENEDOR: {l_vehicle_num}" was added to textual description.
# Let's use compliance.container_ids or logistics.vehicle_num
c_num = logistics.vehicle_num if logistics and logistics.vehicle_num else ""
if c_num: field_5 = c_num
# 6. Firma Electronica
firma_txt = aviso.firma_electronica
# 7. Cantidad Comercial (Format @n015.3 -> 15 chars total, 3 decimals?)
# We need to sum quantities.
cant_total = 0.0
if items:
for item in items:
if item.lines:
for line in item.lines:
# Priority: Quantity (UMA or Standard)
q = 0.0
if line.quantity:
try:
if line.quantity.quantity_uma is not None:
q = float(line.quantity.quantity_uma)
elif line.quantity.quantity is not None:
q = float(line.quantity.quantity)
except (ValueError, TypeError):
q = 0.0
cant_total += q
# Format: 15 chars, 3 decimals? Actually Clarion LINEPRINT usually just prints the text.
# Clarion 'CLIP(FORMAT(Loc:CantTotal,@n015.3))' removes spaces.
cant_total_str = f"{cant_total:.3f}"
# 8. Valor Total Dlls
# Clarion: LINEPRINT(FORMAT(Loc:ValorTotalDlls,@n012)) -> Integer? Or just standard?
# Clarion @n012 usually means right justified or just specific length?
# Code says: Loc:ValorTotalDlls = GSQLFile3.SQL3:C1 + (rounding logic).
val_usd = 0.0
if financials:
try:
# Use value_me (Foreign Currency) as primary source for USD amount
if financials.value_me is not None:
val_usd = float(financials.value_me)
elif financials.value_mn is not None:
# Fallback to MN if ME is missing (though technically incorrect for USD field, avoids crash)
val_usd = float(financials.value_mn)
except (ValueError, TypeError):
val_usd = 0.0
# Clarion logic:
# IF GSQLFile3.SQL3:C1 > 0 AND GSQLFile3.SQL3:C1 < 1 THEN
# Loc:ValorTotalDlls = GSQLFile3.SQL3:C1 + (1 - GSQLFile3.SQL3:C1) (Result is 1.0)
# ELSE ... ROUND(...,1) or Raw.
final_val_usd = val_usd
if 0.0 < val_usd < 1.0:
final_val_usd = 1.0
elif (val_usd - int(val_usd)) > 0 and (val_usd - int(val_usd)) < 0.5:
# Clarion: IF Loc:Decimal > 0 AND Loc:Decimal < 0.5 THEN Loc:ValorTotalDlls = ROUND(GSQLFile3.SQL3:C1,1)
# Round to 1 decimal place? Or standard round? Python round matches generally.
final_val_usd = round(val_usd, 1)
val_usd_str = f"{final_val_usd:.2f}"
# 9. Cant Embarques (Rail)
field_9 = "000000000000"
if is_rail_bar:
# Logic for Cant Embarques?
# Clarion: EqiFex:CantGuiasEmbarque
# usage unknown in current DB. Defaulting to 0.
pass
# 10. NIU / DTA (Rail)
field_10 = "0000000000000"
if is_rail_bar:
# Clarion: EqiFex:NumeroNIU
if compliance and compliance.niu:
field_10 = compliance.niu
# 11. Remesa (4 chars)
remesa_txt = str(compliance.remesa) if (compliance and compliance.remesa) else "0"
# 12. Filler
field_12 = "00000000.000"
# Construct Line Prints (Text content for barcode)
# Clarion LINEPRINT separates by NewLine? Or is it one long string?
# "Glo:GeneraTXT" is a file. LINEPRINT appends a line.
# So the Barcode Content is a multi-line string or specific format.
# PDF417 normally encodes the full text block.
# 1. Patente (4 Digits)
# Formatted: @P####P -> 4 digits.
# Assuming simple string slice or pad.
patente_formatted = f"{patente_txt}".strip()[:4]
# 2. Pedimento (7 Digits)
pedimento_formatted = f"{pedimento_num}".strip()[:7]
# 3. Recinto (3 Digits Zero Padded @n03)
# Ensure it's numeric-like for zero padding or just string pad?
# Clarion FORMAT(Loc:Recinto,@n03) implies numeric.
try:
recinto_val = int(recinto_txt)
recinto_formatted = f"{recinto_val:03d}"
except:
recinto_formatted = "000"
# 4. E-Document (Left aligned, clipped)
edoc_formatted = edoc_txt.strip()
# 5. Container (13 chars?) or Rail Logic
# Clarion: IF Rail -> CLIP(Loc:NumContenedor) ELSE '0000000000000'
if is_rail_bar and field_5 and len(field_5) > 0:
field_5_formatted = field_5.strip()
else:
field_5_formatted = "0000000000000"
# 6. Firma (Clipped)
firma_formatted = firma_txt.strip()
# 7. Cantidad (FORMAT(Loc:CantTotal,@n015.3)) -> 15 chars, 3 decimals, Zero Padded?
# Python f"{val:015.3f}" produces 15 chars total (including dot) with zero padding.
cant_total_formatted = f"{cant_total:015.3f}"
# 8. Valor USD (FORMAT(Loc:ValorTotalDlls,@n012)) -> 12 chars, Integer?, Zero Padded?
# If Clarion @n012 means Integer:
# But previously we calculated rounding. If it is integer, we cast to int.
# Clarion default doubles formatted with @n012 usually rounds to integer.
# Let's assume Integer Zero Padded for now based on @n012 (no decimal part).
val_usd_formatted = f"{int(final_val_usd):012d}"
# 9. Cant Embarques (Rail) (FORMAT(...,@n012))
field_9_formatted = "000000000000"
if is_rail_bar:
# If we had a value... assuming 0 generally.
pass
# 10. NIU (Rail) (@s13 -> String 13 chars?) or DTA
# Clarion: LINEPRINT(CLIP(FORMAT(Loc:NumeroNIU,@s13)),Glo:GeneraTXT)
# CLIP removes spaces, FORMAT @s13 makes it string 13?
# Actually CLIP(FORMAT(...,@s13)) might just mean "The string value".
# The ELSE is '0000000000000' (13 chars).
field_10_formatted = "0000000000000"
if is_rail_bar and field_10 != "0000000000000":
field_10_formatted = field_10.strip()
# 11. Remesa (FORMAT(...,@n04) -> 4 digits zero padded)
try:
remesa_val = int(remesa_txt)
remesa_formatted = f"{remesa_val:04d}"
except:
remesa_formatted = "0000"
# 12. Filler / Appendix 17
# Clarion Logic:
# IF TipoFactura = 'IMPOTEMP'/'EXPO'/'IMPODEF' ... IF Apendice17=1 -> '000000000003' ELSE '00000000.000'
# Default '00000000.000'
field_12_formatted = "00000000.000"
if compliance and compliance.appendix_17 == 1:
# Check Doc Type? Assuming broadly for now based on flag.
field_12_formatted = "000000000003"
barcode_lines = [
patente_formatted,
pedimento_formatted,
recinto_formatted,
edoc_formatted,
field_5_formatted,
firma_formatted,
cant_total_formatted,
val_usd_formatted,
field_9_formatted,
field_10_formatted,
remesa_formatted,
field_12_formatted
]
# Join with appropriate separator. Clarion LINEPRINT adds CR/LF (Windows).
barcode_content = "\r\n".join(barcode_lines)
# Generate Image
codes = pdf417gen.encode(barcode_content, columns=14)
image = pdf417gen.render_image(codes, scale=5, padding=5)
# Convert to B64
buffered = BytesIO()
image.save(buffered, format="PNG")
img_str = base64.b64encode(buffered.getvalue()).decode("utf-8")
aviso.codigo_barras_b64 = f"data:image/png;base64,{img_str}"
debug_log.append("SUCCESS: Barcode generated.")
except Exception as e:
import traceback
error_msg = f"Error generando codigo de barras InvID={invoice_id}: {str(e)}\n{traceback.format_exc()}"
print(error_msg)
debug_log.append(f"ERROR: {error_msg}")
aviso.codigo_barras_b64 = None
# Write Debug Log
try:
with open("/tmp/barcode_debug.log", "a") as f:
f.write("\n".join(debug_log) + "\n--------------------------------\n")
except Exception as e_log:
print(f"FAILED TO WRITE LOG: {e_log}")
# 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"

View File

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

View File

@@ -0,0 +1,357 @@
<!DOCTYPE html>
<html lang="es" xml:lang="es">
<head>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type" />
<title>Aviso Consolidado - {{ aviso.pedimento_completo }}</title>
<style type="text/css">
/* ESTILOS EXACTOS DE SCAPII (Copiados de tu archivo) */
* {
margin: 0;
padding: 0;
font-family: Tahoma, sans-serif;
color: black;
}
.titulo {
font-size: 14pt;
padding: 3pt 0 0 6pt;
font-weight: bold;
}
.grande {
font-size: 13pt;
padding-left: 5pt;
}
.medio-bold {
font-size: 9pt;
font-weight: bold;
padding: 3pt 0 0 3pt;
}
.normal {
font-size: 8pt;
}
.small-bold {
font-size: 8pt;
font-weight: bold;
}
.tiny-bold {
font-size: 7pt;
font-weight: bold;
}
.tiny {
font-size: 7pt;
}
.mini {
font-size: 5pt;
}
.center {
text-align: center;
}
.right {
text-align: right;
}
.left {
text-align: left;
}
.border {
border: 1pt solid black;
}
/* Ajusté a negro puro para que se vea como el formato oficial */
/* Utilidades de Padding/Margin del original */
.p-t-1 {
padding-top: 1pt;
}
.p-t-2 {
padding-top: 2pt;
}
.p-t-3 {
padding-top: 3pt;
}
.p-l-2 {
padding-left: 2pt;
}
.p-l-3 {
padding-left: 3pt;
}
.p-r-2 {
padding-right: 2pt;
}
.h-10 {
height: 10pt;
}
.h-14 {
height: 14pt;
}
/* Estilos específicos para el Grid del Aviso */
table {
width: 100%;
border-collapse: collapse;
}
.bg-grey {
background-color: #E4E4E4;
}
.section-header {
font-size: 8pt;
font-weight: bold;
background-color: #CCCCCC;
text-align: center;
border: 1pt solid black;
}
.input-box {
border-bottom: 1pt solid black;
min-height: 10pt;
}
.cell-pad {
padding: 2pt 4pt;
}
</style>
</head>
<body>
<table cellspacing="0">
<tr>
<td class="border bg-grey center" style="width: 80%;">
<p class="titulo center">AVISO CONSOLIDADO</p>
</td>
<td class="border center" style="width: 20%;">
<p class="normal">Página <span class="small-bold">1</span> de <span class="small-bold">1</span></p>
</td>
</tr>
</table>
<table cellspacing="0" style="margin-top: -1pt;">
<tr>
<td class="border cell-pad" style="width: 40%;">
<span class="tiny-bold">NUM. PEDIMENTO: </span>
<span class="tiny">{{ aviso.pedimento_completo }}</span>
</td>
<td class="border cell-pad" style="width: 20%;">
<span class="tiny-bold">T. OPER: </span>
<span class="tiny">{{ aviso.tipo_operacion }}</span>
</td>
<td class="border cell-pad" style="width: 20%;">
<span class="tiny-bold">CVE. PEDIMENTO: </span>
<span class="tiny">{{ aviso.clave_pedimento }}</span>
</td>
<td class="border bg-grey center" style="width: 20%;">
<span class="tiny-bold">CERTIFICACIONES</span>
<br>
<span class="tiny-bold" style="font-size: 6pt;">TIPO: {{ aviso.tipo_documento }}</span>
</td>
</tr>
</table>
<table cellspacing="0" style="margin-top: -1pt;">
<tr>
<td class="border cell-pad" colspan="3">
<span class="tiny-bold">NUMERO DE ACUSE DE VALOR: </span>
<span class="tiny">{{ aviso.acus_valor }}</span>
</td>
<td class="border" rowspan="4" style="vertical-align: top;">
<p class="mini">&nbsp;</p>
</td>
</tr>
<tr>
<td class="border cell-pad" style="width: 25%;">
<span class="tiny-bold">ADUANA E/S: </span>
<span class="tiny">{{ aviso.aduana_seccion }}</span>
</td>
<td class="border cell-pad" style="width: 25%;">
<span class="tiny-bold">NUM. REMESA: </span>
<span class="tiny">{{ aviso.numero_remesa }}</span>
</td>
<td class="border cell-pad" style="width: 30%;">
<span class="tiny-bold">PESO BRUTO: </span>
<span class="tiny">{{ aviso.peso_bruto }}</span>
</td>
</tr>
<tr>
<td colspan="3" class="section-header">DATOS DEL IMPORTADOR/EXPORTADOR</td>
</tr>
<tr style="height: 40pt;">
<td colspan="3" class="border cell-pad" style="vertical-align: top;">
<div style="float: left; width: 30%;">
<p class="tiny-bold">RFC:</p>
<p class="tiny">{{ empresa.rfc }}</p>
</div>
<div style="float: left; width: 70%;">
<p class="tiny-bold">NOMBRE, DENOMINACION O RAZON SOCIAL:</p>
<p class="tiny">{{ empresa.razon_social }}</p>
<p class="mini">{{ empresa.direccion_completa }}</p>
</div>
</td>
</tr>
</table>
<table cellspacing="0" style="margin-top: -1pt;">
<tr style="height: 60pt;">
<td class="border cell-pad" style="width: 25%; vertical-align: top;">
<p class="tiny-bold">CODIGO DE ACEPTACION:</p>
<p class="normal center p-t-3">{{ aviso.codigo_aceptacion }}</p>
</td>
<td class="border cell-pad" style="width: 55%; vertical-align: top;">
<p class="tiny-bold">CODIGO DE BARRAS</p>
<div class="center p-t-2">
{% if aviso.codigo_barras_b64 %}
<img src="{{ aviso.codigo_barras_b64 }}" style="height: 60pt; max-width: 90%;" />
{% else %}
<br><br><br>
{% endif %}
</div>
</td>
<td class="border cell-pad" style="width: 20%; vertical-align: top;">
<p class="tiny-bold">CLAVE DE LA SECCION ADUANERA DE DESPACHO:</p>
<p class="grande center p-t-3">{{ aviso.clave_seccion }}</p>
</td>
</tr>
</table>
<table cellspacing="0" style="margin-top: -1pt;">
<tr>
<td class="section-header" style="text-align: left; padding-left: 5pt;">MARCAS, NUMEROS Y TOTAL DE BULTOS:
</td>
</tr>
<tr>
<td class="border cell-pad" style="height: 20pt; vertical-align: top;">
<p class="tiny">{{ aviso.marcas_numeros_bultos }}</p>
</td>
</tr>
</table>
<table cellspacing="0" style="margin-top: -1pt;">
<tr>
<td class="section-header"
style="width: 25%; text-align: left; padding-left: 5pt; background-color: #E4E4E4;">NUMERO DE CANDADO:
</td>
<td class="border center tiny" style="width: 15%;">{{ aviso.candados[0] if aviso.candados|length > 0 }}</td>
<td class="border center tiny" style="width: 15%;">{{ aviso.candados[1] if aviso.candados|length > 1 }}</td>
<td class="border center tiny" style="width: 15%;">{{ aviso.candados[2] if aviso.candados|length > 2 }}</td>
<td class="border center tiny" style="width: 15%;">{{ aviso.candados[3] if aviso.candados|length > 3 }}</td>
<td class="border center tiny" style="width: 15%;">{{ aviso.candados[4] if aviso.candados|length > 4 }}</td>
</tr>
</table>
<table cellspacing="0" style="margin-top: -1pt;">
<tr>
<td class="section-header"
style="text-align: left; padding-left: 5pt; background-color: #E4E4E4; border-bottom: 0;">1RA. REVISION
</td>
</tr>
<tr>
<td class="border h-14"></td>
</tr>
<tr>
<td class="section-header"
style="text-align: left; padding-left: 5pt; background-color: #E4E4E4; border-bottom: 0;">2DA. REVISION
</td>
</tr>
<tr>
<td class="border h-14"></td>
</tr>
</table>
<table cellspacing="0" style="margin-top: -1pt;">
<tr>
<td class="section-header"
style="width: 25%; text-align: left; padding-left: 5pt; background-color: #CCCCCC;">NUMERO/TIPO:</td>
<td class="border cell-pad tiny" style="width: 25%;">{{ aviso.vehiculo_placas }}</td>
<td class="border cell-pad tiny" style="width: 25%;">{{ aviso.vehiculo_tipo }}</td>
<td class="border cell-pad tiny" style="width: 25%;"></td>
</tr>
</table>
<table cellspacing="0" style="margin-top: -1pt;">
<tr>
<td class="section-header" style="text-align: center; background-color: #CCCCCC;">OBSERVACIONES</td>
</tr>
<tr>
<td class="border" style="height: 100pt; vertical-align: top; padding: 5pt;">
<p class="tiny">{{ aviso.observaciones }}</p>
</td>
</tr>
</table>
<table cellspacing="0" style="margin-top: -1pt;">
<tr>
<td class="border cell-pad" style="vertical-align: top; height: 90pt;">
<p class="tiny-bold">AGENTE ADUANAL, APODERADO ADUANAL:</p>
<div style="margin-top: 5pt;">
<span class="tiny-bold">NOMBRE: </span>
<span class="tiny">{{ agente.nombre }}</span>
</div>
<div style="margin-top: 2pt;">
<div style="display: inline-block; width: 45%;">
<span class="tiny-bold">RFC: </span>
<span class="tiny">{{ agente.rfc }}</span>
</div>
<div style="display: inline-block; width: 50%;">
<span class="tiny-bold">CURP: </span>
<span class="tiny">{{ agente.curp }}</span>
</div>
</div>
<div style="margin-top: 10pt;">
<span class="tiny-bold">MANDATARIO/PERSONA AUTORIZADA:</span>
</div>
<div style="margin-top: 2pt;">
<span class="tiny-bold">NOMBRE: </span>
<span class="tiny">{{ mandatario.nombre }}</span>
</div>
<div style="margin-top: 2pt;">
<div style="display: inline-block; width: 45%;">
<span class="tiny-bold">RFC: </span>
<span class="tiny">{{ mandatario.rfc }}</span>
</div>
<div style="display: inline-block; width: 50%;">
<span class="tiny-bold">CURP: </span>
<span class="tiny">{{ mandatario.curp }}</span>
</div>
</div>
<div style="margin-top: 5pt; border-top: 1pt solid black; padding-top: 2pt;">
<span class="tiny-bold">NUMERO DE SERIE DEL CERTIFICADO: </span>
<span class="tiny">{{ aviso.numero_certificado }}</span>
</div>
<div style="margin-top: 2pt;">
<span class="tiny-bold">e.firma: </span>
<p class="mini" style="word-wrap: break-word;">{{ aviso.firma_electronica }}</p>
</div>
</td>
</tr>
</table>
<p class="mini center p-t-5">*********************************************************************** FIN DE LA
IMPRESION ***********************************************************************</p>
</body>
</html>

View File

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

View File

@@ -0,0 +1,93 @@
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
fraccion_americana: Optional[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]
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] = ""
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]
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] = ""
class PackingListSchema(BaseModel):
cliente_proveedor: ClienteSchema
cliente_vendido: ClienteSchema
cliente_enviado: ClienteSchema
factura: FacturaSchema
partidas: List[PartidaSchema]
totales: TotalesSchema
logo_b64: Optional[str] = None

View File

@@ -0,0 +1,401 @@
import shutil
import base64
import pdfkit
from pathlib import Path
from decimal import Decimal
from typing import Tuple, List, Callable, Optional
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_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
)
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 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="Importer / Consignee:",
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 = (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:
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 ""
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()
# --- 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
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,
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=uom_comercial, # Commercial UOM (PCS, EA)
cantidad_bultos=int(qty.package_quantity) if qty and qty.package_quantity else 0,
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),
peso_bruto_lbs=self.formatear_numero(peso_bruto_lb),
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)
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]
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),
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=""
)
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

View File

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

View File

@@ -0,0 +1,519 @@
<!DOCTYPE html>
<html lang="es" xml:lang="es">
<head>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type" />
<title>Packing List - {{ factura.numero }}</title>
<style type="text/css">
/* ESTILOS EXACTOS DE SCAPII PARA MANTENER EL FORMATO */
* {
margin: 0;
padding: 0;
font-family: Tahoma, sans-serif;
color: black;
}
.titulo {
font-size: 14pt;
padding: 3pt 0 0 6pt;
}
.grande {
font-size: 13pt;
padding-left: 5pt;
}
.medio-bold {
font-size: 9pt;
font-weight: bold;
padding: 3pt 0 0 3pt;
}
.normal {
font-size: 8pt;
}
.small-bold {
font-size: 8pt;
font-weight: bold;
}
.tiny-bold {
font-size: 7pt;
font-weight: bold;
}
.tiny {
font-size: 7pt;
}
.mini {
font-size: 5pt;
}
h1 {
font-weight: bold;
font-size: 8pt;
}
p {
font-size: 7pt;
}
.cliente {
border-bottom: 1pt solid black;
text-decoration: none;
display: block;
width: 100%
}
.center {
text-align: center;
}
.right {
text-align: right;
}
.border {
border: 1pt solid #808080;
}
.p-t-1 {
padding-top: 1pt;
}
.p-t-2 {
padding-top: 2pt;
}
.p-t-3 {
padding-top: 3pt;
}
.p-t-4 {
padding-top: 4pt;
}
.p-t-5 {
padding-top: 5pt;
}
.p-t-8 {
padding-top: 8pt;
}
.p-t-9 {
padding-top: 9pt;
}
.p-l-2 {
padding-left: 2pt;
}
.p-l-3 {
padding-left: 3pt;
}
.p-l-5 {
padding-left: 5pt;
}
.p-l-6 {
padding-left: 6pt;
}
.p-l-7 {
padding-left: 7pt;
}
.p-l-8 {
padding-left: 8pt;
}
.p-l-9 {
padding-left: 9pt;
}
.p-l-10 {
padding-left: 10pt;
}
.p-r-1 {
padding-right: 1pt;
}
.p-r-2 {
padding-right: 2pt;
}
.p-r-4 {
padding-right: 4pt;
}
.p-r-5 {
padding-right: 5pt;
}
.p-r-9 {
padding-right: 9pt;
}
.p-b-1 {
padding-bottom: 1pt;
}
.h-10 {
height: 10pt;
}
.h-11 {
height: 11pt;
}
.h-14 {
height: 14pt;
}
.h-15 {
height: 15pt;
}
.h-18 {
height: 18pt;
}
.h-22 {
height: 22pt;
}
.h-82 {
height: 82pt;
}
.h-384 {
height: 384pt;
}
.line-1 {
line-height: 1pt;
}
.line-7 {
line-height: 7pt;
}
.line-8 {
line-height: 8pt;
}
.line-9 {
line-height: 9pt;
}
.line-10 {
line-height: 10pt;
}
.m-l-3 {
margin-left: 3pt;
}
.m-l-5 {
margin-left: 5.74pt;
}
.flex-container {
width: 100%;
overflow: hidden;
}
.flex-container-end {
width: 100%;
overflow: hidden;
}
.width-48 {
width: 48%;
float: left;
}
.width-48-right {
width: 48%;
float: right;
text-align: right;
}
.position-relative-centered {
position: relative;
width: 48%;
float: left;
}
.full-width-block {
display: block;
width: 100%;
}
.clearfix::after {
content: "";
display: table;
clear: both;
}
table,
tbody {
vertical-align: top;
overflow: visible;
}
</style>
</head>
<body>
<header>
<div class="flex-container clearfix">
<div class="width-48" style="position: relative;">
<p class="titulo">PACKING LIST / LISTA DE EMPAQUE</p>
<div class="cliente" style="height: 1px;"></div>
<p class="cliente p-t-1 p-b-1"></p>
</div>
<div class="width-48-right">
<table cellspacing="0" class="m-l-3" style="float: right; text-align: left;">
<tbody>
<tr class="h-22">
<td bgcolor="#E4E4E4" class="border" style="width:100pt">
<p class="medio-bold p-l-3 line-9">PACKING LIST / LISTA DE EMPAQUE:</p>
</td>
<td class="border" colspan="3" style="width:128pt">
<p class="grande p-l-5">{{ factura.numero }}</p>
</td>
</tr>
<tr class="h-22">
<td bgcolor="#E4E4E4" class="border">
<p class="small-bold p-l-3 line-9">MX CUSTOM BROKER / AGENTE ADUANAL MEXICANO:</p>
</td>
<td class="border" colspan="3">
<p class="normal p-l-5">{{ factura.agente_aduanal or '' }}</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="flex-container-end clearfix" style="overflow: visible;">
<div class="position-relative-centered">
{% if logo_b64 %}
<div style="position: absolute; top: 10pt; left: 0;">
<img src="{{ logo_b64 }}" style="max-height: 70pt; max-width: 120pt;" />
</div>
{% endif %}
<div style="margin-left: 130pt; padding-top: 5pt; text-align: left;">
<h1 class="cliente">{{ cliente_proveedor.header }}</h1>
<p>{{ cliente_proveedor.nombre }}</p>
<p>{{ 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 %}
</p>
<p>{{ cliente_proveedor.colonia }} {% if cliente_proveedor.codigo_postal %} CP: {{
cliente_proveedor.codigo_postal }}{% endif %}</p>
<p>{{ cliente_proveedor.ciudad }}, {{ cliente_proveedor.estado }}, {{ cliente_proveedor.pais }}</p>
<p>TAX ID: {{ cliente_proveedor.tax_id }}
{% if cliente_proveedor.programa and cliente_proveedor.programa != 'Ninguno' %}
{{ cliente_proveedor.programa }}: {{ cliente_proveedor.autorizacion }}
{% endif %}
</p>
<p><br></p>
</div>
</div>
</div>
<div class="flex-container clearfix">
<div class="width-48">
<h1 class="p-t-5 p-l-5 line-10 cliente">{{ cliente_vendido.header }}</h1>
<p class="p-l-5 line-8">{{ cliente_vendido.nombre }}</p>
<p class="p-l-5">{{ 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 %}
</p>
<p class="p-l-5">{{ cliente_vendido.colonia }} {% if cliente_vendido.codigo_postal %} CP: {{
cliente_vendido.codigo_postal }}{% endif %}</p>
<p class="p-l-5">{{ cliente_vendido.ciudad }}, {{ cliente_vendido.estado }}, {{ cliente_vendido.pais }}
</p>
<p class="p-l-5">RFC: {{ cliente_vendido.tax_id }}
{% if cliente_vendido.programa and cliente_vendido.programa != 'Ninguno' %}
{{ cliente_vendido.programa }}: {{ cliente_vendido.autorizacion }}
{% endif %}
</p>
<p class="p-l-5">
{% 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 %}
</p>
</div>
<div class="width-48-right" style="text-align: left;">
<h1 class="p-t-5 p-l-5 line-10 cliente full-width-block">{{ cliente_enviado.header }}</h1>
<p class="p-l-5 line-8">{{ cliente_enviado.nombre }}</p>
<p class="p-l-5">{{ 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 %}
</p>
<p class="p-l-5">{{ cliente_enviado.colonia }} {% if cliente_enviado.codigo_postal %} CP: {{
cliente_enviado.codigo_postal }}{% endif %}</p>
<p class="p-l-5">{{ cliente_enviado.ciudad }}, {{ cliente_enviado.estado }}, {{ cliente_enviado.pais }}
</p>
<p class="p-l-5">RFC: {{ cliente_enviado.tax_id }}
{% if cliente_enviado.programa and cliente_enviado.programa != 'Ninguno' %}
{{ cliente_enviado.programa }}: {{ cliente_enviado.autorizacion }}
{% endif %}
</p>
<p class="p-l-5">
{% 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 %}
</p>
</div>
</div>
<p class="p-t-8"><br /></p>
</header>
<table cellspacing="0" class="m-l-5" style="width: 99%; max-width: 580pt;">
<thead>
<!-- TABLA DE TRANSPORTES ELIMINADA POR SOLICITUD DEL USUARIO -->
<tr class="h-10">
<td class="border" rowspan="2">
<p class="tiny-bold p-t-5 center">Line / Línea</p>
</td>
<td class="border" rowspan="2" colspan="2">
<p class="tiny-bold p-l-2 line-10">Part Number / Número de Parte</p>
<p class="tiny-bold p-l-2 line-10">Description / Descripción</p>
</td>
<td class="border" colspan="2">
<p class="tiny-bold center line-9">Quantity / Cantidad</p>
</td>
<td class="border" colspan="2" style="width:60pt">
<p class="tiny-bold center line-9">Packing / Empaque</p>
</td>
<td class="border" colspan="2">
<p class="tiny-bold center line-9">Weight / Peso (KGS)</p>
</td>
<!-- COLUMNA VALORES REMOVIDA -->
</tr>
<tr class="h-11">
<!-- Subheaders -->
<td class="border center">
<p class="mini p-l-2">Qty / Cant.</p>
</td>
<td class="border center">
<p class="mini p-l-1">U.M.</p>
</td>
<td class="border">
<p class="mini center">Qty / Cant.</p>
</td>
<td class="border">
<p class="mini center">Type / Tipo</p>
</td>
<td class="border">
<p class="mini center">Net / Neto</p>
<p class="mini center font-bold" style="font-size: 4pt;">(LBS / KGS)</p>
</td>
<td class="border">
<p class="mini center">Gross / Bruto</p>
<p class="mini center font-bold" style="font-size: 4pt;">(LBS / KGS)</p>
</td>
</tr>
</thead>
<tbody class="h-384" style="width: 100%;">
{% for partida in partidas %}
<tr>
<td class="border" style="width: 25pt;">
<p class="mini p-t-3 center">{{ loop.index }}</p>
</td>
<td class="border" colspan="2">
<p class="mini p-t-1" style="font-weight: bold;">{{ partida.numero_parte }}</p>
<p class="mini">{{ partida.descripcion }} / {{ partida.fraccion_americana }} / {{ partida.origen }}
</p>
</td>
<td class="border">
<p class="mini p-t-2 center">{{ partida.cantidad_importacion }}</p>
</td>
<td class="border">
<p class="mini p-t-2 center">{{ partida.unidad_medida }}</p>
</td>
<td class="border">
<p class="mini p-t-2 center">
{% if partida.cantidad_bultos != 0 %}{{ partida.cantidad_bultos }}{% endif %}
</p>
</td>
<td class="border">
<p class="mini p-t-2 center">
{{ partida.clave_bultos }}
</p>
</td>
<td class="border">
<p class="mini p-t-1 center">{{ partida.peso_neto_lbs }}</p>
<p class="mini center font-bold">{{ partida.peso_neto }}</p>
</td>
<td class="border">
<p class="mini p-t-1 center">{{ partida.peso_bruto_lbs }}</p>
<p class="mini center font-bold">{{ partida.peso_bruto }}</p>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="h-14">
<td bgcolor="#E4E4E4" class="border" colspan="3" style="width:123pt">
<p class="small-bold p-t-2 p-l-3 line-10">
<span>Observaciones:</span>
<span class="small-bold" style="float: right; margin-right: 2pt;">TOTALES</span>
</p>
</td>
<td class="border" style="width:40pt">
<p class="mini p-t-3 p-r-1 line-8 center">{{ totales.cantidad_total }}</p>
</td>
<td class="border" style="width:22pt"></td>
<td class="border" style="width:30pt">
<p class="mini p-t-3 center line-8">
{% if totales.bultos_total != 0 %}{{ totales.bultos_total }}{% endif %}
</p>
</td>
<td class="border" style="width:30pt">
<p class="mini p-t-3 center line-8">
<span>{{ totales.clave_bultos or '' }}</span>
</p>
</td>
<td class="border" style="width:40pt">
<p class="mini p-t-2 center line-8">{{ totales.peso_neto_total_lbs }} LBS</p>
<p class="mini center line-8 font-bold">{{ totales.peso_neto_total }} KGS</p>
</td>
<td class="border" style="width:40pt">
<p class="mini p-t-2 center line-8">{{ totales.peso_bruto_total_lbs }} LBS</p>
<p class="mini center line-8 font-bold">{{ totales.peso_bruto_total }} KGS</p>
</td>
</tr>
<tr>
<td colspan="3" class="border" style="height: 100pt; text-align:start; vertical-align: top;">
<p class="mini p-l-2 p-t-2">{{ factura.observaciones }}</p>
</td>
<td colspan="5" style="width:336pt; vertical-align: bottom; height: 100%;">
<p style="border-bottom: 1pt solid black; width: 80%; margin: 0 auto 2pt auto;"></p>
<p class="normal center" style="margin-bottom: 0;">{{ cliente_proveedor.nombre }}</p>
<p style="margin-bottom: 0;"><br /></p>
</td>
</tr>
<tr>
<td colspan="10" style="width:580pt; vertical-align: top; height: 100%;">
<p class="p-t-8"><br /></p>
</td>
</tr>
</tfoot>
</table>
</body>
</html>

View File

@@ -51,6 +51,9 @@ 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
from .reports.exportacion.aviso_consolidado.routes import router as aviso_consolidado_export_router
# Router principal
@@ -130,4 +133,16 @@ 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"]
)
router.include_router(
aviso_consolidado_export_router,
prefix="/a76/reports/exportacion/aviso_consolidado",
tags=["a76 / reports"]
)

View File

@@ -10,7 +10,9 @@ 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",
"api.v1.modules.a76.reports.exportacion.aviso_consolidado.task"
] # Ruta al módulo donde están las tareas
)

View File

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

View File

@@ -47,4 +47,7 @@ pdfkit==1.0.0
# Desarrollo en seguno plano
celery==5.3.6
redis==5.0.1
flower==2.0.1
flower==2.0.1
# Barcode
pdf417gen==0.8.1

View File

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

View File

@@ -1,4 +1,5 @@
const BASE_URL = import.meta.env.VITE_API_URL || '';
const BASE_URL = import.meta.env.VITE_API_URL || '';
export const invoicesReportsApi = {
@@ -11,8 +12,10 @@ export const invoicesReportsApi = {
});
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}`,
@@ -22,11 +25,14 @@ 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',
@@ -35,5 +41,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();
}
};

View File

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

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { ...restProps }: DropdownMenuPrimitive.RootProps = $props();
</script>
<DropdownMenuPrimitive.Root data-slot="dropdown-menu-root" {...restProps} />

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { ...restProps }: DropdownMenuPrimitive.SubProps = $props();
</script>
<DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...restProps} />

View File

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

View File

@@ -15,7 +15,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";
@@ -376,6 +376,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");
@@ -383,7 +385,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
@@ -400,6 +402,55 @@
}
}
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) {
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) {
// Esta función se llama cuando el diálogo reporta SUCCESS
try {
@@ -652,10 +703,21 @@
<FileText class="h-4 w-4 mr-2" />
Factura
</Button>
<Button variant="outline" size="sm" onclick={() => selectedInvoice && handleDownloadConsolidated(selectedInvoice)} disabled={!selectedInvoice}>
<Boxes class="h-4 w-4 mr-2" />
Consolidado
</Button>
<Button variant="outline" size="sm" onclick={() => selectedInvoice && handleDownloadAvisoConsolidado(selectedInvoice)} disabled={!selectedInvoice}>
<Boxes class="h-4 w-4 mr-2" />
Aviso Consolidado
</Button>
<Button variant="outline" size="sm" onclick={() => selectedInvoice && handleDownloadPackingList(selectedInvoice)} disabled={!selectedInvoice}>
<Package class="h-4 w-4 mr-2" />
Packing List
</Button>
</div>
</div>
</div>

7
frontend/test_bits.js Normal file
View File

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