Files
mini-erp/mini-backend/routers/reports.py
T
GeorgeandClaude Sonnet 5 fef21898f9 Backport de mejoras genéricas de backend (desde cliente/tagumedica) + limpieza de datos de cliente en main
Mejoras genéricas traídas:
- core/crud_router.py: el PATCH genérico reenvía cualquier header B1S-* recibido del caller en vez de decidirlo el backend.
- routers/catalogs.py: nuevo endpoint /api/Catalogs/UdfValidValues (valores válidos de cualquier UDF vía Service Layer).
- routers/batch.py: /api/batch/stock (stock de un lote por almacén) y /api/batch/lot-source-warehouse (resuelve contra HANA de qué almacén salió un lote, repartiendo por origen).
- routers/inventory_transfer_requests.py + core/inventory_transfer_requests.py: CRUD genérico de Solicitudes de Transferencia de Inventario.
- routers/series.py + core/series.py: series activas de un tipo de documento.
- routers/user_tables.py: catálogo Code/Name genérico de tablas de usuario (mapeo USER_TABLES vacío, cada cliente completa el suyo).
- routers/reports.py / templates/reports/stickers.html: fix de márgenes/alto de página del reporte de etiquetas.
- mini-backend/test/ -> mini-backend/tools/ (rename organizativo).

Limpieza de datos de Tagumedica que ya estaban en el commit base de main (anteriores a la branch cliente/tagumedica, no relacionados con el backport):
- tools/sap-tagumedica/sl.http: eliminado (tenía CompanyDB y password reales).
- tools/ApiGateway/Login.yml: CompanyDB/host/password reemplazados por placeholders.
- templates/reports/stickers.html: título hardcodeado "TAGUMEDICA" reemplazado por "EMPRESA".
- menu_permissions.json: reemplazado por un ejemplo demo (usuarios/módulos ficticios) en vez de emails reales.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 14:18:42 -05:00

174 lines
7.2 KiB
Python

