Se ingreso factura americana y modal con botoncitos

This commit is contained in:
2026-01-23 17:59:08 -06:00
parent 5a254fb2b1
commit 9e602a2337
14 changed files with 2396 additions and 104 deletions

View File

@@ -0,0 +1,630 @@
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 sqlalchemy.orm import Session
# --- MODELOS ---
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics, InvoiceComplianceMx
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, FacturaImportacionCompleta
)
class ConsolidadoImportacionMexService:
def __init__(self):
self.template_dir = Path(__file__).parent.parent / "templates"
self.jinja_env = Environment(
loader=FileSystemLoader(self.template_dir),
autoescape=select_autoescape(['html', 'xml'])
)
self.template = self.jinja_env.get_template('cons_mex_ver.html')
def _get_wkhtmltopdf_config(self):
path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf"
if not Path(path).exists():
raise RuntimeError("wkhtmltopdf no encontrado.")
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
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 obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> FacturaImportacionCompleta:
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) - Used for fallback or Right Side (Enviado A)
# Default Header (Company)
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:
# Map known headers or default to Sold To / Vendido a
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:
# Map to Shipped To / Enviado a
clean_header_shipped = "Shipped To / Enviado a:"
# Fetch client data
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 ""
# Init values
placas_val = (logistics.license_plate or "") if logistics else "" # Placas Tracto
placas_remolque_val = ""
transportista_val = (logistics.carrier_id or "") if logistics else ""
caat_val = ""
scac_val = ""
licencia_cond_val = ""
conductor_nombre = ""
# Block Logic (Clarion Style) for transportista_info
transport_lines = []
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 "" # Mapping transport_code to SCAC
transportista_val = transporter_obj.name or logistics.carrier_id
# Clarion Logic: Name first
# Line 1: Name
transport_lines.append(transporter_obj.name or "")
# Line 2: Streets
if transporter_obj.streets:
transport_lines.append(transporter_obj.streets)
# Line 3: City, State, Country
loc_line = ""
if transporter_obj.city:
loc_line = transporter_obj.city
if transporter_obj.state:
loc_line += f", {transporter_obj.state}, "
else:
loc_line += ", "
else:
if transporter_obj.state:
loc_line = f"{transporter_obj.state},"
country_desc = transporter_obj.country or ""
if loc_line:
loc_line += f" {country_desc}"
elif country_desc:
loc_line = country_desc
if loc_line.strip(", "):
transport_lines.append(loc_line)
# 2. Vehicle (Placas Tracto) - Try transport_id first
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: # Fallback to vehicle_num if populated and transport_id failed/empty
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:
conductor_nombre = logistics.driver_name
# Attempt to find driver by name + carrier
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 ""
# --- Building the rest of the block ---
# Line 4: Driver
if conductor_nombre:
transport_lines.append(f"Driver/Conductor: {conductor_nombre}")
# Line 5: Conveyance / Transporte
t_label = "Conveyance / Transporte"
t_val = placas_val # Default to Truck Plate
if logistics.transport_type:
ttype = str(logistics.transport_type).lower()
if "caja" in ttype or "trailer" in ttype:
t_label = "Trailer / Caja"
t_val = placas_remolque_val or num_transporte_val
elif "placa" in ttype:
t_label = "Plates / Placas"
elif "camion" in ttype or "truck" in ttype:
t_label = "Truck / Camión"
if t_val:
transport_lines.append(f"{t_label}: {t_val}")
# Line 6: SCAC / CAAT
codes_line = ""
if scac_val:
codes_line = f"SCAC Code/Clave: {scac_val}"
if caat_val:
if codes_line:
codes_line += f", CAAT Code/Clave: {caat_val}"
else:
codes_line = f"CAAT Code/Clave: {caat_val}"
if codes_line:
transport_lines.append(codes_line)
# Join with newlines
transport_block_str = "\n".join([l for l in transport_lines if l])
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,
representante_legal=getattr(company, 'responsible', "") or "",
nombre_empresa=getattr(company, 'name', "") or "",
transportista_info=transport_block_str
)
if progress_callback: progress_callback(50, "Procesando partidas...")
# --- Fetch Lines from SINGLE Invoice (Requested Scope Change) ---
# User requested to ONLY report items from the specific selected invoice,
# NOT consolidating all invoices from the same Pedimento.
target_invoice_ids = [header.id]
lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter(
Item.invoice_id.in_(target_invoice_ids)
).all()
partidas_list = []
# --- AGGREGATION LOGIC (Refactoring based on Clarion) ---
from collections import defaultdict
# Key: (us_fraction_code, origin_country)
# Value: Object with accumulated fields
aggregated_data = defaultdict(lambda: {
"qty": 0.0,
"net_weight_kgs": 0.0,
"gross_weight_kgs": 0.0,
"total_value": 0.0,
"est_total_value": 0.0,
"description": "",
"advalorem_txt": "0%",
"unit_measure": "PZA", # Placeholder, takes first one found
"hts_code_print": "",
"part_number_display": "CONSOLIDADO"
})
# Pre-fetch US Tariff Fractions for efficiency if possible, or query inside loop (caching recommended)
# For simplicity in this step, we query inside or rely on Part data.
# Ideally fetch USTariffFraction from DB based on Part.us_fraction
# --- Optimización: Cargar Facturas en Memoria ---
invoices_list = db.query(InvoiceHeader).filter(InvoiceHeader.id.in_(target_invoice_ids)).all()
invoice_map = {inv.id: inv for inv in invoices_list}
from api.v1.modules.a76.general_catalogs.us_tariff_fractions.models import USTariffFraction
for line in lines:
qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first()
fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == line.id).first()
part_master = db.query(Part).filter(Part.id == line.part_number).first()
# --- Resolver Identificadores (MOVED INSIDE MAIN LOOP) ---
us_fraction_raw = ""
origin_final = "MEX"
if part_master:
origin_final = part_master.fa_data.origin_country if (part_master.fa_data and part_master.fa_data.origin_country) else "MEX"
us_fraction_raw = part_master.us_fraction if part_master.us_fraction else ""
# Key for aggregation
us_frac_clean = us_fraction_raw.strip()
agg_key = (us_frac_clean, origin_final)
# --- Weights & Qty ---
q_line = float(qty.quantity) if (qty and qty.quantity) else 0.0
nw_line = float(qty.net_weight) if qty else 0.0
gw_line = float(qty.gross_weight) if qty else 0.0
# --- Multi-Currency Normalization Logic ---
# Determine Line Currency context
# Use manual lookup instead of specific attribute
invoice_id = line.item.invoice_id if line.item else None
line_invoice = invoice_map.get(invoice_id) if invoice_id else None
line_currency_is_mxn = False
line_exchange_rate = 1.0
if line_invoice and line_invoice.financials:
# Check explicit currency string AND code
curr_desc = str(line_invoice.financials.currency or "").upper()
curr_code = str(line_invoice.financials.currency_type or "").upper()
# Logic: It is MXN if description says PESO/MX or code is MXN/MN
is_mx_desc = ("MX" in curr_desc or "PESO" in curr_desc)
is_mx_code = ("MXN" in curr_code or "MN" == curr_code)
# But if code allows clarifying USD, prioritize that
is_usd_code = ("USD" in curr_code)
if is_usd_code:
line_currency_is_mxn = False
elif is_mx_code or is_mx_desc:
line_currency_is_mxn = True
else:
line_currency_is_mxn = False # Default to Foreign/USD if unsure
line_exchange_rate = float(line_invoice.financials.exchange_rate or 1.0)
# Target Report Currency
report_is_mxn = (factura_schema.moneda == 'MXN')
# DEBUG LOGGING
if line_invoice:
print(f"DEBUG: Line {line.id} - Inv {line_invoice.id} - CurrDesc: '{curr_desc}' Code: '{curr_code}' - Rate: {line_exchange_rate}")
print(f"DEBUG: Is MXN Context? {line_currency_is_mxn}. Report is MXN? {report_is_mxn}")
# --- Get Financials for Line (Raw) ---
v_total_raw = 0.0
v_unitario_raw = 0.0
if fin:
# NEW PRIORITY LOGIC (To avoid Inflation from dirty Customs Unit Cost)
# Priority 1: Use 'fin.value_usd' if it exists and > 0.
# Priority 2: Use 'fin.total_commercial_value' if it exists and > 0.
# Priority 3: Calculate using 'fin.unit_cost_commercial_usd' * 'q_line'.
# Priority 4: Only use 'fin.unit_cost_usd' * 'q_line' if commercial data is also missing.
val_usd = float(fin.value_usd or 0.0)
total_comm = float(fin.total_commercial_value or 0.0)
unit_comm_usd = float(fin.unit_cost_commercial_usd or 0.0)
unit_usd = float(fin.unit_cost_usd or 0.0)
# 1. Direct Total: Custom Value (Best case)
if val_usd > 0:
v_total_raw = val_usd
# 2. Direct Total: Commercial Total
elif total_comm > 0:
# Convert if invoice currency is MXN
if line_currency_is_mxn and line_exchange_rate > 0:
v_total_raw = total_comm / line_exchange_rate
else:
v_total_raw = total_comm
# 3. Calc from Commercial Unit Cost (Safe Fallback)
elif unit_comm_usd > 0 and q_line > 0:
v_total_raw = unit_comm_usd * q_line
# 4. Calc from Customs Unit Cost (Unknown Risk - Last Resort)
elif unit_usd > 0 and q_line > 0:
v_total_raw = unit_usd * q_line
else:
v_total_raw = 0.0
# NOTE: v_unitario_raw is left as 0.0 here.
# It will be calculated in the 'Calculation Gap Fill' block below:
# v_unitario_raw = v_total_raw / q_line
# This guarantees consistency and avoids the inflated unit cost record (198.00).
# --- Calculation Gap Fill (Raw) ---
if q_line > 0:
if v_total_raw == 0 and v_unitario_raw > 0:
v_total_raw = v_unitario_raw * q_line
if v_unitario_raw == 0 and v_total_raw > 0:
v_unitario_raw = v_total_raw / q_line
# --- Conversion to Report Currency (DISABLED TEMPORARILY) ---
# User confirms all are USD. Forcing direct sum to avoid logic errors in detection.
v_total_line = v_total_raw
v_unitario_line = v_unitario_raw
# if report_is_mxn and not line_currency_is_mxn:
# # USD -> MXN
# v_total_line = v_total_raw * line_exchange_rate
# v_unitario_line = v_unitario_raw * line_exchange_rate
# elif not report_is_mxn and line_currency_is_mxn:
# # MXN -> USD
# if line_exchange_rate > 0:
# v_total_line = v_total_raw / line_exchange_rate
# v_unitario_line = v_unitario_raw / line_exchange_rate
# else:
# v_total_line = 0.0
# v_unitario_line = 0.0
print(f"DEBUG: ValRaw: {v_total_raw} -> ValFinal: {v_total_line}")
# --- Resolve Fraction Details (Description & Rate) ---
# Only if this is the first time we see this key (or overwrite, doesn't matter much as they should be same for same HTS)
# We check if we already have description set to avoid re-querying if we want optimization,
# but relying on DB query per distinct fraction is safer.
current_agg = aggregated_data[agg_key]
if not current_agg["description"]:
us_frac_db = db.query(USTariffFraction).filter(USTariffFraction.code == us_frac_clean).first()
if us_frac_db:
current_agg["description"] = us_frac_db.description or "Sin Descripción"
# Parse AdValorem from DB if available, else 0 ??
# Creating logical placeholder. The provided Clarion code used `FraAme.Adv`
adv_val = us_frac_db.ad_valorem # Assuming field exists based on viewing file later?
# Wait, in us-tariff-fractions.ts I saw `ad_valorem: number | null`.
current_agg["advalorem_txt"] = f"{adv_val}%" if adv_val is not None else "0%"
else:
current_agg["description"] = part_master.description_spanish if part_master else "S/D"
current_agg["hts_code_print"] = us_frac_clean
current_agg["unit_measure"] = qty.weight_unit if qty else "KGS" # Default to first found
# --- Calculate Estimated Tax for this Line ---
rate = 0.0
try:
clean_adv = current_agg["advalorem_txt"].replace("%", "").strip()
rate = float(clean_adv) / 100.0
except: rate = 0.0
v_est_line = v_total_line * rate
# --- Accumulate ---
current_agg["qty"] += q_line
current_agg["net_weight_kgs"] += nw_line
current_agg["gross_weight_kgs"] += gw_line
current_agg["total_value"] += v_total_line
current_agg["est_total_value"] += v_est_line
# --- Convert Aggregated Data to Schema List ---
partidas_list = []
for (hts, origin), data in aggregated_data.items():
# Calculate Unit Price based on Total Value / Total Qty
unit_price = 0.0
if data["qty"] > 0:
unit_price = data["total_value"] / data["qty"]
partidas_list.append(PartidaSchema(
numero_parte="VARIOS", # Or empty
descripcion=data["description"],
fraccion=data["hts_code_print"],
origen=origin,
advalorem=data["advalorem_txt"],
preferencia="General",
cantidad_importacion=self.formatear_numero(data["qty"]),
unidad_medida=data["unit_measure"],
cantidad_bultos=0, # Summing bultos might be tricky if not homogeneous, check logic later
clave_bultos="",
peso_neto=self.formatear_numero(data["net_weight_kgs"]),
peso_bruto=self.formatear_numero(data["gross_weight_kgs"]),
valor_costo_unitario=self.formatear_numero(unit_price),
valor_total=self.formatear_numero(data["total_value"]),
valor_estimado=self.formatear_numero(data["est_total_value"])
))
# Sort by Fraction (HTS Code)
partidas_list.sort(key=lambda x: x.fraccion)
totales = self.calcular_totales(partidas_list, Decimal(factura_schema.tipo_cambio))
return FacturaImportacionCompleta(
cliente_proveedor=cliente_proveedor, cliente_vendido=cliente_vendido,
cliente_enviado=cliente_enviado, factura=factura_schema,
partidas=partidas_list, totales=totales
)
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(p.cantidad_importacion for p in partidas)
valor = sum(p.valor_total for p in partidas)
peso_n = sum(p.peso_neto for p in partidas)
peso_b = sum(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"
v_est = sum(p.valor_estimado for p in partidas if isinstance(p.valor_estimado, (int, float, Decimal)))
tc = float(tipo_cambio) if tipo_cambio else 1.0
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=self.formatear_numero(valor), valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0),
valor_estimado_total=self.formatear_numero(v_est)
)
def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", 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...")
# LOGO LOGIC
logo_b64 = None
try:
# Fetch company to get logo path
comp_logo = db.query(Company).filter(Company.id == company_id).first()
if comp_logo and comp_logo.logo:
p = Path(comp_logo.logo)
# Logic robusta de búsqueda (igual que en routes.py)
target_path = p
if not target_path.exists():
# Intentar en la ruta estándar: app_data/logos/{id}/{nombre}
# Esto cubre el caso donde solo se guardó el nombre del archivo o la ruta absoluta cambió
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')
# Detect MIME type loosely
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}")
context = {
'cliente_proveedor': datos.cliente_proveedor.model_dump(), 'cliente_vendido': datos.cliente_vendido.model_dump(),
'cliente_enviado': datos.cliente_enviado.model_dump(), 'factura': datos.factura.model_dump(),
'partidas': [p.model_dump() for p in datos.partidas], 'totales': datos.totales.model_dump(),
'logo_b64': logo_b64
}
html_content = self.template.render(**context)
nombre = f"Consolidado_{datos.factura.numero}.{formato}"
if formato == "html": return html_content.encode('utf-8'), nombre, "text/html"
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

