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>
174 lines
7.2 KiB
Python
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"'},
|
|
)
|