import logging
from io import BytesIO
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, HTTPException
from fastapi.responses import HTMLResponse, StreamingResponse
from jinja2 import Environment, FileSystemLoader
from pydantic import BaseModel
from weasyprint import HTML
from hdbcli import dbapi
from core.hana import hana_connection, HANA_SCHEMA
router = APIRouter(
tags=["Reportes (Jinja/HTML)"]
)
TEMPLATES_REPORTS_DIR = Path(__file__).resolve().parent.parent / "templates" / "reports"
templates_env = Environment(loader=FileSystemLoader("templates/reports"))
logger = logging.getLogger(__name__)
# Tamaño de papel que usa cada reporte al renderizar el PDF (CSS @page.size).
PAPER_SIZES = {"A4": "A4", "Carta": "letter"}
# Alto útil de la página en orientación horizontal (tamaño de papel menos el
# 1.6cm de margen superior + 1cm de margen inferior definidos en el @page de
# stickers.html).
PAGINA_ALTO_LANDSCAPE = {"A4": "18.4cm", "Carta": "18.99cm"}
# Query original de Crystal Reports para DLN20015, adaptada: el placeholder
# de Crystal `{?Dockey@}` se reemplaza por un bind param `?` de hdbcli, y cada
# tabla queda calificada con el schema de la compañía SAP B1 (HANA_SCHEMA).
STICKERS_QUERY = f"""
SELECT TOP 1
T0."DocEntry",
T0."DocNum",
CASE
WHEN IFNULL(T0."NumAtCard",'') <> '' THEN T0."NumAtCard"
ELSE T0."FolioPref"||'-'||T0."FolioNum"
END AS "GUIA_REMISION",
T0."U_SIS_ORDCOM" AS "ORDEN_COMPRA",
CASE
WHEN T0."Indicator" = 'AN' THEN 'ANULADO'
END AS "ESTADO",
T0."CardCode",
T0."CardName",
T4."Phone1" AS "TELEFONO",
T7."StreetS" AS "DIRECCION",
T7."CountyS" AS "PROVINCIA",
T14."Name" AS "DEPARTAMENTO",
T7."ZipCodeS" AS "DISTRITO",
T6."IndDesc" AS "GRUPO_CLIENTE",
CASE
WHEN T4."GroupCode" = '100' THEN 'VENTA LOCAL'
WHEN T4."GroupCode" = '102' THEN 'VENTA EXTERIOR'
END AS "TIPO_VENTA",
T0."DocDate" AS "FECHA",
T1."ItemCode",
T2."ItemName",
T3."Name" AS "LINEA",
T10."BatchNum" AS "LOTE",
T1."unitMsr" AS "UNIDADES",
T10."Quantity" AS "CANTIDAD",
T0."Indicator" AS "DOCUMENTO",
T0."U_SIS_FECENT" AS "FECHA_ENTREGA",
T8."SlpName" AS "REPRESENTANTE",
UPPER(T9."U_NAME") AS "ASISTENTE_VENTA",
T0."Comments" AS "COMENTARIOS",
CASE T0."U_SIS_TIPDESP"
WHEN 'N' THEN 'LOCAL'
WHEN 'A' THEN 'PROVINCIA'
WHEN 'U' THEN 'LOCAL - URGENTE'
WHEN 'O' THEN 'OFICINA'
WHEN 'V' THEN '-'
WHEN 'J' THEN 'PROVINCIA -URGENTE'
WHEN 'R' THEN 'REGULARIZACION'
ELSE 'LOCAL'
END AS "TIPO_DESPACHO",
T11."ExpDate" AS "FECHA VENCIMIENTO",
CASE T0."U_SIS_HORREC"
WHEN '100' THEN 'SIN HORARIO'
WHEN '101' THEN '08 AM - 10 AM'
WHEN '102' THEN '10 AM - 01 PM'
WHEN '103' THEN '01 PM - 03 PM'
WHEN '104' THEN '03 PM - 06 PM'
ELSE 'SIN HORARIO'
END AS "HORARIO RECOJO",
T11."LotNumber" AS "UBICACION",
T1."WhsCode" AS "ALMACEN",
T1."VisOrder",
T0.U_EXX_NOMTRANS AS "TRANSPORTISTA",
ROW_NUMBER() OVER (PARTITION BY T1."VisOrder" ORDER BY T1."VisOrder" DESC) AS row_num
FROM "{HANA_SCHEMA}".ODLN T0
INNER JOIN "{HANA_SCHEMA}".DLN1 T1 ON T0."DocEntry" = T1."DocEntry"
INNER JOIN "{HANA_SCHEMA}".OITM T2 ON T1."ItemCode" = T2."ItemCode"
LEFT JOIN "{HANA_SCHEMA}"."@SIS_LINART" T3 ON T2.U_SIS_LINART = T3."Code"
INNER JOIN "{HANA_SCHEMA}".OCRD T4 ON T0."CardCode" = T4."CardCode"
INNER JOIN "{HANA_SCHEMA}".NNM1 T5 ON T0."Series"=T5."Series"
LEFT JOIN "{HANA_SCHEMA}".OOND T6 ON T4."IndustryC" = T6."IndCode"
LEFT JOIN "{HANA_SCHEMA}".DLN12 T7 ON T0."DocEntry" = T7."DocEntry"
LEFT JOIN "{HANA_SCHEMA}".OCST T14 ON T14."Code" = T7."StateS" AND T14."Country" = T7."CountryS"
LEFT JOIN "{HANA_SCHEMA}".OSLP T8 ON T0."SlpCode" = T8."SlpCode"
LEFT JOIN "{HANA_SCHEMA}".OUSR T9 ON T0."UserSign" = T9."USERID"
LEFT JOIN "{HANA_SCHEMA}".IBT1 T10 ON T1."ItemCode"=T10."ItemCode" AND T0."DocEntry"=T10."BaseEntry" AND T1."WhsCode"=T10."WhsCode" AND T0."ObjType"=T10."BaseType" AND T0."DocNum"=T10."BaseNum" AND T1."LineNum" = T10."BaseLinNum" AND T10."Direction"='1'
LEFT JOIN "{HANA_SCHEMA}".OBTN T11 ON T11."ItemCode" = T1."ItemCode" AND T10."BatchNum" = T11."DistNumber"
WHERE T0."DocEntry" = ?
"""
# ============================================================================
class StickersParams(BaseModel):
doc_entry: int
cantidad: int
formato: Literal["pdf", "html"] = "pdf"
papel: Literal["A4", "Carta"] = "Carta"
# ============================================================================
@router.post("/api/reports/stickers")
async def generar_stickers(params: StickersParams):
log_extra = {"http_method": "POST", "endpoint": "reports/stickers"}
logger.info(f"Generando reporte de stickers para DocEntry {params.doc_entry}", extra=log_extra)
try:
with hana_connection() as conn:
cursor = conn.cursor()
cursor.execute(STICKERS_QUERY, (params.doc_entry,))
row = cursor.fetchone()
columns = [d[0] for d in cursor.description]
except dbapi.Error as exc:
logger.error(f"Error al consultar la guía de remisión {params.doc_entry}: {exc}", extra=log_extra)
raise HTTPException(status_code=500, detail=f"Error al consultar el reporte: {exc}")
if not row:
logger.warning(f"DocEntry {params.doc_entry} no encontrado", extra=log_extra)
raise HTTPException(status_code=404, detail=f"No se encontró el documento {params.doc_entry}")
fila = dict(zip(columns, row))
lugar = " - ".join(
p for p in (fila.get("DEPARTAMENTO"), fila.get("PROVINCIA"), fila.get("DISTRITO")) if p
)
datos = {
"cliente": fila.get("CardName"),
"direccion": fila.get("DIRECCION"),
"lugar": lugar,
"documento": fila.get("GUIA_REMISION"),
}
stickers = [{"numero": i, "total": params.cantidad} for i in range(1, params.cantidad + 1)]
paginas = [stickers[i:i + 4] for i in range(0, len(stickers), 4)]
template = templates_env.get_template("stickers.html")
html_renderizado = template.render(
datos=datos,
paginas=paginas,
paper_size=PAPER_SIZES[params.papel],
pagina_alto=PAGINA_ALTO_LANDSCAPE[params.papel],
)
if params.formato == "html":
logger.info(f"Reporte de stickers generado en HTML para DocEntry {params.doc_entry}", extra={**log_extra, "status_code": 200})
return HTMLResponse(content=html_renderizado)
pdf_bytes = HTML(string=html_renderizado, base_url=str(TEMPLATES_REPORTS_DIR)).write_pdf()
logger.info(f"Reporte de stickers generado en PDF para DocEntry {params.doc_entry}", extra={**log_extra, "status_code": 200})
return StreamingResponse(
BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="stickers_{params.doc_entry}.pdf"'},
)