@@ -30,6 +30,7 @@ class ClienteSchema(BaseModel):
class FacturaSchema(BaseModel):
numero: str
titulo_documento: str = "Factura de Importacion" # Titulo dinámico basado en document_type
fecha: str
tipo_cambio: float
moneda: str

View File

@@ -47,6 +47,46 @@ class FacturaImportacionMexService:
)
self.template = self.jinja_env.get_template('factura_mex_ver.html')
def _get_document_title(self, invoice_type: str, is_american: bool = False) -> str:
"""
Determina el título del documento basado en el tipo de factura.
Args:
invoice_type: Tipo de factura (TEM, DEF, MEX, CR)
is_american: Si es factura americana (True) o mexicana (False)
Returns:
Título formateado para la factura
"""
# Mapeo para facturas mexicanas
mexican_titles = {
"MEX": "Factura Importación Compras Mexicanas",
"DEF": "Importación Definitiva",
"TEM": "Importación Temporal",
"CR": "Importación de Cambio de Régimen",
}
# Mapeo para facturas americanas
american_titles = {
"MEX": "Mexican Purchases Import Invoice",
"DEF": "Definitive Importation",
"TEM": "Temporary Importation",
"CR": "Regime Change Importation",
}
# Seleccionar el mapa correcto
titles = american_titles if is_american else mexican_titles
# Obtener el título (normalizar a mayúsculas)
invoice_type_upper = invoice_type.upper() if invoice_type else ""
title = titles.get(invoice_type_upper, "")
# Fallback a genéricos si no se encuentra
if not title:
return "Commercial Invoice" if is_american else "Factura de Importación"
return title
def _get_wkhtmltopdf_config(self):
path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf"
if not Path(path).exists():
@@ -93,7 +133,7 @@ class FacturaImportacionMexService:
cert=prog.is_certified_company if (prog and prog.is_certified_company) else ""
)
def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> FacturaImportacionCompleta:
def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None, currency_code: str = 'ORIGINAL') -> FacturaImportacionCompleta:
try:
if progress_callback: progress_callback(10, "Buscando factura...")
header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first()
@@ -200,11 +240,19 @@ class FacturaImportacionMexService:
if drv_obj:
licencia_cond_val = drv_obj.license_number or ""
# Determine Currency
moneda_final = getattr(header, 'currency', "USD") or "USD"
if currency_code == 'MXN':
moneda_final = 'MXN'
elif currency_code == 'USD':
moneda_final = 'USD'
factura_schema = FacturaSchema(
numero=header.invoice_number or "S/N",
titulo_documento=self._get_document_title(header.invoice_type or "", is_american=False),
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",
moneda=moneda_final,
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 "",
@@ -351,9 +399,9 @@ class FacturaImportacionMexService:
valor_total_total=self.formatear_numero(valor), valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0)
)
def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", progress_callback: Optional[Callable] = None) -> Tuple[bytes, str, str]:
def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", progress_callback: Optional[Callable] = None, currency_code: str = 'ORIGINAL') -> 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)
datos = self.obtener_datos(db, invoice_id, company_id, progress_callback, currency_code)
if progress_callback: progress_callback(80, "Renderizando plantilla...")

