diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/schemas.py b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/schemas.py index a7e765e1..e2f73cc3 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/schemas.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/schemas.py @@ -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 diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py index c188fe09..8f02cbd1 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py @@ -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" \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py b/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py index 0638ea41..0049204e 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py @@ -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)}") \ No newline at end of file + 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") \ No newline at end of file diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 95f5791a..4faae183 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -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"] +) \ No newline at end of file diff --git a/backend/api/v1/router.py b/backend/api/v1/router.py index 1a4a8c47..d9b4d517 100644 --- a/backend/api/v1/router.py +++ b/backend/api/v1/router.py @@ -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"} + + diff --git a/backend/requirements.txt b/backend/requirements.txt index d33317be..8ed4b755 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -41,3 +41,4 @@ pylint==4.0.2 # reportes Jinja2==3.1.6 +pdfkit==1.0.0 diff --git a/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts b/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts new file mode 100644 index 00000000..68ebafab --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts @@ -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(); + }, +}; \ No newline at end of file diff --git a/frontend/src/lib/components/dashboard/invoices/columns.ts b/frontend/src/lib/components/dashboard/invoices/columns.ts index c087fae8..46802478 100644 --- a/frontend/src/lib/components/dashboard/invoices/columns.ts +++ b/frontend/src/lib/components/dashboard/invoices/columns.ts @@ -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[] { - return [ - { - accessorKey: "id", - header: "ID", - cell: ({ row }) => { - const idSnippet = createRawSnippet<[{ id: number }]>((getId) => { - const { id } = getId(); - return { - render: () => - `
#${id}
` - }; - }); - 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: () => - ` - ${label} - ` - }; - }); - return renderSnippet(typeSnippet, { label, colorClass }); - } - }, - { - accessorKey: "invoice_type", - header: "Tipo", - cell: ({ row }) => { - const typeSnippet = createRawSnippet<[{ type?: string | null }]>((getType) => { - const { type } = getType(); - return { - render: () => - `
${type || '-'}
` - }; - }); - 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: () => - `${number || 'N/A'}` - }; - }); - 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: () => - `
${project || '-'}
` - }; - }); - 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: () => - `
${pedimento || '-'}
` - }; - }); - return renderSnippet(pedimentoSnippet, { pedimento }); - } - }, - { - accessorKey: "financials.value_mn", - header: () => { - const headerSnippet = createRawSnippet(() => { - return { - render: () => `
Valor MN
` - }; - }); - return renderSnippet(headerSnippet, {}); - }, - cell: ({ row }) => { - const valueMN = row.original.financials?.value_mn; - - const valueSnippet = createRawSnippet<[{ value: string }]>((getValue) => { - const { value } = getValue(); - return { - render: () => - `
${value}
` - }; - }); - return renderSnippet(valueSnippet, { value: formatCurrencyMXN(valueMN) }); - } - }, - { - accessorKey: "financials.value_me", - header: () => { - const headerSnippet = createRawSnippet(() => { - return { - render: () => `
Valor ME
` - }; - }); - return renderSnippet(headerSnippet, {}); - }, - cell: ({ row }) => { - const valueME = row.original.financials?.value_me; - - const valueSnippet = createRawSnippet<[{ value: string }]>((getValue) => { - const { value } = getValue(); - return { - render: () => - `
${value}
` - }; - }); - 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: () => - ` - ${status || '-'} - ` - }; - }); - return renderSnippet(statusSnippet, { status, colorClass }); - } - }, - { - accessorKey: "invoice_date", - header: "Fecha Factura", - cell: ({ row }) => { - const dateSnippet = createRawSnippet<[{ date: string }]>((getDate) => { - const { date } = getDate(); - return { - render: () => - `
${date}
` - }; - }); - 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[] { + return [ + { + accessorKey: "id", + header: "ID", + cell: ({ row }) => { + const idSnippet = createRawSnippet<[{ id: number }]>((getId) => { + const { id } = getId(); + return { + render: () => + `
#${id}
` + }; + }); + 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: () => + ` + ${label} + ` + }; + }); + return renderSnippet(typeSnippet, { label, colorClass }); + } + }, + { + accessorKey: "invoice_type", + header: "Tipo", + cell: ({ row }) => { + const typeSnippet = createRawSnippet<[{ type?: string | null }]>((getType) => { + const { type } = getType(); + return { + render: () => + `
${type || '-'}
` + }; + }); + 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: () => + `${number || 'N/A'}` + }; + }); + 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: () => + `
${project || '-'}
` + }; + }); + 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: () => + `
${pedimento || '-'}
` + }; + }); + return renderSnippet(pedimentoSnippet, { pedimento }); + } + }, + { + accessorKey: "financials.value_mn", + header: () => { + const headerSnippet = createRawSnippet(() => { + return { + render: () => `
Valor MN
` + }; + }); + return renderSnippet(headerSnippet, {}); + }, + cell: ({ row }) => { + const valueMN = row.original.financials?.value_mn; + + const valueSnippet = createRawSnippet<[{ value: string }]>((getValue) => { + const { value } = getValue(); + return { + render: () => + `
${value}
` + }; + }); + return renderSnippet(valueSnippet, { value: formatCurrencyMXN(valueMN) }); + } + }, + { + accessorKey: "financials.value_me", + header: () => { + const headerSnippet = createRawSnippet(() => { + return { + render: () => `
Valor ME
` + }; + }); + return renderSnippet(headerSnippet, {}); + }, + cell: ({ row }) => { + const valueME = row.original.financials?.value_me; + + const valueSnippet = createRawSnippet<[{ value: string }]>((getValue) => { + const { value } = getValue(); + return { + render: () => + `
${value}
` + }; + }); + 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: () => + ` + ${status || '-'} + ` + }; + }); + return renderSnippet(statusSnippet, { status, colorClass }); + } + }, + { + accessorKey: "invoice_date", + header: "Fecha Factura", + cell: ({ row }) => { + const dateSnippet = createRawSnippet<[{ date: string }]>((getDate) => { + const { date } = getDate(); + return { + render: () => + `
${date}
` + }; + }); + 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(); \ No newline at end of file diff --git a/frontend/src/lib/components/dashboard/invoices/data-table-actions.svelte b/frontend/src/lib/components/dashboard/invoices/data-table-actions.svelte index bbb7a09f..3cd08d74 100644 --- a/frontend/src/lib/components/dashboard/invoices/data-table-actions.svelte +++ b/frontend/src/lib/components/dashboard/invoices/data-table-actions.svelte @@ -1,71 +1,80 @@ - - {#snippet child({ props })} - - {/snippet} - - - Acciones - - - showDetails = true}> - - Ver Detalles - + + {#snippet child({ props })} + + {/snippet} + + + Acciones + + + showDetails = true}> + + Ver Detalles + - - - Editar - + {#if onDownload} + onDownload(invoice)}> + + Descargar PDF + + {/if} - - showDelete = true} class="text-destructive"> - - Eliminar - - + + + Editar + + + + showDelete = true} class="text-destructive"> + + Eliminar + + - {#if showDetails} - showDetails = false} - /> + showDetails = false} + /> {/if} {#if showDelete} - showDelete = false} - {onSuccess} - /> + showDelete = false} + {onSuccess} + /> {/if} \ No newline at end of file diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index 6fd7fc9d..dc8f2d53 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -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 }); } }