Se creo la rutina de creacion de reportes parcial
This commit is contained in:
@@ -1,25 +1,29 @@
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional, Union
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
# Nota: Usamos Union[float, str] en los números para poder enviar
|
||||
# strings formateados con comas (ej: "1,200.50") si lo deseamos,
|
||||
# ya que tu HTML no tiene filtros de formato.
|
||||
|
||||
class ClienteSchema(BaseModel):
|
||||
header: str
|
||||
nombre: str
|
||||
direccion: str
|
||||
num_exterior: str = ""
|
||||
num_interior: str = ""
|
||||
colonia: str = ""
|
||||
codigo_postal: str
|
||||
ciudad: str
|
||||
estado: str
|
||||
pais: str
|
||||
# Ponemos valor por defecto "" y permitimos que sea opcional
|
||||
direccion: Optional[str] = ""
|
||||
num_exterior: Optional[str] = ""
|
||||
num_interior: Optional[str] = ""
|
||||
colonia: Optional[str] = ""
|
||||
codigo_postal: Optional[str] = ""
|
||||
ciudad: Optional[str] = ""
|
||||
estado: Optional[str] = ""
|
||||
pais: Optional[str] = ""
|
||||
tax_id: str
|
||||
programa: str = ""
|
||||
autorizacion: str = ""
|
||||
programa: Optional[str] = ""
|
||||
autorizacion: Optional[str] = ""
|
||||
|
||||
# Si llega un None, lo convertimos en "" automáticamente
|
||||
@field_validator('direccion', 'nombre', mode='before')
|
||||
@classmethod
|
||||
def prevent_none(cls, v):
|
||||
return v or ""
|
||||
|
||||
class FacturaSchema(BaseModel):
|
||||
numero: str
|
||||
|
||||
@@ -8,18 +8,22 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# --- IMPORTACIONES DE TUS MODELOS (Asegúrate que las rutas sean correctas) ---
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics
|
||||
from api.v1.modules.a76.items.models import ItemLines, LineQuantities, LineFinancials
|
||||
from api.v1.modules.a76.clients.models import (
|
||||
ClientsAndProviders,
|
||||
ClientsAndProvidersAddress,
|
||||
ClientsAndProvidersPrograms
|
||||
# --- 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 Parts
|
||||
from api.v1.modules.a76.pedimentos.models import Pedimentos
|
||||
from api.v1.modules.a76.companies.models import Company # Tu empresa propia (Tenant)
|
||||
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
|
||||
|
||||
# --- SCHEMAS ---
|
||||
from .schemas import (
|
||||
ClienteSchema, PartidaSchema, TotalesSchema,
|
||||
FacturaSchema, FacturaImportacionCompleta
|
||||
@@ -27,9 +31,7 @@ from .schemas import (
|
||||
|
||||
class FacturaImportacionMexService:
|
||||
def __init__(self):
|
||||
# Directorio de templates
|
||||
self.template_dir = Path(__file__).parent.parent / "templates"
|
||||
|
||||
self.jinja_env = Environment(
|
||||
loader=FileSystemLoader(self.template_dir),
|
||||
autoescape=select_autoescape(['html', 'xml'])
|
||||
@@ -37,14 +39,8 @@ class FacturaImportacionMexService:
|
||||
self.template = self.jinja_env.get_template('factura_mex_ver.html')
|
||||
|
||||
def _get_wkhtmltopdf_config(self):
|
||||
path = shutil.which("wkhtmltopdf")
|
||||
if not path:
|
||||
common_paths = ["/usr/bin/wkhtmltopdf", "/usr/local/bin/wkhtmltopdf"]
|
||||
for p in common_paths:
|
||||
if Path(p).exists():
|
||||
path = p
|
||||
break
|
||||
if not path:
|
||||
path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf"
|
||||
if not Path(path).exists():
|
||||
raise RuntimeError("wkhtmltopdf no encontrado.")
|
||||
return pdfkit.configuration(wkhtmltopdf=path)
|
||||
|
||||
@@ -55,178 +51,152 @@ class FacturaImportacionMexService:
|
||||
except: return 0.0
|
||||
|
||||
def _obtener_datos_cliente(self, db: Session, client_id: int, rol: str) -> ClienteSchema:
|
||||
"""
|
||||
Busca en las 3 tablas de clientes (Main, Address, Programs) para armar el esquema.
|
||||
"""
|
||||
# 1. Tabla Principal
|
||||
main = db.query(ClientsAndProviders).filter(ClientsAndProviders.id == client_id).first()
|
||||
main = db.query(ClientProvider).filter(ClientProvider.id == client_id).first()
|
||||
if not main:
|
||||
return ClienteSchema(
|
||||
header=rol, nombre="Desconocido", direccion="", codigo_postal="",
|
||||
ciudad="", estado="", pais="", tax_id=""
|
||||
header=rol, nombre="Desconocido", direccion="", tax_id="",
|
||||
codigo_postal="", ciudad="", estado="", pais="MEX"
|
||||
)
|
||||
|
||||
# 2. Dirección
|
||||
addr = db.query(ClientsAndProvidersAddress).filter(ClientsAndProvidersAddress.client_id == client_id).first()
|
||||
|
||||
# 3. Programa / Tax ID
|
||||
prog = db.query(ClientsAndProvidersPrograms).filter(ClientsAndProvidersPrograms.client_id == client_id).first()
|
||||
addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == client_id).first()
|
||||
prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == client_id).first()
|
||||
|
||||
direccion_str = addr.streets if addr else ""
|
||||
|
||||
return ClienteSchema(
|
||||
header=rol,
|
||||
nombre=main.name or main.short_name,
|
||||
direccion=direccion_str,
|
||||
num_exterior=addr.exterior_number if addr else "",
|
||||
num_interior=addr.interior_number if addr else "",
|
||||
colonia=addr.neighborhood if addr else "",
|
||||
codigo_postal=addr.postal_code if addr else "",
|
||||
ciudad=addr.city if addr else "",
|
||||
estado=addr.state if addr else "",
|
||||
pais=addr.country if addr else "MEX",
|
||||
tax_id=prog.tax_id if prog else (main.rfc or ""),
|
||||
programa="IMMEX" if prog and prog.program else "",
|
||||
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 ""
|
||||
)
|
||||
|
||||
def obtener_datos(self, db: Session, invoice_id: int) -> FacturaImportacionCompleta:
|
||||
def obtener_datos(self, db: Session, invoice_id: int, company_id: int) -> FacturaImportacionCompleta:
|
||||
try:
|
||||
# 1. CABECERA (InvoiceHeader)
|
||||
header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id).first()
|
||||
# 1. Cabecera
|
||||
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")
|
||||
|
||||
# 2. LOGÍSTICA (InvoiceLogistics)
|
||||
logistics = db.query(InvoiceLogistics).filter(InvoiceLogistics.invoice_id == header.id).first()
|
||||
|
||||
# 3. PEDIMENTO (Pedimentos)
|
||||
# Intentamos buscar por relation_doc_id o asumimos una búsqueda por ID si existiera columna pedimento_id
|
||||
# Si no hay link directo, buscamos el pedimento asociado al cliente/fecha (Lógica aproximada)
|
||||
pedimento = None
|
||||
if header.related_doc_id:
|
||||
pedimento = db.query(Pedimentos).filter(Pedimentos.id == header.related_doc_id).first()
|
||||
|
||||
# 4. ACTORES (Clientes/Proveedores)
|
||||
# Importación: Proveedor = Externo (Client ID?), Importador = Nosotros (Company ID)
|
||||
# Nota: Asumo que en invoice_header hay un campo 'client_id' para el proveedor externo.
|
||||
# Si no existe en el modelo, revisa qué campo guarda el ID del proveedor.
|
||||
proveedor_id = getattr(header, 'client_id', None)
|
||||
# 2. Relaciones Críticas (Compliance y Logistics)
|
||||
# Nota: Usamos la relación ORM 'compliance_mx' que definiste en el modelo
|
||||
compliance = header.compliance_mx
|
||||
|
||||
# --- PROVEEDOR (Extranjero) ---
|
||||
# Si logistics es una lista, tomamos el primero, si no, None
|
||||
logistics = header.logistics[0] if header.logistics else None
|
||||
|
||||
# Pedimento: Prioridad al de Compliance, si no, al de Header
|
||||
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
|
||||
|
||||
# 3. Mapeo Actores (¡AQUÍ ESTABA EL DETALLE!)
|
||||
|
||||
# A) Proveedor: Sacado de compliance.provider_id
|
||||
proveedor_id = compliance.provider_id if compliance else None
|
||||
if proveedor_id:
|
||||
cliente_proveedor = self._obtener_datos_cliente(db, proveedor_id, "Proveedor / Supplier")
|
||||
else:
|
||||
# Fallback si no encontramos ID de proveedor
|
||||
cliente_proveedor = ClienteSchema(
|
||||
header="Proveedor", nombre="No Asignado", direccion="", codigo_postal="", ciudad="", estado="", pais="", tax_id=""
|
||||
)
|
||||
cliente_proveedor = ClienteSchema(header="Proveedor", nombre="No Asignado", direccion="", tax_id="",
|
||||
codigo_postal="", ciudad="", estado="", pais="")
|
||||
|
||||
# --- IMPORTADOR (Vendido A - Tu Empresa) ---
|
||||
# Usamos 'company_id' que viene en invoice_header
|
||||
# Ojo: Si tu empresa también está en clients_and_providers, úsala. Si está en 'companies', mapea desde ahí.
|
||||
# Aquí asumo que está en 'companies' como Tenant.
|
||||
# B) Agente Aduanal: Sacado de compliance.customs_broker_id
|
||||
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 # Asumiendo que CustomsBroker tiene 'name'
|
||||
|
||||
# C) Importador (Company)
|
||||
company = db.query(Company).filter(Company.id == header.company_id).first()
|
||||
|
||||
cliente_vendido = ClienteSchema(
|
||||
header="Importador / Consignatario",
|
||||
nombre=company.name if company else "Mi Empresa",
|
||||
direccion=company.address_street if company else "", # Ajustar nombres de col company
|
||||
num_exterior=str(company.address_number) if company else "",
|
||||
colonia=company.neighborhood if company else "",
|
||||
codigo_postal=company.zip_code if company else "",
|
||||
ciudad=company.city if company else "",
|
||||
estado=company.state if company else "",
|
||||
pais=company.country if company else "MEX",
|
||||
tax_id=company.tax_id if company else "",
|
||||
programa="IMMEX",
|
||||
autorizacion=company.immex_program if company else ""
|
||||
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', "")
|
||||
)
|
||||
|
||||
cliente_enviado = cliente_vendido
|
||||
# 4. Mapeo Factura
|
||||
# Nota: Muchos datos vienen de 'compliance', no de 'header'
|
||||
remesa_valor = str(compliance.remesa) if (compliance and compliance.remesa) else ""
|
||||
acuse_valor = str(compliance.edocument) if (compliance and compliance.edocument) else "N/A"
|
||||
|
||||
# 5. MAPEO FACTURA
|
||||
factura_schema = FacturaSchema(
|
||||
numero=header.invoice_number,
|
||||
numero=header.invoice_number or "S/N",
|
||||
fecha=str(header.invoice_date) if header.invoice_date else "",
|
||||
tipo_cambio=float(pedimento.exchange_rate) if pedimento and pedimento.exchange_rate else 1.0,
|
||||
moneda="USD", # O derivar de header.invoice_type o logistics
|
||||
moneda=getattr(header, 'currency', "USD") or "USD",
|
||||
incoterm=logistics.incoterm if logistics else "",
|
||||
observaciones=header.observation_es or header.observation_en or "",
|
||||
|
||||
# Datos Pedimento
|
||||
# Pedimento y Agente
|
||||
pedimento=pedimento.pedimento_number if pedimento else "",
|
||||
clave_pedimento=pedimento.pedimento_code if pedimento else "",
|
||||
regimen=pedimento.regime if pedimento else "",
|
||||
patente=pedimento.license if pedimento else "",
|
||||
agente_aduanal=nombre_agente, # Agregamos el nombre real
|
||||
|
||||
# Datos Transporte
|
||||
transportista="", # logistics.carrier_id (habría que hacer join con tabla carriers)
|
||||
transporte=logistics.transport_type if logistics else "",
|
||||
# Transporte
|
||||
transporte=str(logistics.transport_type.value) if (logistics and logistics.transport_type) else "",
|
||||
num_transporte=logistics.trailer_num if logistics else "",
|
||||
placas=logistics.license_plate if logistics else "",
|
||||
aduana=pedimento.customs_office if pedimento else "",
|
||||
transportista=logistics.carrier_id if logistics else "", # Si carrier_id es ID, aquí habría que buscar nombre
|
||||
|
||||
# Otros
|
||||
remesa="", # No vi campo remesa en header
|
||||
acuse_electronico="",
|
||||
agente_aduanal="", # pedimento.license
|
||||
patente=pedimento.license if pedimento else "",
|
||||
aduana=pedimento.customs_office if pedimento else "",
|
||||
precinto=logistics.seal_number if logistics else "",
|
||||
destino=logistics.destination_goods if logistics else ""
|
||||
destino=logistics.destination_goods if logistics else "",
|
||||
remesa=remesa_valor,
|
||||
acuse_electronico=acuse_valor
|
||||
)
|
||||
|
||||
# 6. PARTIDAS (JOIN item_lines + line_quantities + line_financials)
|
||||
lines = db.query(ItemLines).filter(ItemLines.item_id == header.id).all() # Ojo: item_id suele ser invoice_id en header
|
||||
|
||||
# 5. Mapeo Partidas
|
||||
lines = db.query(LineItem).filter(LineItem.item_id == header.id).all()
|
||||
partidas_list = []
|
||||
|
||||
for line in lines:
|
||||
# Datos Cuantitativos
|
||||
qty = db.query(LineQuantities).filter(LineQuantities.item_line_id == line.id).first()
|
||||
# Datos Financieros
|
||||
fin = db.query(LineFinancials).filter(LineFinancials.item_line_id == line.id).first()
|
||||
|
||||
# Parte Maestra (Si line.part_number es ID, buscamos. Si es string, usamos directo)
|
||||
# Tu tabla dice 'part_number' es integer, asumo que es ID hacia tabla 'parts'
|
||||
part_master = db.query(Parts).filter(Parts.id == line.part_number).first()
|
||||
|
||||
# Construcción de valores
|
||||
descripcion = part_master.description_spanish if part_master else "S/D"
|
||||
fraccion = part_master.fraction if part_master else ""
|
||||
|
||||
cantidad = float(qty.quantity) if qty else 0.0
|
||||
# Costo: Unitario Comercial USD
|
||||
precio_unitario = float(fin.commercial_unit_cost) if fin else 0.0
|
||||
valor_total = float(fin.total_commercial_value) if fin else (cantidad * precio_unitario)
|
||||
|
||||
# Pesos y Bultos (LineQuantities tiene todo esto, ¡genial!)
|
||||
peso_n = float(qty.net_weight) if qty else 0.0
|
||||
peso_b = float(qty.gross_weight) if qty else 0.0
|
||||
bultos = int(qty.package_quantity) if qty and qty.package_quantity else 0
|
||||
clave_b = qty.package_key if qty else ""
|
||||
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()
|
||||
|
||||
partidas_list.append(PartidaSchema(
|
||||
numero_parte=part_master.part_number if part_master else str(line.part_number),
|
||||
descripcion=descripcion,
|
||||
fraccion=fraccion,
|
||||
origen="MEX", # line.country_origin? o part_master
|
||||
cantidad_importacion=self.formatear_numero(cantidad),
|
||||
unidad_medida=qty.weight_unit if qty else "KG", # O qty.package_key
|
||||
cantidad_bultos=bultos,
|
||||
clave_bultos=clave_b,
|
||||
peso_neto=self.formatear_numero(peso_n),
|
||||
peso_bruto=self.formatear_numero(peso_b),
|
||||
valor_costo_unitario=self.formatear_numero(precio_unitario),
|
||||
valor_total=self.formatear_numero(valor_total)
|
||||
numero_parte=part_master.part_number if part_master else str(line.part_number or "S/N"),
|
||||
descripcion=part_master.description_spanish if part_master else "S/D",
|
||||
fraccion=part_master.fraction if part_master else "",
|
||||
origen="MEX",
|
||||
cantidad_importacion=self.formatear_numero(qty.quantity if qty else 0),
|
||||
unidad_medida=qty.weight_unit if qty else "KG",
|
||||
cantidad_bultos=int(qty.package_quantity) if qty and qty.package_quantity else 0,
|
||||
clave_bultos=qty.package_key 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(fin.commercial_unit_cost if fin else 0),
|
||||
valor_total=self.formatear_numero(fin.total_commercial_value if fin else 0)
|
||||
))
|
||||
|
||||
# 7. TOTALES
|
||||
# 6. Totales
|
||||
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,
|
||||
cliente_enviado=cliente_vendido,
|
||||
factura=factura_schema,
|
||||
partidas=partidas_list,
|
||||
totales=totales
|
||||
@@ -237,16 +207,13 @@ class FacturaImportacionMexService:
|
||||
raise HTTPException(status_code=500, detail=f"Error procesando datos: {str(e)}")
|
||||
|
||||
def calcular_totales(self, partidas: List[PartidaSchema], tipo_cambio: Decimal) -> TotalesSchema:
|
||||
cant = sum(float(p.cantidad_importacion) for p in partidas)
|
||||
valor = sum(float(p.valor_total) for p in partidas)
|
||||
peso_n = sum(float(p.peso_neto) for p in partidas)
|
||||
peso_b = sum(float(p.peso_bruto) for p in partidas)
|
||||
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)
|
||||
|
||||
# Obtener clave de bulto más común
|
||||
claves = [p.clave_bultos for p in partidas if p.clave_bultos]
|
||||
clave_comun = max(set(claves), key=claves.count) if claves else ""
|
||||
|
||||
tc = float(tipo_cambio) if tipo_cambio else 1.0
|
||||
|
||||
return TotalesSchema(
|
||||
@@ -259,8 +226,9 @@ class FacturaImportacionMexService:
|
||||
valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0)
|
||||
)
|
||||
|
||||
# --- GENERACIÓN ---
|
||||
def generar_html(self, datos: FacturaImportacionCompleta) -> str:
|
||||
def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf") -> Tuple[bytes, str, str]:
|
||||
datos = self.obtener_datos(db, invoice_id, company_id)
|
||||
|
||||
context = {
|
||||
'cliente_proveedor': datos.cliente_proveedor.model_dump(),
|
||||
'cliente_vendido': datos.cliente_vendido.model_dump(),
|
||||
@@ -269,28 +237,17 @@ class FacturaImportacionMexService:
|
||||
'partidas': [p.model_dump() for p in datos.partidas],
|
||||
'totales': datos.totales.model_dump()
|
||||
}
|
||||
return self.template.render(**context)
|
||||
|
||||
def generar_pdf(self, html_content: str) -> bytes:
|
||||
options = {
|
||||
'page-size': 'Letter',
|
||||
'margin-top': '0.75in',
|
||||
'margin-right': '0.75in',
|
||||
'margin-bottom': '1.00in',
|
||||
'margin-left': '0.75in',
|
||||
'encoding': "UTF-8",
|
||||
'enable-local-file-access': None
|
||||
}
|
||||
config = self._get_wkhtmltopdf_config()
|
||||
return pdfkit.from_string(html_content, False, options=options, configuration=config)
|
||||
|
||||
def generar_factura_completa(self, db: Session, invoice_id: int, formato: str = "pdf") -> Tuple[bytes, str, str]:
|
||||
datos = self.obtener_datos(db, invoice_id)
|
||||
html = self.generar_html(datos)
|
||||
|
||||
html_content = self.template.render(**context)
|
||||
nombre = f"Factura_{datos.factura.numero}.{formato}"
|
||||
|
||||
if formato == "html":
|
||||
return html.encode('utf-8'), nombre, "text/html"
|
||||
return html_content.encode('utf-8'), nombre, "text/html"
|
||||
|
||||
pdf = self.generar_pdf(html)
|
||||
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())
|
||||
return pdf, nombre, "application/pdf"
|
||||
@@ -1,9 +1,10 @@
|
||||
from enum import Enum
|
||||
from typing import Dict, Any
|
||||
from fastapi import APIRouter, Depends, Query, Response, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from core.database import get_db
|
||||
|
||||
# Importamos el servicio mexicano
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, validate_access_to_resource
|
||||
from .mex.service import FacturaImportacionMexService
|
||||
|
||||
router = APIRouter()
|
||||
@@ -11,7 +12,6 @@ servicio_mex = FacturaImportacionMexService()
|
||||
|
||||
class TipoFactura(str, Enum):
|
||||
mexicana = "mex"
|
||||
# americana = "usa"
|
||||
|
||||
class Formato(str, Enum):
|
||||
html = "html"
|
||||
@@ -20,31 +20,34 @@ class Formato(str, Enum):
|
||||
@router.get("/{invoice_id}/download")
|
||||
async def descargar_factura(
|
||||
invoice_id: int,
|
||||
company_id: int = Query(..., description="ID de la empresa"),
|
||||
tipo: TipoFactura = Query(TipoFactura.mexicana),
|
||||
formato: Formato = Query(Formato.pdf),
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Descargar Factura de Importación (A76)
|
||||
"""
|
||||
if tipo == TipoFactura.mexicana:
|
||||
servicio = servicio_mex
|
||||
else:
|
||||
raise HTTPException(status_code=501, detail="Tipo de factura no implementado")
|
||||
# Validar que el usuario tiene acceso a esta empresa
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
try:
|
||||
contenido, nombre, media_type = servicio.generar_factura_completa(
|
||||
db=db,
|
||||
invoice_id=invoice_id,
|
||||
formato=formato.value
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=contenido,
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={nombre}"
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error generando reporte: {str(e)}")
|
||||
if tipo == TipoFactura.mexicana:
|
||||
try:
|
||||
# Pasamos invoice_id Y company_id al servicio
|
||||
contenido, nombre, media_type = servicio_mex.generar_factura_completa(
|
||||
db=db,
|
||||
invoice_id=invoice_id,
|
||||
company_id=company_id,
|
||||
formato=formato.value
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=contenido,
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={nombre}",
|
||||
"Access-Control-Expose-Headers": "Content-Disposition"
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
else:
|
||||
raise HTTPException(status_code=501, detail="Tipo no implementado")
|
||||
@@ -48,6 +48,9 @@ from .transportation.transporters.routes import router as transporters_router
|
||||
from .transportation.vehicles.routes import router as vehicles_router
|
||||
from api.v1.modules.public.reference_data.material_types.routes import router as material_types_router
|
||||
|
||||
# --- NUEVO IMPORT PARA REPORTES DE FACTURAS ---
|
||||
from .reports.importacion.facturas.routes import router as invoices_reports_router
|
||||
|
||||
|
||||
# Router principal
|
||||
router = APIRouter()
|
||||
@@ -114,3 +117,10 @@ router.include_router(
|
||||
prefix="/public/reference-data",
|
||||
tags=["Reference Data"]
|
||||
)
|
||||
|
||||
# --- REGISTRO DE RUTAS DE REPORTES ---
|
||||
router.include_router(
|
||||
invoices_reports_router,
|
||||
prefix="/a76/reports/importacion/facturas",
|
||||
tags=["a76 / reports"]
|
||||
)
|
||||
@@ -12,6 +12,7 @@ from .modules.a24.router import router as a24_router
|
||||
from .modules.public.router import router as public_router
|
||||
from .modules.a24.router import router as a24_router
|
||||
|
||||
|
||||
# Router principal
|
||||
router = APIRouter()
|
||||
|
||||
@@ -29,3 +30,5 @@ router.include_router(a24_router)
|
||||
def status():
|
||||
"""Health check de la API"""
|
||||
return {"status": "ok", "version": "1.0.0", "api": "v1"}
|
||||
|
||||
|
||||
|
||||
@@ -41,3 +41,4 @@ pylint==4.0.2
|
||||
|
||||
# reportes
|
||||
Jinja2==3.1.6
|
||||
pdfkit==1.0.0
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// En invoices.ts
|
||||
|
||||
// 1. Recuperamos la URL base del entorno (así funciona igual en local y en producción)
|
||||
// Nota: El nombre 'VITE_API_URL' depende de cómo lo tengan en tu proyecto.
|
||||
// A veces es import.meta.env.PUBLIC_API_URL
|
||||
const BASE_URL = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
export const invoicesReportsApi = {
|
||||
// ... tus otros métodos ...
|
||||
|
||||
downloadPdf: async (invoiceId: number, type: 'mex' | 'usa' = 'mex', companyId: number) => {
|
||||
const params = new URLSearchParams({
|
||||
tipo: type,
|
||||
formato: 'pdf',
|
||||
company_id: companyId.toString()
|
||||
});
|
||||
// 2. Usamos la variable, no el texto fijo
|
||||
const endpoint = `${BASE_URL}/v1/a76/reports/importacion/facturas/${invoiceId}/download?${params.toString()}`;
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Error desconocido' }));
|
||||
throw new Error(error.detail || 'Error al descargar');
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
},
|
||||
};
|
||||
@@ -4,247 +4,241 @@ import { createRawSnippet } from "svelte";
|
||||
import DataTableActions from "./data-table-actions.svelte";
|
||||
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
|
||||
|
||||
/**
|
||||
* Formatea un número como moneda MXN
|
||||
*/
|
||||
// ... (Tus funciones de formato formatCurrencyMXN, formatCurrencyUSD, formatDate, etc. se quedan igual) ...
|
||||
function formatCurrencyMXN(value?: number | null): string {
|
||||
if (value === null || value === undefined) return '-';
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}).format(value);
|
||||
if (value === null || value === undefined) return '-';
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea un número como moneda USD
|
||||
*/
|
||||
function formatCurrencyUSD(value?: number | null): string {
|
||||
if (value === null || value === undefined) return '-';
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}).format(value);
|
||||
if (value === null || value === undefined) return '-';
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea una fecha
|
||||
*/
|
||||
function formatDate(date?: string | null): string {
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleDateString('es-MX', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
});
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleDateString('es-MX', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el color del badge según el tipo de operación
|
||||
*/
|
||||
function getOperationTypeColor(type?: string | null): string {
|
||||
if (!type) return 'bg-gray-100 text-gray-800';
|
||||
return type === 'imp' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800';
|
||||
if (!type) return 'bg-gray-100 text-gray-800';
|
||||
return type === 'imp' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800';
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el color del badge según el semáforo fiscal
|
||||
*/
|
||||
function getTrafficLightColor(status?: string | null): string {
|
||||
if (!status) return 'bg-gray-100 text-gray-800';
|
||||
|
||||
const statusLower = status.toLowerCase();
|
||||
if (statusLower.includes('verde') || statusLower === 'green') return 'bg-green-100 text-green-800';
|
||||
if (statusLower.includes('amarillo') || statusLower === 'yellow') return 'bg-yellow-100 text-yellow-800';
|
||||
if (statusLower.includes('rojo') || statusLower === 'red') return 'bg-red-100 text-red-800';
|
||||
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
if (!status) return 'bg-gray-100 text-gray-800';
|
||||
|
||||
const statusLower = status.toLowerCase();
|
||||
if (statusLower.includes('verde') || statusLower === 'green') return 'bg-green-100 text-green-800';
|
||||
if (statusLower.includes('amarillo') || statusLower === 'yellow') return 'bg-yellow-100 text-yellow-800';
|
||||
if (statusLower.includes('rojo') || statusLower === 'red') return 'bg-red-100 text-red-800';
|
||||
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<Invoice>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "ID",
|
||||
cell: ({ row }) => {
|
||||
const idSnippet = createRawSnippet<[{ id: number }]>((getId) => {
|
||||
const { id } = getId();
|
||||
return {
|
||||
render: () =>
|
||||
`<div class="font-medium">#${id}</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(idSnippet, { id: row.original.id });
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "operation_type",
|
||||
header: "Operación",
|
||||
cell: ({ row }) => {
|
||||
const type = row.original.operation_type;
|
||||
const colorClass = getOperationTypeColor(type);
|
||||
const label = type === 'imp' ? 'IMP' : type === 'exp' ? 'EXP' : 'N/A';
|
||||
|
||||
const typeSnippet = createRawSnippet<[{ label: string; colorClass: string }]>((getType) => {
|
||||
const { label, colorClass } = getType();
|
||||
return {
|
||||
render: () =>
|
||||
`<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${colorClass}">
|
||||
${label}
|
||||
</span>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(typeSnippet, { label, colorClass });
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "invoice_type",
|
||||
header: "Tipo",
|
||||
cell: ({ row }) => {
|
||||
const typeSnippet = createRawSnippet<[{ type?: string | null }]>((getType) => {
|
||||
const { type } = getType();
|
||||
return {
|
||||
render: () =>
|
||||
`<div class="capitalize">${type || '-'}</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(typeSnippet, { type: row.original.invoice_type });
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "invoice_number",
|
||||
header: "Número de Factura",
|
||||
cell: ({ row }) => {
|
||||
const numberSnippet = createRawSnippet<[{ number?: string | null }]>((getNumber) => {
|
||||
const { number } = getNumber();
|
||||
return {
|
||||
render: () =>
|
||||
`<code class="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">${number || 'N/A'}</code>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(numberSnippet, { number: row.original.invoice_number });
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "project_number",
|
||||
header: "Proyecto",
|
||||
cell: ({ row }) => {
|
||||
const projectSnippet = createRawSnippet<[{ project?: string | null }]>((getProject) => {
|
||||
const { project } = getProject();
|
||||
return {
|
||||
render: () =>
|
||||
`<div>${project || '-'}</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(projectSnippet, { project: row.original.project_number });
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "compliance_mx.pedimento",
|
||||
header: "Pedimento",
|
||||
cell: ({ row }) => {
|
||||
const pedimento = row.original.compliance_mx?.pedimento;
|
||||
|
||||
const pedimentoSnippet = createRawSnippet<[{ pedimento?: string | null }]>((getPedimento) => {
|
||||
const { pedimento } = getPedimento();
|
||||
return {
|
||||
render: () =>
|
||||
`<div class="text-sm">${pedimento || '-'}</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(pedimentoSnippet, { pedimento });
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "financials.value_mn",
|
||||
header: () => {
|
||||
const headerSnippet = createRawSnippet(() => {
|
||||
return {
|
||||
render: () => `<div class="text-right">Valor MN</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(headerSnippet, {});
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const valueMN = row.original.financials?.value_mn;
|
||||
|
||||
const valueSnippet = createRawSnippet<[{ value: string }]>((getValue) => {
|
||||
const { value } = getValue();
|
||||
return {
|
||||
render: () =>
|
||||
`<div class="text-right font-medium">${value}</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(valueSnippet, { value: formatCurrencyMXN(valueMN) });
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "financials.value_me",
|
||||
header: () => {
|
||||
const headerSnippet = createRawSnippet(() => {
|
||||
return {
|
||||
render: () => `<div class="text-right">Valor ME</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(headerSnippet, {});
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const valueME = row.original.financials?.value_me;
|
||||
|
||||
const valueSnippet = createRawSnippet<[{ value: string }]>((getValue) => {
|
||||
const { value } = getValue();
|
||||
return {
|
||||
render: () =>
|
||||
`<div class="text-right font-medium">${value}</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(valueSnippet, { value: formatCurrencyUSD(valueME) });
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "traffic_light_status",
|
||||
header: "Semáforo",
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.traffic_light_status;
|
||||
const colorClass = getTrafficLightColor(status);
|
||||
|
||||
const statusSnippet = createRawSnippet<[{ status?: string | null; colorClass: string }]>((getStatus) => {
|
||||
const { status, colorClass } = getStatus();
|
||||
return {
|
||||
render: () =>
|
||||
`<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${colorClass}">
|
||||
${status || '-'}
|
||||
</span>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(statusSnippet, { status, colorClass });
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "invoice_date",
|
||||
header: "Fecha Factura",
|
||||
cell: ({ row }) => {
|
||||
const dateSnippet = createRawSnippet<[{ date: string }]>((getDate) => {
|
||||
const { date } = getDate();
|
||||
return {
|
||||
render: () =>
|
||||
`<div class="text-sm text-muted-foreground">${date}</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(dateSnippet, { date: formatDate(row.original.invoice_date) });
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(DataTableActions, { invoice: row.original, onSuccess });
|
||||
}
|
||||
}
|
||||
];
|
||||
// 1. MODIFICAMOS LA FIRMA DE LA FUNCIÓN AQUÍ ABAJO
|
||||
export function createColumns(
|
||||
onSuccess?: () => void,
|
||||
onDownload?: (invoice: Invoice) => void // <--- AQUI AGREGAMOS EL ARGUMENTO
|
||||
): ColumnDef<Invoice>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "ID",
|
||||
cell: ({ row }) => {
|
||||
const idSnippet = createRawSnippet<[{ id: number }]>((getId) => {
|
||||
const { id } = getId();
|
||||
return {
|
||||
render: () =>
|
||||
`<div class="font-medium">#${id}</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(idSnippet, { id: row.original.id });
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "operation_type",
|
||||
header: "Operación",
|
||||
cell: ({ row }) => {
|
||||
const type = row.original.operation_type;
|
||||
const colorClass = getOperationTypeColor(type);
|
||||
const label = type === 'imp' ? 'IMP' : type === 'exp' ? 'EXP' : 'N/A';
|
||||
|
||||
const typeSnippet = createRawSnippet<[{ label: string; colorClass: string }]>((getType) => {
|
||||
const { label, colorClass } = getType();
|
||||
return {
|
||||
render: () =>
|
||||
`<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${colorClass}">
|
||||
${label}
|
||||
</span>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(typeSnippet, { label, colorClass });
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "invoice_type",
|
||||
header: "Tipo",
|
||||
cell: ({ row }) => {
|
||||
const typeSnippet = createRawSnippet<[{ type?: string | null }]>((getType) => {
|
||||
const { type } = getType();
|
||||
return {
|
||||
render: () =>
|
||||
`<div class="capitalize">${type || '-'}</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(typeSnippet, { type: row.original.invoice_type });
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "invoice_number",
|
||||
header: "Número de Factura",
|
||||
cell: ({ row }) => {
|
||||
const numberSnippet = createRawSnippet<[{ number?: string | null }]>((getNumber) => {
|
||||
const { number } = getNumber();
|
||||
return {
|
||||
render: () =>
|
||||
`<code class="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">${number || 'N/A'}</code>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(numberSnippet, { number: row.original.invoice_number });
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "project_number",
|
||||
header: "Proyecto",
|
||||
cell: ({ row }) => {
|
||||
const projectSnippet = createRawSnippet<[{ project?: string | null }]>((getProject) => {
|
||||
const { project } = getProject();
|
||||
return {
|
||||
render: () =>
|
||||
`<div>${project || '-'}</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(projectSnippet, { project: row.original.project_number });
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "compliance_mx.pedimento",
|
||||
header: "Pedimento",
|
||||
cell: ({ row }) => {
|
||||
const pedimento = row.original.compliance_mx?.pedimento_id;
|
||||
|
||||
const pedimentoSnippet = createRawSnippet<[{ pedimento?: string | null }]>((getPedimento) => {
|
||||
const { pedimento } = getPedimento();
|
||||
return {
|
||||
render: () =>
|
||||
`<div class="text-sm">${pedimento || '-'}</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(pedimentoSnippet, { pedimento });
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "financials.value_mn",
|
||||
header: () => {
|
||||
const headerSnippet = createRawSnippet(() => {
|
||||
return {
|
||||
render: () => `<div class="text-right">Valor MN</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(headerSnippet, {});
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const valueMN = row.original.financials?.value_mn;
|
||||
|
||||
const valueSnippet = createRawSnippet<[{ value: string }]>((getValue) => {
|
||||
const { value } = getValue();
|
||||
return {
|
||||
render: () =>
|
||||
`<div class="text-right font-medium">${value}</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(valueSnippet, { value: formatCurrencyMXN(valueMN) });
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "financials.value_me",
|
||||
header: () => {
|
||||
const headerSnippet = createRawSnippet(() => {
|
||||
return {
|
||||
render: () => `<div class="text-right">Valor ME</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(headerSnippet, {});
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const valueME = row.original.financials?.value_me;
|
||||
|
||||
const valueSnippet = createRawSnippet<[{ value: string }]>((getValue) => {
|
||||
const { value } = getValue();
|
||||
return {
|
||||
render: () =>
|
||||
`<div class="text-right font-medium">${value}</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(valueSnippet, { value: formatCurrencyUSD(valueME) });
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "traffic_light_status",
|
||||
header: "Semáforo",
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.traffic_light_status;
|
||||
const colorClass = getTrafficLightColor(status);
|
||||
|
||||
const statusSnippet = createRawSnippet<[{ status?: string | null; colorClass: string }]>((getStatus) => {
|
||||
const { status, colorClass } = getStatus();
|
||||
return {
|
||||
render: () =>
|
||||
`<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${colorClass}">
|
||||
${status || '-'}
|
||||
</span>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(statusSnippet, { status, colorClass });
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "invoice_date",
|
||||
header: "Fecha Factura",
|
||||
cell: ({ row }) => {
|
||||
const dateSnippet = createRawSnippet<[{ date: string }]>((getDate) => {
|
||||
const { date } = getDate();
|
||||
return {
|
||||
render: () =>
|
||||
`<div class="text-sm text-muted-foreground">${date}</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(dateSnippet, { date: formatDate(row.original.invoice_date) });
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(DataTableActions, {
|
||||
invoice: row.original,
|
||||
onSuccess,
|
||||
onDownload
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// Mantener compatibilidad hacia atrás
|
||||
export const columns = createColumns();
|
||||
export const columns = createColumns();
|
||||
@@ -1,71 +1,80 @@
|
||||
<script lang="ts">
|
||||
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Ellipsis, Eye, Pencil, Trash2 } from 'lucide-svelte';
|
||||
import DetailsDialog from './details-dialog.svelte';
|
||||
import DeleteDialog from './delete-dialog.svelte';
|
||||
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
// 1. Agregamos FileDown a los imports
|
||||
import { Ellipsis, Eye, Pencil, Trash2, FileDown } from 'lucide-svelte';
|
||||
import DetailsDialog from './details-dialog.svelte';
|
||||
import DeleteDialog from './delete-dialog.svelte';
|
||||
|
||||
interface Props {
|
||||
invoice: Invoice;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
interface Props {
|
||||
invoice: Invoice;
|
||||
onSuccess?: () => void;
|
||||
// 2. Definimos la nueva prop (opcional para que no rompa si no se pasa)
|
||||
onDownload?: (invoice: Invoice) => void;
|
||||
}
|
||||
|
||||
let { invoice, onSuccess }: Props = $props();
|
||||
|
||||
let showDetails = $state(false);
|
||||
let showDelete = $state(false);
|
||||
// 3. Desestructuramos onDownload de los props
|
||||
let { invoice, onSuccess, onDownload }: Props = $props();
|
||||
|
||||
let showDetails = $state(false);
|
||||
let showDelete = $state(false);
|
||||
|
||||
function handleEdit() {
|
||||
// Redirigir a la página de edición
|
||||
window.location.href = `/dashboard/invoices/edit/${invoice.id}`;
|
||||
}
|
||||
function handleEdit() {
|
||||
window.location.href = `/dashboard/invoices/edit/${invoice.id}`;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<Ellipsis class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Item onclick={() => showDetails = true}>
|
||||
<Eye class="mr-2 h-4 w-4" />
|
||||
Ver Detalles
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<Ellipsis class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Item onclick={() => showDetails = true}>
|
||||
<Eye class="mr-2 h-4 w-4" />
|
||||
Ver Detalles
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item onclick={handleEdit}>
|
||||
<Pencil class="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
{#if onDownload}
|
||||
<DropdownMenu.Item onclick={() => onDownload(invoice)}>
|
||||
<FileDown class="mr-2 h-4 w-4" />
|
||||
Descargar PDF
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={() => showDelete = true} class="text-destructive">
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
<DropdownMenu.Item onclick={handleEdit}>
|
||||
<Pencil class="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={() => showDelete = true} class="text-destructive">
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<!-- Diálogos -->
|
||||
{#if showDetails}
|
||||
<DetailsDialog
|
||||
invoice={invoice}
|
||||
onClose={() => showDetails = false}
|
||||
/>
|
||||
<DetailsDialog
|
||||
invoice={invoice}
|
||||
onClose={() => showDetails = false}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showDelete}
|
||||
<DeleteDialog
|
||||
invoice={invoice}
|
||||
onClose={() => showDelete = false}
|
||||
{onSuccess}
|
||||
/>
|
||||
<DeleteDialog
|
||||
invoice={invoice}
|
||||
onClose={() => showDelete = false}
|
||||
{onSuccess}
|
||||
/>
|
||||
{/if}
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount } from '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';
|
||||
import DataTable from '$lib/components/dashboard/invoices/data-table.svelte';
|
||||
import { createColumns } from '$lib/components/dashboard/invoices/columns.js';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
@@ -11,7 +12,7 @@
|
||||
import type { PageData } from './$types';
|
||||
import { browser } from '$app/environment';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { Plus, Filter, Trash2, RefreshCw } from 'lucide-svelte';
|
||||
import { Plus, RefreshCw } from 'lucide-svelte';
|
||||
|
||||
// IMPORTANTE: Asegúrate de tener instalada svelte-sonner para las notificaciones
|
||||
import { toast } from "svelte-sonner";
|
||||
@@ -314,60 +315,24 @@
|
||||
// --- NUEVA FUNCIÓN: DESCARGAR PDF ---
|
||||
async function handleDownloadPdf(invoice: any) {
|
||||
const toastId = toast.loading("Generando PDF...");
|
||||
|
||||
try {
|
||||
// Obtenemos el token del localStorage
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) throw new Error('No hay sesión activa');
|
||||
// Llamada limpia
|
||||
const blob = await invoicesReportsApi.downloadPdf(invoice.id, 'mex', companyStore.activeCompany.id);
|
||||
|
||||
// Determinar tipo: Si es importación (imp) -> mex, Si es exportación (exp) -> usa (o lo que definas)
|
||||
// Por ahora hardcodeamos 'mex' como pediste
|
||||
const tipo = 'mex';
|
||||
|
||||
const endpoint = `/api/v1/a76/reports/importacion/facturas/${invoice.id}/download?tipo=${tipo}&formato=pdf`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => ({}));
|
||||
throw new Error(errData.detail || 'Error al generar el reporte');
|
||||
}
|
||||
|
||||
// Convertir respuesta a Blob
|
||||
const blob = await response.blob();
|
||||
// Lógica de descarga (crear el link fantasma)
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
// Crear link fantasma y descargar
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
|
||||
// Intentar usar nombre del header o fallback
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let fileName = `Factura_${invoice.invoice_number || invoice.id}.pdf`;
|
||||
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename="?([^"]+)"?/);
|
||||
if (match && match[1]) fileName = match[1];
|
||||
}
|
||||
|
||||
a.download = fileName;
|
||||
a.download = `Factura_${invoice.invoice_number}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// Limpieza
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
toast.success("Factura descargada", { id: toastId });
|
||||
|
||||
} catch (error: any) {
|
||||
toast.success("Descargado", { id: toastId });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(error.message || "No se pudo descargar la factura", { id: toastId });
|
||||
toast.error("Error al descargar", { id: toastId });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user