View File

@@ -40,9 +40,10 @@ async def get_task_status(
async def trigger_descarga_factura(
invoice_id: int,
company_id: int = Query(..., description="ID de la empresa"),
invoice_type: str = Query('mexican', description="Tipo de factura: 'mexican' o 'american'"),
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_factura_async.delay(invoice_id, company_id)
task = generar_pdf_factura_async.delay(invoice_id, company_id, invoice_type)
return {"task_id": task.id, "message": "Generación iniciada"}

View File

@@ -5,35 +5,36 @@ from celery import current_task, states
from core.database import CoreSessionLocal
from .mex.service import FacturaImportacionMexService
from .usa.service import FacturaImportacionUsaService
logger = logging.getLogger(__name__)
@celery_app.task(name="generar_pdf_factura_async", bind=True)
def generar_pdf_factura_async(self, invoice_id: int, company_id: int):
def generar_pdf_factura_async(self, invoice_id: int, company_id: int, invoice_type: str = 'mexican', currency_code: str = 'ORIGINAL'):
# 1. Abrimos conexión a la DB
db = CoreSessionLocal()
try:
logger.info(f"Worker procesando factura {invoice_id}...")
logger.info(f"Worker procesando factura {invoice_id} ({invoice_type}, {currency_code})...")
# 2. Instanciamos el servicio de reportes
service = FacturaImportacionMexService()
if invoice_type == 'american':
service = FacturaImportacionUsaService()
else:
service = FacturaImportacionMexService()
# 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_factura_completa(
db=db,
invoice_id=invoice_id,
company_id=company_id,
progress_callback=progress_callback
progress_callback=progress_callback,
currency_code=currency_code
)
# 4. Codificamos a base64 para que viaje seguro por Valkey
pdf_base64 = base64.b64encode(pdf_bytes).decode('utf-8')
return {
@@ -48,5 +49,5 @@ def generar_pdf_factura_async(self, invoice_id: int, company_id: int):
return {"status": "error", "message": str(e)}
finally:
# 5. MUY IMPORTANTE: Cerramos la conexión para no saturar Postgres
db.close()

View File

@@ -273,7 +273,7 @@
<header>
<div class="flex-container clearfix">
<div class="width-48" style="position: relative;">
<p class="titulo">Factura de Importacion</p>
<p class="titulo">{{ factura.titulo_documento }}</p>
<div class="cliente" style="height: 1px;"></div>
<p class="cliente p-t-1 p-b-1"></p>
</div>

View File

@@ -0,0 +1,597 @@
<!DOCTYPE html>
<html lang="en" xml:lang="en">
<head>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type" />
<title>Commercial Invoice - {{ 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">{{ factura.titulo_documento }}</p>
<div class="cliente" style="height: 1px;"></div>
<p class="cliente p-t-1 p-b-1"></p>
</div>
<div class="width-48-right">
<p class="p-l-5 line-1"><span></span></p>
<p class="p-t-2"><br></p>
</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: 25pt; 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 %} Zip Code: {{
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 style="position: relative;">
<table cellspacing="0" class="m-l-3" style="float: right; text-align: left;">
<tbody>
<tr class="h-18">
<td bgcolor="#E4E4E4" class="border" style="width:52pt">
<p class="medio-bold">INVOICE:</p>
</td>
<td class="border" colspan="3" style="width:176pt">
<p class="grande">{{ factura.numero }}</p>
</td>
</tr>
<tr class="h-11">
<td bgcolor="#E4E4E4" class="border" style="width:52pt">
<p class="normal p-l-3 line-9">Date:</p>
</td>
<td class="border" style="width:72pt">
<p class="normal p-l-2 line-9">{{ factura.fecha }}</p>
</td>
<td bgcolor="#E4E4E4" class="border" style="width:52pt">
<p class="normal p-l-2 line-9">Ex. Rate:</p>
</td>
<td class="border" style="width:52pt">
<p class="normal center line-9">{{ factura.tipo_cambio }}</p>
</td>
</tr>
<tr class="h-11">
<td bgcolor="#E4E4E4" class="border">
<p class="tiny-bold p-l-3 line-9">INCOTERM:</p>
</td>
<td class="border">
<p class="tiny p-l-3 line-9">{{ factura.incoterm or '' }}</p>
</td>
<td bgcolor="#E4E4E4" class="border">
<p class="tiny-bold p-l-3 line-9">Customs:</p>
</td>
<td class="border">
<p class="tiny p-l-3 line-9 bottom">{{ factura.aduana }}</p>
</td>
</tr>
</tbody>
</table>
</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 %} Zip Code: {{
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">Tax ID: {{ 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 %} Zip Code: {{
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">Tax ID: {{ 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>
<tr class="h-10">
<td bgcolor="#E4E4E4" class="border" style="width:60pt">
<p class="tiny line-9">Carrier:</p>
</td>
<td class="border" colspan="4" style="width:120pt">
<p class="tiny">{{ factura.transportista }}</p>
</td>
<td class="border" style="width:40pt">
<p class="tiny">SCAC: {{ factura.scac }}</p>
</td>
<td bgcolor="#E4E4E4" class="border" style="width:40pt">
<p class="tiny">INCOTERM:</p>
</td>
<td class="border" style="width:40pt">
<p class="tiny">{{ factura.incoterm }}</p>
</td>
<td class="border" style="width:50pt">
<p class="tiny line-9">Customs: <span class="mini">{{ factura.aduana }}</span> / Ped: <span
class="mini">{{ factura.pedimento }}</span></p>
</td>
<td bgcolor="#E4E4E4" class="border" style="width:20pt">
<p><br /></p>
</td>
</tr>
<tr class="h-10">
<td bgcolor="#E4E4E4" class="border" style="width:60pt">
<p class="tiny line-9">Transport:</p>
</td>
<td class="border" colspan="4" style="width:120pt">
<p class="tiny">{{ factura.transporte }}: {{ factura.num_transporte }}</p>
</td>
<td class="border" colspan="3" style="width:80pt">
<p class="tiny">CAAT: {{ factura.caat }}</p>
</td>
<td class="border" colspan="2" style="width:100pt">
<p class="tiny p-t-1 line-8">Plates: {{ factura.placas or '' }} / Trl: {{ factura.placas_remolque or
'' }}</p>
</td>
</tr>
<tr class="h-10">
<td bgcolor="#E4E4E4" class="border" colspan="2" style="width:80pt">
<p class="tiny p-l-1 line-9">Driver/Lic:</p>
</td>
<td class="border" colspan="8">
<p class="tiny p-l-3">{{ factura.licencia_conductor or 'N/A' }}</p>
</td>
</tr>
<tr class="h-10">
<td class="border" rowspan="2">
<p class="tiny-bold p-t-5 center">Line</p>
</td>
<td class="border" rowspan="2">
<p class="tiny-bold p-l-2 line-10">Part Number</p>
<p class="tiny-bold p-l-2 line-10">Description</p>
</td>
<td class="border" colspan="3">
<p class="tiny-bold center line-9">Commercial</p>
</td>
<td class="border" style="width:50pt">
<p class="tiny-bold center line-9">Packaging</p>
</td>
<td class="border" colspan="2">
<p class="tiny-bold center line-9">Weight (KGS)</p>
</td>
<td class="border" colspan="2">
<p class="tiny-bold line-9 center">Values</p>
</td>
</tr>
<tr class="h-11">
<td class="border center" colspan="2">
<p class="mini p-l-2">Quantity</p>
</td>
<td class="border center">
<p class="mini p-l-1">U.M.</p>
</td>
<td class="border">
<p class="mini center">Type</p>
</td>
<td class="border">
<p class="mini center">Net</p>
</td>
<td class="border">
<p class="mini center">Gross</p>
</td>
<td class="border">
<p class="mini center">Unit</p>
</td>
<td class="border">
<p class="mini center">Total</p>
</td>
</tr>
</thead>
<tbody class="h-384" style="width: 100%;">
{% for partida in partidas %}
<tr>
<td class="border">
<p class="mini p-t-3 center">{{ loop.index }}</p>
</td>
<td class="border">
<p class="mini p-t-1" style="font-weight: bold;">{{ partida.numero_parte }}</p>
<p class="mini">{{ partida.descripcion }}</p>
<p class="mini">HTS Code: {{ partida.fraccion }} / Orig: {{ partida.origen or 'MEX' }}</p>
<p class="mini">
{% if partida.advalorem %}ADV: {{ partida.advalorem }}{% endif %}
{% if partida.preferencia %} / PREF: {{ partida.preferencia }}{% endif %}
</p>
</td>
<td class="border" colspan="2">
<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 %}
{{ partida.clave_bultos }}
</p>
</td>
<td class="border">
<p class="mini p-t-3 center">{{ partida.peso_neto }}</p>
</td>
<td class="border">
<p class="mini p-t-3 center">{{ partida.peso_bruto }}</p>
</td>
<td class="border">
<p class="mini p-t-2 center">${{ partida.valor_costo_unitario }}</p>
</td>
<td class="border">
<p class="mini p-t-2 right p-r-2">${{ partida.valor_total }}</p>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="h-14">
<td bgcolor="#E4E4E4" class="border" colspan="2" style="width:123pt">
<p class="small-bold p-t-2 p-l-3 line-10">
<span>Remarks:</span>
<span class="small-bold" style="float: right; margin-right: 2pt;">TOTALS</span>
</p>
</td>
<td class="border" colspan="2" style="width:28pt">
<p class="tiny p-t-5 p-r-1 line-8 center">{{ totales.cantidad_total }}</p>
</td>
<td class="border" style="width:22pt"></td>
<td class="border" style="width:40pt">
<p class="tiny p-t-4 center line-8">
{% if totales.bultos_total != 0 %}{{ totales.bultos_total }}{% endif %}
<span>{{ totales.clave_bultos or '' }}</span>
</p>
</td>
<td class="border" style="width:27pt">
<p class="tiny p-t-5 center line-8">{{ totales.peso_neto_total }}</p>
</td>
<td class="border" style="width:28pt">
<p class="tiny p-t-5 center line-8">{{ totales.peso_bruto_total }}</p>
</td>
<td style="width:30pt"></td>
<td class="border" style="width:35pt">
<p class="tiny p-t-5 right line-8 p-r-2">${{ totales.valor_total_total }}</p>
</td>
</tr>
<tr>
<td colspan="2" 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="8" 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>
<p class="small-bold center line-7" style="margin-bottom: 0;">Values expressed in: {{ factura.moneda
}}</p>
</td>
</tr>
<tr>
<td colspan="10" style="width:580pt; vertical-align: top; height: 100%;">
<p class="p-t-8"><br /></p>
<p class="p-l-5 line-8 tiny-bold"></p>
<p class="normal p-l-5 line-9">I declare under penalty of perjury that the information contained in
this document is true and correct.</p>
</td>
</tr>
</tfoot>
</table>
</body>
</html>

View File

@@ -0,0 +1,403 @@
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 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, FacturaImportacionCompleta
)
class FacturaImportacionMexService:
def __init__(self):
self.template_dir = Path(__file__).parent.parent / "templates"
self.jinja_env = Environment(
loader=FileSystemLoader(self.template_dir),
autoescape=select_autoescape(['html', 'xml'])
)
self.template = self.jinja_env.get_template('factura_mex_ver.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 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
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 obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> FacturaImportacionCompleta:
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) - Used for fallback or Right Side (Enviado A)
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:
# Clean header: "enviado_a" -> "Enviado a:"
raw_header_shipped = compliance.shipped_to_header or "DESTINATARIO"
clean_header_shipped = raw_header_shipped.replace("_", " ").capitalize() + ":"
# Fetch client data
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 ""
# Init values
placas_val = (logistics.license_plate or "") if logistics else "" # Placas Tracto
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 "" # Mapping transport_code to SCAC
transportista_val = transporter_obj.name or logistics.carrier_id
# 2. Vehicle (Placas Tracto) - Try transport_id first
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: # Fallback to vehicle_num if populated and transport_id failed/empty
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:
# Attempt to find driver by name + carrier
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()
fin = db.query(LineFinancial).filter(LineFinancial.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 ""
# Fetch Origin from Master Catalog (FaPart)
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)
# Consultar tabla tariff_fractions
fraccion_db = db.query(TariffFraction).filter(TariffFraction.code == fraccion_limpia).first()
preferencia_txt = "General"
advalorem_txt = "0%"
fraccion_imprimir = fraccion_raw
if fraccion_db:
# Si el valor en BD es None, "0", o vacío, dejarlo como "0%" o "EXENTO"
adv_db = fraccion_db.adv_impo
if adv_db and adv_db.strip() not in ["0", "0.0", "0.00", ""]:
advalorem_txt = adv_db if "%" in adv_db else f"{adv_db}%"
else:
advalorem_txt = "0%"
fraccion_imprimir = fraccion_db.fraction or fraccion_raw
else:
fraccion_imprimir = self._format_fraccion_fallback(fraccion_limpia)
# Logic to determine values - Prioritize Specific Currency Columns
v_unitario = 0.0
v_total = 0.0
if fin:
is_mxn = (factura_schema.moneda == 'MXN')
# 1. Try Specific Currency Columns First
if is_mxn:
v_unitario = float(fin.unit_cost_commercial_mxn or 0.0)
v_total = float(fin.value_commercial_mxn or 0.0)
else:
v_unitario = float(fin.unit_cost_commercial_usd or 0.0)
v_total = float(fin.value_commercial_usd or 0.0)
# 2. Fallback to Generic independently if Specific is 0
if not v_unitario:
v_unitario = float(fin.commercial_unit_cost or 0.0)
if not v_total:
v_total = float(fin.total_commercial_value or 0.0)
# 3. Calculate from Quantity if still missing
cantidad = float(qty.quantity) if (qty and qty.quantity) else 0.0
if cantidad > 0:
if v_unitario > 0 and v_total == 0:
v_total = v_unitario * cantidad
elif v_total > 0 and v_unitario == 0:
v_unitario = v_total / cantidad
partidas_list.append(PartidaSchema(
numero_parte=num_parte_final,
descripcion=desc_final,
fraccion=fraccion_imprimir,
origen=origen_final,
advalorem=advalorem_txt,
preferencia=preferencia_txt,
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 "",
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),
valor_total=self.formatear_numero(v_total)
))
totales = self.calcular_totales(partidas_list, Decimal(factura_schema.tipo_cambio))
return FacturaImportacionCompleta(
cliente_proveedor=cliente_proveedor, cliente_vendido=cliente_vendido,
cliente_enviado=cliente_enviado, factura=factura_schema,
partidas=partidas_list, totales=totales
)
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(p.cantidad_importacion for p in partidas)
valor = sum(p.valor_total for p in partidas)
peso_n = sum(p.peso_neto for p in partidas)
peso_b = sum(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"
tc = float(tipo_cambio) if tipo_cambio else 1.0
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=self.formatear_numero(valor), valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0)
)
def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", 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...")
# LOGO LOGIC
logo_b64 = None
try:
# Fetch company to get logo path
comp_logo = db.query(Company).filter(Company.id == company_id).first()
if comp_logo and comp_logo.logo:
p = Path(comp_logo.logo)
# Logic robusta de búsqueda (igual que en routes.py)
target_path = p
if not target_path.exists():
# Intentar en la ruta estándar: app_data/logos/{id}/{nombre}
# Esto cubre el caso donde solo se guardó el nombre del archivo o la ruta absoluta cambió
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')
# Detect MIME type loosely
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}")
context = {
'cliente_proveedor': datos.cliente_proveedor.model_dump(), 'cliente_vendido': datos.cliente_vendido.model_dump(),
'cliente_enviado': datos.cliente_enviado.model_dump(), 'factura': datos.factura.model_dump(),
'partidas': [p.model_dump() for p in datos.partidas], 'totales': datos.totales.model_dump(),
'logo_b64': logo_b64
}
html_content = self.template.render(**context)
nombre = f"Factura_{datos.factura.numero}.{formato}"
if formato == "html": return html_content.encode('utf-8'), nombre, "text/html"
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,451 @@
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 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 ---
# Reuse schemas from neighbor package as they fit the same data structure
from ..mex.schemas import (
ClienteSchema, PartidaSchema, TotalesSchema,
FacturaSchema, FacturaImportacionCompleta
)
class FacturaImportacionUsaService:
def __init__(self):
self.template_dir = Path(__file__).parent.parent / "templates"
self.jinja_env = Environment(
loader=FileSystemLoader(self.template_dir),
autoescape=select_autoescape(['html', 'xml'])
)
self.template = self.jinja_env.get_template('factura_usa_ver.html')
def _get_document_title(self, invoice_type: str, is_american: bool = True) -> str:
"""
Determina el título del documento basado en el tipo de factura.
Args:
invoice_type: Tipo de factura (TEM, DEF, MEX, CR)
is_american: Si es factura americana (True) o mexicana (False)
Returns:
Título formateado para la factura
"""
# Mapeo para facturas mexicanas
mexican_titles = {
"MEX": "Factura Importación Compras Mexicanas",
"DEF": "Importación Definitiva",
"TEM": "Importación Temporal",
"CR": "Importación de Cambio de Régimen",
}
# Mapeo para facturas americanas
american_titles = {
"MEX": "Mexican Purchases Import Invoice",
"DEF": "Definitive Importation",
"TEM": "Temporary Importation",
"CR": "Regime Change Importation",
}
# Seleccionar el mapa correcto
titles = american_titles if is_american else mexican_titles
# Obtener el título (normalizar a mayúsculas)
invoice_type_upper = invoice_type.upper() if invoice_type else ""
title = titles.get(invoice_type_upper, "")
# Fallback a genéricos si no se encuentra
if not title:
return "Commercial Invoice" if is_american else "Factura de Importación"
return title
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 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
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="Unknown", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="USA")
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 "N/A",
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 "USA") if addr else "USA",
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 obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None, currency_code: str = 'ORIGINAL') -> FacturaImportacionCompleta:
try:
if progress_callback: progress_callback(10, "Searching invoice...")
header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first()
if not header: raise HTTPException(status_code=404, detail="Invoice not found")
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, "Fetching entry data...")
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, "Fetching client and supplier...")
proveedor_id = compliance.provider_id if compliance else None
cliente_proveedor = self._obtener_datos_cliente(db, proveedor_id, "Supplier:") if proveedor_id else ClienteSchema(header="Supplier", nombre="Unassigned", 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', "Local Company"),
direccion="FISCAL ADDRESS",
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 (Sold To)
cliente_vendido = cliente_default
if compliance and compliance.sold_to_id:
# Force English header for American Invoice
clean_header = "Sold To:"
# raw_header = compliance.sold_to_header or "SOLD_TO"
# clean_header = raw_header.replace("_", " ").title() + ":"
cliente_vendido = self._obtener_datos_cliente(db, compliance.sold_to_id, clean_header)
# Right Side Logic (Shipped To)
cliente_enviado = cliente_default
if compliance and compliance.shipped_to_id:
# Force English header for American Invoice
clean_header_shipped = "Shipped To:"
# raw_header_shipped = compliance.shipped_to_header or "SHIPPED_TO"
# clean_header_shipped = raw_header_shipped.replace("_", " ").title() + ":"
# Fetch client data
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 ""
# Init values
placas_val = (logistics.license_plate or "") if logistics else "" # Plates
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 "" # Mapping transport_code to SCAC
transportista_val = transporter_obj.name or logistics.carrier_id
# 2. Vehicle (Plates)
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
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 ""
# Determine Currency
moneda_final = getattr(header, 'currency', "USD") or "USD"
if currency_code == 'MXN':
moneda_final = 'MXN'
elif currency_code == 'USD':
moneda_final = 'USD'
factura_schema = FacturaSchema(
numero=header.invoice_number or "N/A",
titulo_documento=self._get_document_title(header.invoice_type or "", is_american=True),
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=moneda_final,
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, "Processing items...")
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()
fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == line.id).first()
part_master = db.query(Part).filter(Part.id == line.part_number).first()
desc_final = "N/D"
num_parte_final = str(line.part_number or "N/A")
fraccion_raw = ""
origen_final = "MEX"
if part_master:
# Prefer English description if available, else Spanish
desc_final = part_master.description_english or part_master.description_spanish or "No Desc."
num_parte_final = part_master.part_number
# Prefer US Fraction (HTS) if available
fraccion_raw = part_master.us_fraction if part_master.us_fraction else (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)
# Fetch tariff fraction
fraccion_db = db.query(TariffFraction).filter(TariffFraction.code == fraccion_limpia).first()
preferencia_txt = "General"
advalorem_txt = "0%"
fraccion_imprimir = fraccion_raw
if fraccion_db:
adv_db = fraccion_db.adv_impo
if adv_db and adv_db.strip() not in ["0", "0.0", "0.00", ""]:
advalorem_txt = adv_db if "%" in adv_db else f"{adv_db}%"
else:
advalorem_txt = "0%"
fraccion_imprimir = fraccion_db.fraction or fraccion_raw
else:
fraccion_imprimir = self._format_fraccion_fallback(fraccion_limpia)
# Prioritize USD for American Invoice logic if available?
# Sticking to same logic as Mex for now but could prioritize USD columns.
# Actually, duplicate logic from mex service for now to ensure consistency.
v_unitario = 0.0
v_total = 0.0
if fin:
is_mxn = (factura_schema.moneda == 'MXN')
if is_mxn:
v_unitario = float(fin.unit_cost_commercial_mxn or 0.0)
v_total = float(fin.value_commercial_mxn or 0.0)
else:
v_unitario = float(fin.unit_cost_commercial_usd or 0.0)
v_total = float(fin.value_commercial_usd or 0.0)
if not v_unitario:
v_unitario = float(fin.commercial_unit_cost or 0.0)
if not v_total:
v_total = float(fin.total_commercial_value or 0.0)
cantidad = float(qty.quantity) if (qty and qty.quantity) else 0.0
if cantidad > 0:
if v_unitario > 0 and v_total == 0:
v_total = v_unitario * cantidad
elif v_total > 0 and v_unitario == 0:
v_unitario = v_total / cantidad
# UOM Mapping for English context
uom_raw = qty.weight_unit if qty else "PCS"
if uom_raw == "PZA": uom_raw = "PCS"
partidas_list.append(PartidaSchema(
numero_parte=num_parte_final,
descripcion=desc_final,
fraccion=fraccion_imprimir,
origen=origen_final,
advalorem=advalorem_txt,
preferencia=preferencia_txt,
cantidad_importacion=self.formatear_numero(qty.quantity if qty else 0),
unidad_medida=uom_raw,
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=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),
valor_total=self.formatear_numero(v_total)
))
totales = self.calcular_totales(partidas_list, Decimal(factura_schema.tipo_cambio))
return FacturaImportacionCompleta(
cliente_proveedor=cliente_proveedor, cliente_vendido=cliente_vendido,
cliente_enviado=cliente_enviado, factura=factura_schema,
partidas=partidas_list, totales=totales
)
except Exception as e:
print(f"Error Service A76 USA: {e}")
raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
def calcular_totales(self, partidas: List[PartidaSchema], tipo_cambio: Decimal) -> TotalesSchema:
cant = sum(p.cantidad_importacion for p in partidas)
valor = sum(p.valor_total for p in partidas)
peso_n = sum(p.peso_neto for p in partidas)
peso_b = sum(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"
# Don't pluralize strictly in English without logic, kept simple.
tc = float(tipo_cambio) if tipo_cambio else 1.0
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=self.formatear_numero(valor), valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0)
)
def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", progress_callback: Optional[Callable] = None, currency_code: str = 'ORIGINAL') -> Tuple[bytes, str, str]:
if progress_callback: progress_callback(5, "Starting report service...")
datos = self.obtener_datos(db, invoice_id, company_id, progress_callback, currency_code)
if progress_callback: progress_callback(80, "Rendering template...")
# 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}")
context = {
'cliente_proveedor': datos.cliente_proveedor.model_dump(), 'cliente_vendido': datos.cliente_vendido.model_dump(),
'cliente_enviado': datos.cliente_enviado.model_dump(), 'factura': datos.factura.model_dump(),
'partidas': [p.model_dump() for p in datos.partidas], 'totales': datos.totales.model_dump(),
'logo_b64': logo_b64
}
html_content = self.template.render(**context)
nombre = f"Commercial_Invoice_{datos.factura.numero}.{formato}"
if formato == "html": return html_content.encode('utf-8'), nombre, "text/html"
if progress_callback: progress_callback(90, "Generating PDF...")
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, "Completed")
return pdf, nombre, "application/pdf"

