WIP: Guardando trabajo antes de actualizar con development

This commit is contained in:
2026-01-09 08:26:44 -06:00
parent c95c7f7c55
commit ff2df90dbe
11 changed files with 1315 additions and 449 deletions

View File

@@ -9,6 +9,31 @@ RUN apt-get update && apt-get install -y \
curl \
&& rm -rf /var/lib/apt/lists/*
# Instalar dependencias para wkhtmltopdf y reportes PDF
RUN apt-get update \
&& apt-get install -y \
xvfb \
fontconfig \
fonts-dejavu-core \
libfontconfig1 \
libxrender1 \
libxtst6 \
libxi6 \
libxrandr2 \
ca-certificates \
libjpeg62-turbo \
libpng16-16 \
&& rm -rf /var/lib/apt/lists/*
# Instalar wkhtmltopdf binario oficial con soporte para footers/headers
RUN curl -k -L -o /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bookworm_amd64.deb \
&& apt-get update \
&& apt-get install -y /tmp/wkhtmltox.deb \
&& rm /tmp/wkhtmltox.deb \
&& rm -rf /var/lib/apt/lists/* \
&& wkhtmltopdf --version
# Copiar requirements
COPY requirements.txt .

View File

@@ -0,0 +1,78 @@
from decimal import Decimal
from typing import List, Optional, Union
from pydantic import BaseModel
# 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
tax_id: str
programa: str = ""
autorizacion: str = ""
class FacturaSchema(BaseModel):
numero: str
fecha: str
tipo_cambio: float
moneda: str
# Campos de aduanas (Opcionales por si A76 aún no los tiene)
pedimento: str = ""
clave_pedimento: str = ""
remesa: str = ""
acuse_electronico: str = ""
agente_aduanal: str = ""
patente: str = ""
precinto: str = ""
regimen: str = ""
transportista: str = ""
scac: str = ""
caat: str = ""
incoterm: str = ""
transporte: str = ""
num_transporte: str = ""
placas: str = ""
aduana: str = ""
destino: str = ""
observaciones: str = ""
class PartidaSchema(BaseModel):
numero_parte: str
descripcion: str
fraccion: str
origen: str
cantidad_importacion: Union[float, str]
unidad_medida: str
cantidad_bultos: int
clave_bultos: str
peso_neto: Union[float, str]
peso_bruto: Union[float, str]
valor_costo_unitario: Union[float, str]
valor_total: Union[float, str]
class TotalesSchema(BaseModel):
cantidad_total: Union[float, str]
bultos_total: int
clave_bultos: str = ""
peso_neto_total: Union[float, str]
peso_bruto_total: Union[float, str]
valor_total_total: Union[float, str]
valor_total_dolares: Union[float, str]
class FacturaImportacionCompleta(BaseModel):
cliente_proveedor: ClienteSchema
cliente_vendido: ClienteSchema
cliente_enviado: ClienteSchema
factura: FacturaSchema
partidas: List[PartidaSchema]
totales: TotalesSchema

View File

@@ -0,0 +1,296 @@
import shutil
import pdfkit
from pathlib import Path
from decimal import Decimal
from typing import Tuple, List
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
)
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 .schemas import (
ClienteSchema, PartidaSchema, TotalesSchema,
FacturaSchema, FacturaImportacionCompleta
)
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'])
)
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:
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 _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()
if not main:
return ClienteSchema(
header=rol, nombre="Desconocido", direccion="", codigo_postal="",
ciudad="", estado="", pais="", tax_id=""
)
# 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()
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 "",
autorizacion=prog.program_number if prog else ""
)
def obtener_datos(self, db: Session, invoice_id: int) -> FacturaImportacionCompleta:
try:
# 1. CABECERA (InvoiceHeader)
header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_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)
# --- PROVEEDOR (Extranjero) ---
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=""
)
# --- 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.
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 ""
)
cliente_enviado = cliente_vendido
# 5. MAPEO FACTURA
factura_schema = FacturaSchema(
numero=header.invoice_number,
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
incoterm=logistics.incoterm if logistics else "",
observaciones=header.observation_es or header.observation_en or "",
# Datos Pedimento
pedimento=pedimento.pedimento_number if pedimento else "",
clave_pedimento=pedimento.pedimento_code if pedimento else "",
regimen=pedimento.regime if pedimento else "",
# Datos Transporte
transportista="", # logistics.carrier_id (habría que hacer join con tabla carriers)
transporte=logistics.transport_type if logistics else "",
num_transporte=logistics.trailer_num if logistics else "",
placas=logistics.license_plate if logistics else "",
aduana=pedimento.customs_office if pedimento else "",
# Otros
remesa="", # No vi campo remesa en header
acuse_electronico="",
agente_aduanal="", # pedimento.license
patente=pedimento.license if pedimento else "",
precinto=logistics.seal_number if logistics else "",
destino=logistics.destination_goods if logistics else ""
)
# 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
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 ""
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)
))
# 7. 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,
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 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)
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(
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)
)
# --- GENERACIÓN ---
def generar_html(self, datos: FacturaImportacionCompleta) -> str:
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()
}
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)
nombre = f"Factura_{datos.factura.numero}.{formato}"
if formato == "html":
return html.encode('utf-8'), nombre, "text/html"
pdf = self.generar_pdf(html)
return pdf, nombre, "application/pdf"

View File

@@ -0,0 +1,50 @@
from enum import Enum
from fastapi import APIRouter, Depends, Query, Response, HTTPException
from sqlalchemy.orm import Session
from core.database import get_db
# Importamos el servicio mexicano
from .mex.service import FacturaImportacionMexService
router = APIRouter()
servicio_mex = FacturaImportacionMexService()
class TipoFactura(str, Enum):
mexicana = "mex"
# americana = "usa"
class Formato(str, Enum):
html = "html"
pdf = "pdf"
@router.get("/{invoice_id}/download")
async def descargar_factura(
invoice_id: int,
tipo: TipoFactura = Query(TipoFactura.mexicana),
formato: Formato = Query(Formato.pdf),
db: Session = Depends(get_db)
):
"""
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")
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)}")

View File

@@ -0,0 +1,367 @@
<!DOCTYPE html>
<html lang="es" xml:lang="es">
<head>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type" />
<title>Factura Importacion Mexicana - {{ 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">
<p class="titulo">Factura de Importacion</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">
<div class="position-relative-centered">
<div style="float: right; text-align: left;">
<h1 class="cliente">{{ cliente_proveedor.header }}</h1>
<p>{{ cliente_proveedor.nombre }}</p>
<p>{{ cliente_proveedor.direccion }}
{% if cliente_proveedor.num_exterior %} Ext: {{ cliente_proveedor.num_exterior }}{% endif %}
{% if cliente_proveedor.num_interior %} Int: {{ cliente_proveedor.num_interior }}{% endif %}
</p>
<p>{{ cliente_proveedor.colonia }} {% if cliente_proveedor.codigo_postal %} CP: {{ cliente_proveedor.codigo_postal }}{% endif %}</p>
<p>{{ cliente_proveedor.ciudad }}, {{ cliente_proveedor.estado }}, {{ cliente_proveedor.pais }}</p>
<p>TAX ID: {{ cliente_proveedor.tax_id }}
{% if cliente_proveedor.programa and cliente_proveedor.programa != 'Ninguno' %}
{{ cliente_proveedor.programa }}: {{ cliente_proveedor.autorizacion }}
{% endif %}
</p>
<p><br></p>
</div>
</div>
<div 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">FACTURA:</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">Fecha:</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">T. Cambio:</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" style="width:52pt">
<p class="small-bold p-l-3 line-9">Pedimento:</p>
</td>
<td class="border" colspan="1" style="width:109pt">
<p class="normal p-l-3 line-9">{{ factura.pedimento }}</p>
</td>
<td bgcolor="#E4E4E4" class="border">
<p class="normal p-l-3 line-9">Clave:</p>
</td>
<td class="border" style="width:37pt">
<p class="normal p-r-2 line-9 center">{{ factura.clave_pedimento }}</p>
</td>
</tr>
<tr class="h-11">
<td bgcolor="#E4E4E4" class="border" style="width:52pt">
<p class="normal p-l-3 line-9">Remesa:</p>
</td>
<td class="border" style="width:38pt">
<p class="normal p-l-10 line-9">{{ factura.remesa }}</p>
</td>
<td bgcolor="#E4E4E4" class="border" style="width:79pt">
<p class="normal p-l-6 line-9">Acuse:</p>
</td>
<td class="border" style="width:59pt">
<p class="normal p-l-6 line-9">{{ factura.acuse_electronico or 'N/A' }}</p>
</td>
</tr>
<tr class="h-22">
<td class="border" colspan="4" style="width:228pt">
<p class="tiny-bold p-l-3 line-7">Agente Aduanal:</p>
<p class="tiny p-l-3 line-7">{{ factura.agente_aduanal or '' }}</p>
</td>
</tr>
<tr class="h-15">
<td class="border" colspan="2">
<p><span class="tiny-bold p-l-3 line-7">Patente: </span><span class="tiny p-l-3 line-7">{{ factura.patente or '' }}</span></p>
</td>
<td class="border">
<span class="tiny-bold p-l-3 line-7">Regimen:</span><span class="tiny p-l-3 line-7">{{ factura.regimen or '' }}</span>
</td>
<td class="border">
<p class="tiny-bold p-l-3 line-7">INCOTERM:</p>
<p class="tiny p-l-3 line-7">{{ factura.incoterm or '' }}</p>
</td>
</tr>
<tr>
<td class="border" colspan="2">
{% if factura.precinto %}
<p class="tiny-bold p-l-3 line-7">Precinto: {{ factura.precinto }}</p>
{% endif %}
</td>
<td class="border">
<p class="tiny-bold p-l-3 line-7">Aduana: {{ factura.aduana }}</p>
</td>
<td class="border">
{% if factura.destino %}
<p class="tiny-bold p-l-3 line-7">Destino: {{ factura.destino }}</p>
{% endif %}
</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 %} CP: {{ cliente_vendido.codigo_postal }}{% endif %}</p>
<p class="p-l-5">{{ cliente_vendido.ciudad }}, {{ cliente_vendido.estado }}, {{ cliente_vendido.pais }}</p>
<p class="p-l-5">RFC: {{ cliente_vendido.tax_id }}
{% if cliente_vendido.programa and cliente_vendido.programa != 'Ninguno' %}
{{ cliente_vendido.programa }}: {{ cliente_vendido.autorizacion }}
{% endif %}
</p>
</div>
<div class="width-48-right" style="text-align: left;">
<h1 class="p-t-5 p-l-5 line-10 cliente full-width-block">{{ cliente_enviado.header }}</h1>
<p class="p-l-5 line-8">{{ cliente_enviado.nombre }}</p>
<p class="p-l-5">{{ cliente_enviado.direccion }}
{% if cliente_enviado.num_exterior %} Ext: {{ cliente_enviado.num_exterior }}{% endif %}
{% if cliente_enviado.num_interior %} Int: {{ cliente_enviado.num_interior }}{% endif %}
</p>
<p class="p-l-5">{{ cliente_enviado.colonia }} {% if cliente_enviado.codigo_postal %} CP: {{ cliente_enviado.codigo_postal }}{% endif %}</p>
<p class="p-l-5">{{ cliente_enviado.ciudad }}, {{ cliente_enviado.estado }}, {{ cliente_enviado.pais }}</p>
<p class="p-l-5">RFC: {{ cliente_enviado.tax_id }}</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">Transportista:</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">Aduana: <span class="mini">{{ factura.aduana }}</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">Transporte:</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">Placas: {{ factura.placas or 'N/A' }}</p>
</td>
</tr>
<tr class="h-10">
<td class="border" colspan="2" style="width:80pt"><p><br /></p></td>
<td class="border" colspan="8"><p><br /></p></td>
</tr>
<tr class="h-10">
<td class="border" rowspan="2">
<p class="tiny-bold p-t-5 center">Línea</p>
</td>
<td class="border" rowspan="2">
<p class="tiny-bold p-l-2 line-10">Número de Parte</p>
<p class="tiny-bold p-l-2 line-10">Descripción</p>
</td>
<td class="border" colspan="3">
<p class="tiny-bold center line-9">Comercial</p>
</td>
<td class="border" style="width:50pt">
<p class="tiny-bold center line-9">Empaque</p>
</td>
<td class="border" colspan="2">
<p class="tiny-bold center line-9">Peso (KGS)</p>
</td>
<td class="border" colspan="2">
<p class="tiny-bold line-9 center">Valores</p>
</td>
</tr>
<tr class="h-11">
<td class="border center" colspan="2">
<p class="mini p-l-2">Cantidad</p>
</td>
<td class="border center">
<p class="mini p-l-1">U.M.</p>
</td>
<td class="border">
<p class="mini center">Tipo</p>
</td>
<td class="border">
<p class="mini center">Neto</p>
</td>
<td class="border">
<p class="mini center">Bruto</p>
</td>
<td class="border">
<p class="mini center">Unitario</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">Frac: {{ partida.fraccion }} / Orig: {{ partida.origen or 'MEX' }}</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>Observaciones:</span>
<span class="small-bold" style="float: right; margin-right: 2pt;">TOTALES</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;">Los valores expresados en esta factura son en: {{ 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">Normal Por Parte</p>
<p class="normal p-l-5 line-9">Declaro bajo protesta de decir verdad que la información contenida en este documento es verdadera y me hago responsable de comprobar lo aquí declarado.</p>
</td>
</tr>
</tfoot>
</table>
</body>
</html>

View File

@@ -38,3 +38,6 @@ black==25.9.0
flake8==7.3.0
mypy==1.18.2
pylint==4.0.2
# reportes
Jinja2==3.1.6

View File

@@ -4,7 +4,7 @@
import { browser } from '$app/environment';
import { createColumns } from '$lib/components/dashboard/ports/columns';
import CreateEditDialog from '$lib/components/dashboard/ports/create-edit-dialog.svelte';
import DataTable from '$lib/components/dashboard/units_of_measure/ace/data-table.svelte';
import DataTable from '$lib/components/dashboard/general_catalogs/units_of_measure/ace/data-table.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Plus } from 'lucide-svelte';

View File

@@ -1,496 +1,543 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { invoicesApi, type Invoice, type OperationType } from '$lib/api/dashboard/a76/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';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
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 { onMount } from 'svelte';
import { page } from '$app/stores';
import { invoicesApi, type Invoice, type OperationType } from '$lib/api/dashboard/a76/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';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
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';
// IMPORTANTE: Asegúrate de tener instalada svelte-sonner para las notificaciones
import { toast } from "svelte-sonner";
// Los datos iniciales vienen del servidor
let { data }: { data: PageData } = $props();
// Los datos iniciales vienen del servidor
let { data }: { data: PageData } = $props();
// Estado para filtros
// Nota: Los query parameters invoice_type y operation_type se pueden usar para filtrar
// Ejemplo: /dashboard/invoices?invoice_type=TEM&operation_type=imp
let filters = $state({
operation_type: (data.filters?.operation_type || '') as '' | OperationType,
invoice_type: data.filters?.invoice_type || '',
invoice_number: data.filters?.invoice_number || '',
project_number: data.filters?.project_number || '',
year: data.filters?.year || ''
});
// Estado para filtros
// Nota: Los query parameters invoice_type y operation_type se pueden usar para filtrar
// Ejemplo: /dashboard/invoices?invoice_type=TEM&operation_type=imp
let filters = $state({
operation_type: (data.filters?.operation_type || '') as '' | OperationType,
invoice_type: data.filters?.invoice_type || '',
invoice_number: data.filters?.invoice_number || '',
project_number: data.filters?.project_number || '',
year: data.filters?.year || ''
});
// Efecto reactivo para actualizar filtros cuando cambian los query parameters en la URL
$effect(() => {
if (browser) {
const searchParams = $page.url.searchParams;
const urlOperationType = searchParams.get('operation_type');
const urlInvoiceType = searchParams.get('invoice_type');
const urlInvoiceNumber = searchParams.get('invoice_number');
const urlProjectNumber = searchParams.get('project_number');
const urlYear = searchParams.get('year');
// Efecto reactivo para actualizar filtros cuando cambian los query parameters en la URL
$effect(() => {
if (browser) {
const searchParams = $page.url.searchParams;
const urlOperationType = searchParams.get('operation_type');
const urlInvoiceType = searchParams.get('invoice_type');
const urlInvoiceNumber = searchParams.get('invoice_number');
const urlProjectNumber = searchParams.get('project_number');
const urlYear = searchParams.get('year');
// Actualizar filtros si hay cambios en la URL
filters.operation_type = (urlOperationType || '') as '' | OperationType;
filters.invoice_type = urlInvoiceType || '';
filters.invoice_number = urlInvoiceNumber || '';
filters.project_number = urlProjectNumber || '';
filters.year = urlYear || '';
}
});
// Actualizar filtros si hay cambios en la URL
filters.operation_type = (urlOperationType || '') as '' | OperationType;
filters.invoice_type = urlInvoiceType || '';
filters.invoice_number = urlInvoiceNumber || '';
filters.project_number = urlProjectNumber || '';
filters.year = urlYear || '';
}
});
// Efecto para limpiar invoice_type si no es válido para el operation_type seleccionado
$effect(() => {
if (filters.invoice_type && filters.operation_type) {
const selectedOption = allInvoiceTypeOptions().find(opt => opt.value === filters.invoice_type);
if (selectedOption && selectedOption.operation !== 'both' && selectedOption.operation !== filters.operation_type) {
// El tipo de factura seleccionado no es válido para esta operación
filters.invoice_type = '';
}
}
});
// Efecto para limpiar invoice_type si no es válido para el operation_type seleccionado
$effect(() => {
if (filters.invoice_type && filters.operation_type) {
const selectedOption = allInvoiceTypeOptions().find(opt => opt.value === filters.invoice_type);
if (selectedOption && selectedOption.operation !== 'both' && selectedOption.operation !== filters.operation_type) {
// El tipo de factura seleccionado no es válido para esta operación
filters.invoice_type = '';
}
}
});
// Efecto para actualizar la URL cuando cambien los filtros
$effect(() => {
if (browser) {
const params = new URLSearchParams();
if (filters.operation_type) params.set('operation_type', filters.operation_type);
if (filters.invoice_type) params.set('invoice_type', filters.invoice_type);
if (filters.invoice_number) params.set('invoice_number', filters.invoice_number);
if (filters.project_number) params.set('project_number', filters.project_number);
if (filters.year) params.set('year', filters.year);
const queryString = params.toString();
const newUrl = queryString ? `?${queryString}` : window.location.pathname;
// Solo actualizar si la URL es diferente (evitar loops infinitos)
if (window.location.search !== (queryString ? `?${queryString}` : '')) {
window.history.replaceState({}, '', newUrl);
}
}
});
// Efecto para actualizar la URL cuando cambien los filtros
$effect(() => {
if (browser) {
const params = new URLSearchParams();
if (filters.operation_type) params.set('operation_type', filters.operation_type);
if (filters.invoice_type) params.set('invoice_type', filters.invoice_type);
if (filters.invoice_number) params.set('invoice_number', filters.invoice_number);
if (filters.project_number) params.set('project_number', filters.project_number);
if (filters.year) params.set('year', filters.year);
const queryString = params.toString();
const newUrl = queryString ? `?${queryString}` : window.location.pathname;
// Solo actualizar si la URL es diferente (evitar loops infinitos)
if (window.location.search !== (queryString ? `?${queryString}` : '')) {
window.history.replaceState({}, '', newUrl);
}
}
});
// Efecto para aplicar filtros automáticamente cuando cambian
let filterTimeout: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
// Observar cambios en los filtros
const _ = {
operation_type: filters.operation_type,
invoice_type: filters.invoice_type,
invoice_number: filters.invoice_number,
project_number: filters.project_number,
year: filters.year
};
// Efecto para aplicar filtros automáticamente cuando cambian
let filterTimeout: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
// Observar cambios en los filtros
const _ = {
operation_type: filters.operation_type,
invoice_type: filters.invoice_type,
invoice_number: filters.invoice_number,
project_number: filters.project_number,
year: filters.year
};
// Debounce para evitar múltiples llamadas rápidas
if (filterTimeout) clearTimeout(filterTimeout);
filterTimeout = setTimeout(() => {
applyFilters();
}, 300); // Esperar 300ms después del último cambio
});
// Debounce para evitar múltiples llamadas rápidas
if (filterTimeout) clearTimeout(filterTimeout);
filterTimeout = setTimeout(() => {
applyFilters();
}, 300); // Esperar 300ms después del último cambio
});
// Sincronizar token de cookies a localStorage al montar el componente
onMount(() => {
if (browser) {
// Función para obtener el valor de una cookie
const getCookie = (name: string): string | null => {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift() || null;
return null;
};
// Sincronizar token de cookies a localStorage al montar el componente
onMount(() => {
if (browser) {
// Función para obtener el valor de una cookie
const getCookie = (name: string): string | null => {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift() || null;
return null;
};
// Verificar si hay token en las cookies
const cookieToken = getCookie('access_token');
const localToken = localStorage.getItem('access_token');
// Verificar si hay token en las cookies
const cookieToken = getCookie('access_token');
const localToken = localStorage.getItem('access_token');
if (cookieToken && cookieToken !== localToken) {
localStorage.setItem('access_token', cookieToken);
}
if (cookieToken && cookieToken !== localToken) {
localStorage.setItem('access_token', cookieToken);
}
// También sincronizar refresh_token si existe
const cookieRefreshToken = getCookie('refresh_token');
const localRefreshToken = localStorage.getItem('refresh_token');
// También sincronizar refresh_token si existe
const cookieRefreshToken = getCookie('refresh_token');
const localRefreshToken = localStorage.getItem('refresh_token');
if (cookieRefreshToken && cookieRefreshToken !== localRefreshToken) {
localStorage.setItem('refresh_token', cookieRefreshToken);
}
if (cookieRefreshToken && cookieRefreshToken !== localRefreshToken) {
localStorage.setItem('refresh_token', cookieRefreshToken);
}
// Escuchar cambios de compañía
const handleCompanyChange = (event: CustomEvent) => {
// Recargar los datos sin recargar la página completa
reloadData();
};
// Escuchar cambios de compañía
const handleCompanyChange = (event: CustomEvent) => {
// Recargar los datos sin recargar la página completa
reloadData();
};
window.addEventListener('companyChanged', handleCompanyChange as EventListener);
window.addEventListener('companyChanged', handleCompanyChange as EventListener);
// Cleanup
return () => {
window.removeEventListener('companyChanged', handleCompanyChange as EventListener);
};
}
});
// Cleanup
return () => {
window.removeEventListener('companyChanged', handleCompanyChange as EventListener);
};
}
});
// Estado para infinite scroll
let allItems = $state<Invoice[]>(data.items || []);
let currentPage = $state(data.page);
let pageSize = $state(50);
let totalItems = $state(data.total || 0);
let loading = $state(false);
let hasMore = $derived(allItems.length < totalItems);
let error = $state<string | null>(data.error || null);
// Estado para infinite scroll
let allItems = $state<Invoice[]>(data.items || []);
let currentPage = $state(data.page);
let pageSize = $state(50);
let totalItems = $state(data.total || 0);
let loading = $state(false);
let hasMore = $derived(allItems.length < totalItems);
let error = $state<string | null>(data.error || null);
async function loadMore() {
if (loading || !hasMore) return;
async function loadMore() {
if (loading || !hasMore) return;
loading = true;
error = null;
loading = true;
error = null;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
error = 'No hay compañía seleccionada';
return;
}
const filterParams = {
operation_type: filters.operation_type || undefined,
invoice_type: filters.invoice_type || undefined,
invoice_number: filters.invoice_number || undefined,
project_number: filters.project_number || undefined,
year: filters.year || undefined
};
const response = await invoicesApi.list(companyId, currentPage + 1, pageSize, filterParams);
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
error = 'No hay compañía seleccionada';
return;
}
const filterParams = {
operation_type: filters.operation_type || undefined,
invoice_type: filters.invoice_type || undefined,
invoice_number: filters.invoice_number || undefined,
project_number: filters.project_number || undefined,
year: filters.year || undefined
};
const response = await invoicesApi.list(companyId, currentPage + 1, pageSize, filterParams);
if (response.error) {
console.error('📊 [Invoices] Error en loadMore:', response.error, 'Status:', response.status);
if (response.status === 401 || response.status === 403) {
error = 'Sesión expirada. Recargando página...';
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
error = response.error;
}
return;
}
if (response.error) {
console.error('📊 [Invoices] Error en loadMore:', response.error, 'Status:', response.status);
if (response.status === 401 || response.status === 403) {
error = 'Sesión expirada. Recargando página...';
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
error = response.error;
}
return;
}
if (response.data?.items) {
// Agregar los nuevos items al array existente
allItems = [...allItems, ...response.data.items];
currentPage++;
totalItems = response.data.total;
}
} catch (e) {
error = 'Error cargando más datos';
console.error('📊 [Invoices] Error loading more:', e);
} finally {
loading = false;
}
}
if (response.data?.items) {
// Agregar los nuevos items al array existente
allItems = [...allItems, ...response.data.items];
currentPage++;
totalItems = response.data.total;
}
} catch (e) {
error = 'Error cargando más datos';
console.error('📊 [Invoices] Error loading more:', e);
} finally {
loading = false;
}
}
async function applyFilters() {
// Reset y recargar con filtros
loading = true;
error = null;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
error = 'No hay compañía seleccionada';
return;
}
// Construir query parameters para el endpoint
// Los filtros se mapean a los parámetros del API
const filterParams = {
operation_type: filters.operation_type || undefined,
invoice_type: filters.invoice_type || undefined,
invoice_number: filters.invoice_number || undefined,
project_number: filters.project_number || undefined,
year: filters.year || undefined
};
const response = await invoicesApi.list(companyId, 1, pageSize, filterParams);
async function applyFilters() {
// Reset y recargar con filtros
loading = true;
error = null;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
error = 'No hay compañía seleccionada';
return;
}
// Construir query parameters para el endpoint
// Los filtros se mapean a los parámetros del API
const filterParams = {
operation_type: filters.operation_type || undefined,
invoice_type: filters.invoice_type || undefined,
invoice_number: filters.invoice_number || undefined,
project_number: filters.project_number || undefined,
year: filters.year || undefined
};
const response = await invoicesApi.list(companyId, 1, pageSize, filterParams);
if (response.error) {
console.error('📊 [Invoices] Error aplicando filtros:', response.error);
if (response.status === 401 || response.status === 403) {
error = 'Sesión expirada. Recargando página...';
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
error = response.error;
}
return;
}
if (response.error) {
console.error('📊 [Invoices] Error aplicando filtros:', response.error);
if (response.status === 401 || response.status === 403) {
error = 'Sesión expirada. Recargando página...';
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
error = response.error;
}
return;
}
if (response.data) {
allItems = response.data.items;
currentPage = 1;
totalItems = response.data.total;
}
} catch (e) {
error = 'Error aplicando filtros';
console.error('📊 [Invoices] Error applying filters:', e);
} finally {
loading = false;
}
}
if (response.data) {
allItems = response.data.items;
currentPage = 1;
totalItems = response.data.total;
}
} catch (e) {
error = 'Error aplicando filtros';
console.error('📊 [Invoices] Error applying filters:', e);
} finally {
loading = false;
}
}
function clearFilters() {
filters = {
operation_type: '',
invoice_type: '',
invoice_number: '',
project_number: '',
year: ''
};
applyFilters();
}
function clearFilters() {
filters = {
operation_type: '',
invoice_type: '',
invoice_number: '',
project_number: '',
year: ''
};
applyFilters();
}
async function reloadData() {
// Reset y recargar desde el principio usando la API
if (!companyStore.activeCompany) return;
loading = true;
error = null;
async function reloadData() {
// Reset y recargar desde el principio usando la API
if (!companyStore.activeCompany) return;
loading = true;
error = null;
try {
const companyId = companyStore.activeCompany.id;
const filterParams = {
operation_type: filters.operation_type || undefined,
invoice_type: filters.invoice_type || undefined,
invoice_number: filters.invoice_number || undefined,
project_number: filters.project_number || undefined,
year: filters.year || undefined
};
try {
const companyId = companyStore.activeCompany.id;
const filterParams = {
operation_type: filters.operation_type || undefined,
invoice_type: filters.invoice_type || undefined,
invoice_number: filters.invoice_number || undefined,
project_number: filters.project_number || undefined,
year: filters.year || undefined
};
const response = await invoicesApi.list(companyId, 1, pageSize, filterParams);
const response = await invoicesApi.list(companyId, 1, pageSize, filterParams);
if (response.error) {
console.error('📊 [Invoices] Error en reloadData:', response.error);
if (response.status === 401 || response.status === 403) {
error = 'Sesión expirada. Recargando página...';
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
error = response.error;
}
return;
}
if (response.error) {
console.error('📊 [Invoices] Error en reloadData:', response.error);
if (response.status === 401 || response.status === 403) {
error = 'Sesión expirada. Recargando página...';
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
error = response.error;
}
return;
}
if (response.data?.items) {
// Reemplazar todos los items con los nuevos datos
allItems = response.data.items;
currentPage = 1;
totalItems = response.data.total;
}
} catch (e) {
error = 'Error recargando datos';
console.error('📊 [Invoices] Error reloading:', e);
} finally {
loading = false;
}
}
if (response.data?.items) {
// Reemplazar todos los items con los nuevos datos
allItems = response.data.items;
currentPage = 1;
totalItems = response.data.total;
}
} catch (e) {
error = 'Error recargando datos';
console.error('📊 [Invoices] Error reloading:', e);
} finally {
loading = false;
}
}
function handleCreateClick() {
// Leer los filtros actuales desde la URL (que ya se actualizó con el $effect)
const params = new URLSearchParams(window.location.search);
// Mapear operation_type de 'imp'/'exp' a números 1/2
const operationType = params.get('operation_type');
if (operationType) {
const operationTypeNumber = operationType === 'exp' ? 1 : 2;
params.set('operation_type', operationTypeNumber.toString());
}
const queryString = params.toString();
const url = queryString
? `/dashboard/invoices/edit/new?${queryString}`
: '/dashboard/invoices/edit/new';
window.location.href = url;
}
// --- 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');
function handleSuccess() {
// Recargar datos después de crear/editar/eliminar
reloadData();
}
// 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}`
}
});
// Opciones de tipo de operación para el filtro
const operationTypeOptions = [
{ value: "", label: "Todas" },
{ value: 'imp', label: 'Importación' },
{ value: 'exp', label: 'Exportación' }
];
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
throw new Error(errData.detail || 'Error al generar el reporte');
}
// Todas las opciones de tipo de factura con su operación correspondiente
// Ahora se cargan desde el servidor en lugar de estar hardcodeadas
const allInvoiceTypeOptions = $derived(() => {
const options = [{ value: "", label: "Todas", operation: "both" }];
// Agregar los tipos de factura del servidor
if (data.invoiceTypes) {
data.invoiceTypes.forEach((type: any) => {
options.push({
value: type.key,
label: type.description,
operation: type.operation
});
});
}
return options;
});
// Convertir respuesta a Blob
const blob = await response.blob();
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;
document.body.appendChild(a);
a.click();
// Limpieza
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success("Factura descargada", { id: toastId });
// Opciones de tipo de factura filtradas según el tipo de operación seleccionado
const invoiceTypeOptions = $derived(() => {
const allOptions = allInvoiceTypeOptions();
if (!filters.operation_type) {
return allOptions;
}
return allOptions.filter(option =>
option.operation === 'both' ||
option.operation === filters.operation_type
);
});
} catch (error: any) {
console.error(error);
toast.error(error.message || "No se pudo descargar la factura", { id: toastId });
}
}
// Crear columnas con el callback onSuccess
const columns = createColumns(handleSuccess);
function handleCreateClick() {
const params = new URLSearchParams(window.location.search);
const operationType = params.get('operation_type');
if (operationType) {
const operationTypeNumber = operationType === 'exp' ? 1 : 2;
params.set('operation_type', operationTypeNumber.toString());
}
const queryString = params.toString();
const url = queryString
? `/dashboard/invoices/edit/new?${queryString}`
: '/dashboard/invoices/edit/new';
window.location.href = url;
}
function handleSuccess() {
reloadData();
}
// Opciones de tipo de operación para el filtro
const operationTypeOptions = [
{ value: "", label: "Todas" },
{ value: 'imp', label: 'Importación' },
{ value: 'exp', label: 'Exportación' }
];
// Todas las opciones de tipo de factura con su operación correspondiente
const allInvoiceTypeOptions = $derived(() => {
const options = [{ value: "", label: "Todas", operation: "both" }];
if (data.invoiceTypes) {
data.invoiceTypes.forEach((type: any) => {
options.push({
value: type.key,
label: type.description,
operation: type.operation
});
});
}
return options;
});
const invoiceTypeOptions = $derived(() => {
const allOptions = allInvoiceTypeOptions();
if (!filters.operation_type) {
return allOptions;
}
return allOptions.filter(option =>
option.operation === 'both' ||
option.operation === filters.operation_type
);
});
// --- AQUÍ PASAMOS LA FUNCIÓN DE DESCARGA A LAS COLUMNAS ---
const columns = createColumns(handleSuccess, handleDownloadPdf);
</script>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Facturas</h1>
<p class="text-muted-foreground">
Gestiona las facturas del sistema
</p>
</div>
<Button onclick={handleCreateClick}>
<Plus class="mr-2" size={16} />
Nueva Factura
</Button>
</div>
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Facturas</h1>
<p class="text-muted-foreground">
Gestiona las facturas del sistema
</p>
</div>
<Button onclick={handleCreateClick}>
<Plus class="mr-2" size={16} />
Nueva Factura
</Button>
</div>
<!-- Filtros -->
<Card.Root>
<Card.Header>
<Card.Title>Filtros</Card.Title>
<Card.Description>Filtra las facturas por diferentes criterios (los filtros se aplican automáticamente)</Card.Description>
</Card.Header>
<Card.Content>
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
<div class="space-y-2">
<Label for="filter-operation-type">Tipo de Operación</Label>
<select
id="filter-operation-type"
bind:value={filters.operation_type}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{#each operationTypeOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<Card.Root>
<Card.Header>
<Card.Title>Filtros</Card.Title>
<Card.Description>Filtra las facturas por diferentes criterios (los filtros se aplican automáticamente)</Card.Description>
</Card.Header>
<Card.Content>
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
<div class="space-y-2">
<Label for="filter-operation-type">Tipo de Operación</Label>
<select
id="filter-operation-type"
bind:value={filters.operation_type}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{#each operationTypeOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="filter-invoice-type">Tipo de Factura</Label>
<select
id="filter-invoice-type"
bind:value={filters.invoice_type}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{#each invoiceTypeOptions() as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="filter-invoice-type">Tipo de Factura</Label>
<select
id="filter-invoice-type"
bind:value={filters.invoice_type}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{#each invoiceTypeOptions() as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="filter-invoice-number">Número de Factura</Label>
<Input
id="filter-invoice-number"
bind:value={filters.invoice_number}
placeholder="Ej: INV-2024-001"
/>
</div>
<div class="space-y-2">
<Label for="filter-invoice-number">Número de Factura</Label>
<Input
id="filter-invoice-number"
bind:value={filters.invoice_number}
placeholder="Ej: INV-2024-001"
/>
</div>
<div class="space-y-2">
<Label for="filter-project-number">Número de Proyecto</Label>
<Input
id="filter-project-number"
bind:value={filters.project_number}
placeholder="Ej: PROJ-001"
/>
</div>
<div class="space-y-2">
<Label for="filter-project-number">Número de Proyecto</Label>
<Input
id="filter-project-number"
bind:value={filters.project_number}
placeholder="Ej: PROJ-001"
/>
</div>
<div class="space-y-2">
<Label for="filter-year">Año</Label>
<Input
id="filter-year"
bind:value={filters.year}
placeholder="Ej: 2024"
maxlength={4}
/>
</div>
</div>
</Card.Content>
</Card.Root>
<div class="space-y-2">
<Label for="filter-year">Año</Label>
<Input
id="filter-year"
bind:value={filters.year}
placeholder="Ej: 2024"
maxlength={4}
/>
</div>
</div>
</Card.Content>
</Card.Root>
<!-- Error Message -->
{#if error}
<Card.Root class="border-destructive">
<Card.Header>
<Card.Title class="text-destructive">Error</Card.Title>
<Card.Description>{error}</Card.Description>
</Card.Header>
</Card.Root>
{/if}
{#if error}
<Card.Root class="border-destructive">
<Card.Header>
<Card.Title class="text-destructive">Error</Card.Title>
<Card.Description>{error}</Card.Description>
</Card.Header>
</Card.Root>
{/if}
<!-- Data Table -->
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Listado de Facturas</Card.Title>
<Card.Description>
Mostrando {allItems.length} de {totalItems} registros
</Card.Description>
</div>
<Button variant="outline" onclick={reloadData}>
<RefreshCw class="mr-2" size={16} />
Actualizar
</Button>
</div>
</Card.Header>
<Card.Content>
<!-- TanStack DataTable con Infinite Scroll -->
<DataTable
data={allItems}
{columns}
{loading}
{hasMore}
{loadMore}
/>
</Card.Content>
</Card.Root>
</div>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Listado de Facturas</Card.Title>
<Card.Description>
Mostrando {allItems.length} de {totalItems} registros
</Card.Description>
</div>
<Button variant="outline" onclick={reloadData}>
<RefreshCw class="mr-2" size={16} />
Actualizar
</Button>
</div>
</Card.Header>
<Card.Content>
<DataTable
data={allItems}
{columns}
{loading}
{hasMore}
{loadMore}
/>
</Card.Content>
</Card.Root>
</div>