View File

@@ -1,15 +1,19 @@
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() });
triggerPdfGeneration: async (invoiceId: number, companyId: number, invoiceType: string = 'mexican', currency: string = 'ORIGINAL') => {
const params = new URLSearchParams({
company_id: companyId.toString(),
invoice_type: invoiceType,
currency_code: currency
});
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 +21,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',

View File

@@ -1,90 +1,23 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { FileDown, LoaderCircle } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { invoicesReportsApi } from '$lib/api/dashboard/a76/reports/reports-invoices';
import { FileDown } from 'lucide-svelte';
import InvoiceDownloadModal from './invoice-download-modal.svelte';
export let invoiceId: number;
export let companyId: number;
let processing = false;
async function startDownload() {
if (processing) return;
processing = true;
const toastId = toast.loading('Iniciando generación de PDF...');
try {
const { task_id } = await invoicesReportsApi.triggerPdfGeneration(invoiceId, companyId);
const pollInterval = setInterval(async () => {
try {
const statusData = await invoicesReportsApi.getTaskStatus(task_id);
if (statusData.state === 'SUCCESS') {
clearInterval(pollInterval);
toast.success('Factura generada correctamente', { id: toastId });
const { content, file_name, media_type } = statusData.result;
downloadBase64File(content, media_type, file_name);
processing = false;
} else if (statusData.state === 'FAILURE') {
clearInterval(pollInterval);
throw new Error(statusData.result || 'Error desconocido');
} else if (statusData.state === 'PROCESSING') {
const meta = statusData.result;
if (meta && typeof meta === 'object') {
const current = meta.current || 0;
const total = meta.total || 100;
const progress = Math.round((current / total) * 100);
// Update toast with progress
toast.loading(`Generando PDF: ${progress}%`, {
id: toastId,
description: meta.status || 'Procesando...'
});
}
}
} catch (err: any) {
clearInterval(pollInterval);
handleError(err, toastId);
}
}, 1000);
} catch (err: any) {
handleError(err, toastId);
}
}
function handleError(err: any, toastId: string | number) {
processing = false;
console.error(err);
toast.error('Error al generar PDF: ' + (err.message || 'Error desconocido'), { id: toastId });
}
function downloadBase64File(base64Data: string, contentType: string, fileName: string) {
const linkSource = `data:${contentType};base64,${base64Data}`;
const downloadLink = document.createElement("a");
downloadLink.href = linkSource;
downloadLink.download = fileName;
downloadLink.click();
}
let isModalOpen = false;
</script>
<Button
variant="outline"
onclick={startDownload}
disabled={processing}
onclick={() => isModalOpen = true}
class="w-[100px]"
>
{#if processing}
<LoaderCircle size={16} class="mr-2 animate-spin" />
PDF
{:else}
<FileDown size={16} class="mr-2" />
PDF
{/if}
<FileDown size={16} class="mr-2" />
PDF
</Button>
<InvoiceDownloadModal
bind:open={isModalOpen}
onConfirm={() => console.log('Download not implemented in this context')}
/>

View File

@@ -0,0 +1,184 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import * as Select from '$lib/components/ui/select';
import { Button } from '$lib/components/ui/button';
import { Label } from '$lib/components/ui/label';
import { Download, RectangleVertical, RectangleHorizontal, DollarSign, FileText, Globe, Scale, Weight, Tag, Package, Box } from 'lucide-svelte';
import { cn } from '$lib/utils';
export let open = false;
export let onConfirm: (type: string, format: string, currency: string, uomSource: string, weightUnit: string) => void;
let invoiceType = 'mexican'; // 'mexican' | 'american'
let format = 'vertical'; // 'vertical' | 'horizontal'
let currency = 'ORIGINAL'; // 'ORIGINAL' | 'MXN' | 'USD'
let uomSource = 'PART'; // 'CLASS' | 'PART' | 'STOCK'
let weightUnit = 'KG'; // 'KG' | 'LB'
const currencyOptions = [
{ value: 'ORIGINAL', label: 'Captura', desc: 'Original', icon: FileText },
{ value: 'MXN', label: 'Nacional', desc: 'Pesos', icon: DollarSign },
{ value: 'USD', label: 'Extranjera', desc: 'Dólares', icon: Globe }
];
const uomOptions = [
{ value: 'CLASS', label: 'Clase', icon: Tag },
{ value: 'PART', label: 'Parte', icon: Package },
{ value: 'STOCK', label: 'Existencia', icon: Box }
];
const weightOptions = [
{ value: 'KG', label: 'Kilos', icon: Scale },
{ value: 'LB', label: 'Libras', icon: Weight }
];
function handleConfirm() {
onConfirm(invoiceType, format, currency, uomSource, weightUnit);
open = false;
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[550px]">
<Dialog.Header>
<Dialog.Title>Descargar Factura</Dialog.Title>
<Dialog.Description>
Configure las opciones para generar el documento PDF.
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-6 py-4">
<div class="grid grid-cols-2 gap-6">
<!-- 1. Invoice Type -->
<div class="space-y-2">
<Label>Tipo de Factura</Label>
<Select.Root type="single" value={invoiceType} onValueChange={(v) => invoiceType = v}>
<Select.Trigger>
{invoiceType === 'mexican' ? 'Factura Mexicana' : 'Factura Americana'}
</Select.Trigger>
<Select.Content>
<Select.Item value="mexican" label="Factura Mexicana">Factura Mexicana</Select.Item>
<Select.Item value="american" label="Factura Americana">Factura Americana</Select.Item>
</Select.Content>
</Select.Root>
</div>
<!-- 2. Format -->
<div class="space-y-2">
<Label>Formato</Label>
<Select.Root
type="single"
value={format}
onValueChange={(v) => format = v}
disabled
>
<Select.Trigger disabled>
<div class="flex items-center gap-2">
{#if format === 'vertical'}
<RectangleVertical class="h-4 w-4" />
<span>Vertical</span>
{:else}
<RectangleHorizontal class="h-4 w-4" />
<span>Horizontal</span>
{/if}
</div>
</Select.Trigger>
<Select.Content>
<Select.Item value="vertical" label="Vertical">
<div class="flex items-center gap-2">
<RectangleVertical class="h-4 w-4" />
<span>Vertical</span>
</div>
</Select.Item>
<Select.Item value="horizontal" label="Horizontal">
<div class="flex items-center gap-2">
<RectangleHorizontal class="h-4 w-4" />
<span>Horizontal</span>
</div>
</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
<!-- 3. Currency Selection -->
<div class="space-y-3">
<Label>Moneda de Impresión</Label>
<div class="grid grid-cols-3 gap-3">
{#each currencyOptions as option}
<button
type="button"
disabled
class={cn(
"flex flex-col items-center justify-between rounded-md border-2 p-3 transition-all h-[80px] opacity-50 cursor-not-allowed",
currency === option.value ? "border-primary bg-primary/5" : "border-muted bg-transparent"
)}
onclick={() => currency = option.value}
>
<svelte:component this={option.icon} class={cn("mb-1 h-5 w-5 text-muted-foreground", currency === option.value && "text-primary")} />
<div class="text-center leading-tight">
<div class="font-semibold text-xs whitespace-nowrap">{option.label}</div>
<div class="text-[10px] text-muted-foreground">{option.desc}</div>
</div>
</button>
{/each}
</div>
</div>
<div class="grid grid-cols-2 gap-6">
<!-- 4. UOM Source -->
<div class="space-y-3">
<Label>Imprimir en la UM de la</Label>
<div class="flex gap-2">
{#each uomOptions as option}
<button
type="button"
disabled
class={cn(
"flex-1 flex flex-col items-center justify-center rounded-md border-2 p-2 transition-all h-[60px] opacity-50 cursor-not-allowed",
uomSource === option.value ? "border-primary bg-primary/5" : "border-muted bg-transparent"
)}
onclick={() => uomSource = option.value}
>
<svelte:component this={option.icon} class={cn("mb-1 h-4 w-4 text-muted-foreground", uomSource === option.value && "text-primary")} />
<div class="font-semibold text-[10px] whitespace-nowrap">{option.label}</div>
</button>
{/each}
</div>
</div>
<!-- 5. Weight Unit -->
<div class="space-y-3">
<Label>Tipo UM de peso</Label>
<div class="flex gap-2">
{#each weightOptions as option}
<button
type="button"
disabled
class={cn(
"flex-1 flex flex-col items-center justify-center rounded-md border-2 p-2 transition-all h-[60px] opacity-50 cursor-not-allowed",
weightUnit === option.value ? "border-primary bg-primary/5" : "border-muted bg-transparent"
)}
onclick={() => weightUnit = option.value}
>
<svelte:component this={option.icon} class={cn("mb-1 h-4 w-4 text-muted-foreground", weightUnit === option.value && "text-primary")} />
<div class="font-semibold text-[10px] whitespace-nowrap">{option.label}</div>
</button>
{/each}
</div>
</div>
</div>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => open = false}>Cancelar</Button>
<Button onclick={handleConfirm}>
<Download class="mr-2 h-4 w-4" />
Descargar
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,5 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import InvoiceDownloadModal from '$lib/components/dashboard/invoices/invoice-download-modal.svelte';
import { page } from '$app/stores';
import { invoicesApi, type Invoice, type OperationType } from '$lib/api/dashboard/a76/invoices';
import { invoicesReportsApi } from '$lib/api/dashboard/a76/reports/reports-invoices';
@@ -33,6 +35,8 @@
year: data.filters?.year || ''
});
let isDownloadModalOpen = $state(false);
// Efecto reactivo para actualizar filtros cuando cambian los query parameters en la URL
$effect(() => {
if (browser) {
@@ -486,6 +490,30 @@
// --- AQUÍ PASAMOS LA FUNCIÓN DE DESCARGA A LAS COLUMNAS ---
const columns = createColumns(handleSuccess);
async function handleModalConfirm(type: string, format: string, currency: string, uomSource: string, weightUnit: string) {
if (!selectedInvoice || !companyStore.activeCompany) return;
try {
// 1. Trigger: Start celery task with selected options
// Note: uomSource and weightUnit are UI-only for now
const { task_id } = await invoicesReportsApi.triggerPdfGeneration(
selectedInvoice.id,
companyStore.activeCompany.id,
type,
currency
);
// 2. Open Progress Dialog
currentTaskId = task_id;
currentStatusFunction = invoicesReportsApi.getTaskStatus;
showProgressDialog = true;
} catch (error) {
console.error(error);
toast.error("No se pudo iniciar la descarga");
}
}
</script>
<div class="space-y-6">
@@ -625,7 +653,7 @@
<RotateCcw class="h-4 w-4 mr-2" />
Desactualizar
</Button>
<Button variant="outline" size="sm" onclick={() => selectedInvoice && handleDownloadPdf(selectedInvoice)} disabled={!selectedInvoice}>
<Button variant="outline" size="sm" onclick={() => isDownloadModalOpen = true} disabled={!selectedInvoice}>
<FileText class="h-4 w-4 mr-2" />
Factura
</Button>
@@ -636,4 +664,15 @@
</div>
</div>
</div>
<!-- ... -->
{#if selectedInvoice && companyStore.activeCompany}
<InvoiceDownloadModal
bind:open={isDownloadModalOpen}
onConfirm={handleModalConfirm}
/>
{/if}
</div>