Initial commit: mini-erp (backend FastAPI + frontend React/Vite)

Base para el flujo de ramas por cliente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5 committed 2026-08-17 10:34:12 -05:00
commit ab3c613165
154 files changed
+16872

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
.env
.env.aberio
backend-env/
__pycache__/
*.pyc
logs/
test/
docs/
.DS_Store
+48
View File
@@ -0,0 +1,48 @@
FROM python:3.12-slim
# Zona horaria del contenedor. Por defecto Perú (America/Lima), a juego con
# los datos de SAP que usa esta app (Country "PE", SUNAT, soles); cambiar el
# build arg si se despliega para un cliente en otro país de Latinoamérica.
ARG TZ=America/Lima
ENV TZ=${TZ}
RUN apt-get update \
&& apt-get install -y --no-install-recommends tzdata \
&& ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \
&& echo ${TZ} > /etc/timezone \
&& rm -rf /var/lib/apt/lists/*
# Librerías nativas que necesita WeasyPrint (routers/reports.py) para
# renderizar HTML/CSS a PDF: no vienen incluidas en python:3.12-slim.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libpangoft2-1.0-0 \
libcairo2 \
libgdk-pixbuf-2.0-0 \
shared-mime-info \
fonts-liberation \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Certificado extra de la empresa (VPN, proxy o antivirus corporativo que
# intercepta HTTPS, ej. Zscaler/Netskope). Si mini-backend/certs/ tiene un
# archivo .pem/.crt, pip confía en él para poder salir a internet durante el
# build. Si la carpeta está vacía (caso normal), este paso no hace nada.
COPY certs/ /usr/local/share/extra-ca/
COPY requirements.txt .
RUN CERT_FILE=$(find /usr/local/share/extra-ca -maxdepth 1 -type f \( -name "*.pem" -o -name "*.crt" -o -name "*.cer" \) 2>/dev/null | head -n1); \
if [ -n "$CERT_FILE" ]; then export PIP_CERT="$CERT_FILE"; fi; \
pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
# Un solo worker (por diseño, no un límite temporal): la sesión de SAP y las
# sesiones de empleado viven en diccionarios en memoria del proceso
# (core/sap.py, core/session.py). Con --workers > 1 cada proceso tendría su
# propia copia y los usuarios verían "sesión expirada" de forma intermitente
# según a qué worker los enrutara Docker/el proxy.
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+40
View File
@@ -0,0 +1,40 @@
from contextvars import ContextVar
# Variable de contexto única por cada Request de FastAPI
request_id_ctx: ContextVar[str] = ContextVar("request_id", default="-")
# Variable de contexto para el empleado conectado que originó la petición
current_user_ctx: ContextVar[dict] = ContextVar(
"current_user", default={"webUser": "-", "emp_id": "-", "full_name": "-"}
)
# ============================================================================
def get_request_id() -> str:
"""
Obtiene el ID de la petición del contexto actual.
"""
return request_id_ctx.get()
# ============================================================================
def set_request_id(request_id: str):
"""
Establece el ID de la petición en el contexto actual.
"""
request_id_ctx.set(request_id)
# ============================================================================
def get_current_user() -> dict:
"""
Obtiene el empleado conectado del contexto actual.
"""
return current_user_ctx.get()
# ============================================================================
def set_current_user(webUser: str, full_name: str = "", emp_id="-"):
"""
Establece el empleado conectado en el contexto actual.
"""
current_user_ctx.set({"webUser": webUser, "emp_id": emp_id, "full_name": full_name})
# ============================================================================
View File
Whitespace-only changes.
+130
View File
@@ -0,0 +1,130 @@
from dotenv import load_dotenv
import logging
import requests
from requests import Session
from typing import Optional, Dict, Any
import os
from fastapi import HTTPException
import urllib3
load_dotenv(dotenv_path=".env")
APIGATEWAY_URL = os.getenv("APIGATEWAY_URL", "")
APIGATEWAY_USER = os.getenv("APIGATEWAY_USER", "")
APIGATEWAY_PASSWORD = os.getenv("APIGATEWAY_PASSWORD", "")
APIGATEWAY_COMPANYDB = os.getenv("APIGATEWAY_COMPANYDB", "")
APIGATEWAY_DBINSTANCE = os.getenv("APIGATEWAY_DBINSTANCE", "")
APIGATEWAY_TIMEOUT = float(os.getenv("APIGATEWAY_TIMEOUT", 30.0))
logger = logging.getLogger(__name__)
api_gateway_session: Optional[Session] = None
def ensure_api_gateway_session(force_refresh: bool = False) -> Session:
global api_gateway_session
if api_gateway_session and not force_refresh:
return api_gateway_session
logger.info("Iniciando sesión en APIGateway...")
login_url = f"{APIGATEWAY_URL}/login"
payload = {
"CompanyDB": APIGATEWAY_COMPANYDB,
"UserName": APIGATEWAY_USER,
"Password": APIGATEWAY_PASSWORD,
"DBInstance": APIGATEWAY_DBINSTANCE,
}
try:
session = Session()
session.verify = False
response = session.post(
login_url,
json=payload,
timeout=APIGATEWAY_TIMEOUT,
)
if response.status_code != 200:
logger.error(f"Error login APIGateway. Status: {response.status_code}")
raise HTTPException(
status_code=response.status_code,
detail="Error de autenticación con APIGateway"
)
api_gateway_session = session
logger.info(f"Sesión APIGateway establecida. Cookies: {dict(session.cookies)}")
return session
except requests.RequestException as exc:
logger.error(f"Error de conexión con APIGateway: {exc}")
api_gateway_session = None
raise HTTPException(
status_code=500,
detail=f"No se pudo conectar con APIGateway: {exc}"
)
def forward_to_api_gateway(
method: str,
endpoint: str,
params: Optional[Dict[str, Any]] = None,
json_data: Optional[Any] = None,
) -> requests.Response:
if not method or not endpoint:
raise ValueError("method y endpoint son requeridos")
method = method.upper()
session = ensure_api_gateway_session()
url_destino = f"{APIGATEWAY_URL}/{endpoint}"
log_extra = {"http_method": method, "endpoint": endpoint}
logger.info(f"[APIGateway] [{method}] {endpoint}", extra=log_extra)
try:
response = session.request(
method=method,
url=url_destino,
params=params,
json=json_data,
timeout=APIGATEWAY_TIMEOUT,
)
except requests.exceptions.Timeout:
logger.error(f"Timeout ({APIGATEWAY_TIMEOUT}s) en APIGateway {endpoint}", extra=log_extra)
raise ConnectionError(f"Timeout al conectar con APIGateway: {endpoint}")
except requests.RequestException as exc:
logger.error(f"Error en solicitud APIGateway: {exc}", extra=log_extra)
raise ConnectionError(f"Error en solicitud APIGateway: {exc}")
if response.status_code == 401:
logger.warning("Sesión APIGateway expirada (401). Reautenticando...", extra=log_extra)
session = ensure_api_gateway_session(force_refresh=True)
try:
response = session.request(
method=method,
url=url_destino,
params=params,
json=json_data,
timeout=APIGATEWAY_TIMEOUT,
)
except requests.RequestException as exc:
raise ConnectionError(f"Error en reintento APIGateway: {exc}")
if response.status_code >= 400:
error_msg = response.text[:500]
logger.error(
f"[{response.status_code}] APIGateway {endpoint}: {error_msg}",
extra={**log_extra, "status_code": response.status_code}
)
raise HTTPException(
status_code=response.status_code,
detail=f"Error en APIGateway: {error_msg}"
)
logger.info(
f"[{response.status_code}] APIGateway {endpoint}",
extra={**log_extra, "status_code": response.status_code}
)
return response
+123
View File
@@ -0,0 +1,123 @@
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Request
from core.sap import forward_to_sapb1
def _build_sap_params(
skip: int,
top: int,
fields: Optional[str],
filter: Optional[str],
orderby: Optional[str] = None,
) -> Dict[str, Any]:
sap_params: Dict[str, Any] = {
"$skip": skip,
"$top": top,
"$inlinecount": "allpages",
}
if fields:
sap_params["$select"] = fields.replace(" ", "")
if filter:
sap_params["$filter"] = filter
if orderby:
sap_params["$orderby"] = orderby
return sap_params
def _build_list_headers(top: int, filter: Optional[str]) -> Dict[str, str]:
headers = {"B1S-PageSize": str(top)}
if filter:
headers["B1S-CaseInsensitive"] = "true"
return headers
def make_crud_router(
resource: str,
*,
tags: List[str],
key_field: Optional[str] = None,
key_quoted: bool = False,
supports_orderby: bool = False,
supports_get_by_key: bool = True,
supports_create: bool = True,
supports_patch: bool = True,
patch_headers: Optional[Dict[str, str]] = None,
) -> APIRouter:
"""
Genera el router list/get/create/update repetido antes en cada módulo de
routers/ (mismo bloque de $skip/$top/$filter y de forward_to_sapb1, solo
cambiaba el nombre del recurso OData). Centralizarlo evita que un
copy-paste entre recursos deje, por ejemplo, un PATCH apuntando al
endpoint_path de otro recurso.
"""
router = APIRouter(tags=tags)
def key_expr(key: str) -> str:
return f"{resource}('{key}')" if key_quoted else f"{resource}({key})"
if supports_orderby:
@router.get(f"/api/{resource}", name=f"list_{resource}")
async def list_resource(
skip: int = 0,
top: int = 50,
fields: Optional[str] = None,
filter: Optional[str] = None,
orderby: Optional[str] = None,
):
sap_params = _build_sap_params(skip, top, fields, filter, orderby)
return forward_to_sapb1(
method="GET",
endpoint_path=resource,
params=sap_params,
extra_headers=_build_list_headers(top, filter),
).json()
else:
@router.get(f"/api/{resource}", name=f"list_{resource}")
async def list_resource(
skip: int = 0,
top: int = 50,
fields: Optional[str] = None,
filter: Optional[str] = None,
):
sap_params = _build_sap_params(skip, top, fields, filter)
return forward_to_sapb1(
method="GET",
endpoint_path=resource,
params=sap_params,
extra_headers=_build_list_headers(top, filter),
).json()
if supports_get_by_key:
@router.get(f"/api/{resource}/{{key}}", name=f"get_{resource}_by_key")
async def get_by_key(key: str):
return forward_to_sapb1(
method="GET",
endpoint_path=key_expr(key),
).json()
if supports_create:
@router.post(f"/api/{resource}", status_code=201, name=f"create_{resource}")
async def create_resource(request: Request):
data = await request.json()
resp = forward_to_sapb1(
method="POST",
endpoint_path=resource,
json_data=data,
)
created = resp.json()
return {key_field: created[key_field]}
if supports_patch:
@router.patch(f"/api/{resource}/{{key}}", status_code=204, name=f"update_{resource}")
async def update_resource(key: str, request: Request):
data = await request.json()
forward_to_sapb1(
method="PATCH",
endpoint_path=key_expr(key),
json_data=data,
extra_headers=patch_headers,
)
return router
+44
View File
@@ -0,0 +1,44 @@
import os
import logging
from contextlib import contextmanager
from typing import Iterator
from dotenv import load_dotenv
from fastapi import HTTPException
from hdbcli import dbapi
# ============================================================================
load_dotenv(dotenv_path=".env")
HANA_HOST = os.getenv("HANA_HOST", "")
HANA_PORT = int(os.getenv("HANA_PORT", 30015))
HANA_USER = os.getenv("HANA_USER", "")
HANA_PASSWORD = os.getenv("HANA_PASSWORD", "")
HANA_SCHEMA = os.getenv("HANA_SCHEMA", "")
logger = logging.getLogger(__name__)
# ============================================================================
@contextmanager
def hana_connection() -> Iterator[dbapi.Connection]:
"""
Abre una conexión a SAP HANA para un query puntual (reportes Jinja/HTML)
y la cierra al salir del bloque `with`.
"""
try:
conn = dbapi.connect(
address=HANA_HOST,
port=HANA_PORT,
user=HANA_USER,
password=HANA_PASSWORD,
currentSchema=HANA_SCHEMA,
)
except Exception as exc:
logger.error(f"Error al conectar con HANA: {exc}")
raise HTTPException(status_code=500, detail=f"No se pudo conectar con la base de datos: {exc}")
try:
yield conn
finally:
conn.close()
# ============================================================================
+200
View File
@@ -0,0 +1,200 @@
from dotenv import load_dotenv
import logging
import requests
from typing import Optional, Dict, Any
import os
from fastapi import FastAPI, HTTPException, Request
import json
import urllib3
# ============================================================================
# CONFIGURACIÓN
# ============================================================================
# Cargar variables de entorno desde .env
load_dotenv(dotenv_path=".env")
SAPB1_SL_URL = os.getenv("SAPB1_SL_URL", "")
SAPB1_SL_USER = os.getenv("SAPB1_SL_USER", "")
SAPB1_SL_PASSWORD = os.getenv("SAPB1_SL_PASSWORD", "")
SAPB1_SL_COMPANYDB = os.getenv("SAPB1_SL_COMPANYDB", "")
# Tiempo de espera y verificación SSL
SAPB1_SL_TIMEOUT = float(os.getenv("SAPB1_SL_TIMEOUT", 15.0))
SAPB1_SL_VERIFY_SSL = os.getenv("SAPB1_SL_VERIFY_SSL", "False").lower() == "true"
# ============================================================================
# Obtiene el logger específico de este archivo
logger = logging.getLogger(__name__)
# ============================================================================
# 1. Diccionario global para persistir las cabeceras de autenticación de SAP
sap_session_store: dict[str, Optional[dict[str, str]]] = {
"headers": None
}
# ============================================================================
def _log_response(
method: str,
endpoint: str,
response: requests.Response,
request_data: Optional[Dict] = None
) -> None:
"""Registra detalles de la respuesta"""
extra = {"http_method": method, "endpoint": endpoint, "status_code": response.status_code}
if response.status_code >= 400:
try:
error_detail = response.json()
respuesta = json.dumps(error_detail, indent=2)[:500]
except Exception:
respuesta = response.text[:500]
logger.error(
f"[{response.status_code}] {method} {endpoint} - "
f"Datos enviados: {json.dumps(request_data, indent=2, default=str)[:500]} - "
f"Respuesta: {respuesta}",
extra=extra
)
else:
logger.info(f"[{response.status_code}] {method} {endpoint}", extra=extra)
# ============================================================================
def ensure_sap_session(force_refresh: bool = False) -> dict:
"""
Verifica si existe una sesión global de SAP B1 (B1SESSION).
Si no existe o si force_refresh es True, realiza el Login con la información del .env.
"""
if sap_session_store["headers"] and not force_refresh:
return sap_session_store["headers"]
logger.info("Iniciando sesión global en SAP como Manager...")
auth_url = f"{SAPB1_SL_URL}/Login"
sap_auth_payload = {
"CompanyDB": SAPB1_SL_COMPANYDB,
"UserName": SAPB1_SL_USER,
"Password": SAPB1_SL_PASSWORD
}
try:
response = requests.post(
auth_url,
json=sap_auth_payload,
timeout=SAPB1_SL_TIMEOUT,
verify=SAPB1_SL_VERIFY_SSL
)
if response.status_code != 200:
logger.error(f"Error crítico: No se pudo conectar el usuario Manager a SAP. Status: {response.status_code}")
sap_session_store["headers"] = None
raise HTTPException(status_code=response.status_code, detail="Conexión fallida con el servidor SAP")
sap_session = response.json()
b1_session_id = sap_session.get("SessionId")
route_id = response.cookies.get("ROUTEID")
headers = {
"Cookie": f"B1SESSION={b1_session_id}; ROUTEID={route_id}"
}
sap_session_store["headers"] = headers
logger.info(f"Sesión global establecida. B1SESSION: {b1_session_id[:10]}...")
return headers
except requests.RequestException as exc:
logger.error(f"Error de conexión al autenticar contra SAP: {exc}")
sap_session_store["headers"] = None
raise HTTPException(status_code=500, detail=f"Error al conectar con SAP Service Layer: {exc}")
# ============================================================================
# FUNCIÓN PROXY CENTRALIZADA
# ============================================================================
def forward_to_sapb1(
method: str,
endpoint_path: str,
json_data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
extra_headers: Optional[Dict[str, str]] = None
) -> requests.Response:
"""
Función proxy centralizada para todas las llamadas a SAP B1 Service Layer.
Maneja autenticación transparentemente con sesión única y reintento.
"""
if not method or not endpoint_path:
raise ValueError("method y endpoint_path son requeridos")
method = method.upper()
# Obtener cabeceras con B1SESSION
headers = ensure_sap_session()
# Preparar URL
url_destino = f"{SAPB1_SL_URL}/{endpoint_path}"
# Preparar cabeceras de la petición
req_headers = {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
req_headers.update(headers)
if extra_headers:
req_headers.update(extra_headers)
log_extra = {"http_method": method, "endpoint": endpoint_path}
logger.info(f"[{method}] {endpoint_path}", extra=log_extra)
# Realizar solicitud
try:
response = requests.request(
method=method,
url=url_destino,
json=json_data,
params=params,
headers=req_headers,
timeout=SAPB1_SL_TIMEOUT,
verify=SAPB1_SL_VERIFY_SSL
)
except requests.exceptions.Timeout:
logger.error(f"Timeout ({SAPB1_SL_TIMEOUT}s) en {endpoint_path}", extra=log_extra)
raise ConnectionError(f"Timeout al conectar con {endpoint_path}")
except requests.exceptions.ConnectionError as exc:
logger.error(f"Error de conexión: {exc}", extra=log_extra)
raise ConnectionError(f"No se pudo conectar con SAP B1: {exc}")
except requests.RequestException as exc:
logger.error(f"Error en solicitud: {exc}", extra=log_extra)
raise ConnectionError(f"Error en solicitud HTTP: {exc}")
# Manejo de 401 - Sesión expirada: reintentar UNA sola vez
if response.status_code == 401:
logger.warning("Sesión expirada (401). Reautenticando e intentando una sola vez...", extra=log_extra)
try:
# Forzar actualización de sesión
headers = ensure_sap_session(force_refresh=True)
req_headers.update(headers)
logger.info("Reintentando solicitud [RETRY]", extra=log_extra)
response = requests.request(
method=method,
url=url_destino,
json=json_data,
params=params,
headers=req_headers,
timeout=SAPB1_SL_TIMEOUT,
verify=SAPB1_SL_VERIFY_SSL
)
except Exception as exc:
logger.error(f"Error en reintento: {exc}", extra=log_extra)
raise ConnectionError(f"Error en reintento tras reautenticación: {exc}")
# Logging de respuesta
_log_response(method, endpoint_path, response, json_data)
# Extraer error SAP y propagar al frontend
if response.status_code >= 400:
error_msg = response.text
try:
err = response.json()
error_msg = err.get("error", {}).get("message", {}).get("value", error_msg)
except Exception:
pass
raise HTTPException(status_code=response.status_code, detail=error_msg)
return response
# ============================================================================
+74
View File
@@ -0,0 +1,74 @@
import secrets
import logging
from datetime import datetime, timedelta
# ============================================================================
SESSION_TIMEOUT_MINUTES = 30
employee_sessions: dict[str, dict] = {}
active_tokens: dict[str, str] = {}
logger = logging.getLogger(__name__)
# ============================================================================
def create_employee_session(webUser: str, emp_id, first_name: str, last_name: str) -> str:
for token, wu in list(active_tokens.items()):
if wu == webUser:
del active_tokens[token]
logger.info(f"Sesión anterior removida para el usuario {webUser} (nuevo login)")
break
token = secrets.token_urlsafe(32)
active_tokens[token] = webUser
employee_sessions[webUser] = {
"last_activity": datetime.now(),
"emp_id": emp_id,
"first_name": first_name,
"last_name": last_name
}
logger.info(f"Sesión creada para el usuario {webUser} - {first_name} {last_name}")
return token
# ============================================================================
def validate_and_refresh_session(token: str) -> str | None:
webUser = active_tokens.get(token)
if not webUser:
return None
session = employee_sessions.get(webUser)
if not session:
active_tokens.pop(token, None)
return None
now = datetime.now()
if now - session["last_activity"] > timedelta(minutes=SESSION_TIMEOUT_MINUTES):
logger.info(f"Sesión expirada para el usuario {webUser} (inactivo > {SESSION_TIMEOUT_MINUTES} min)")
active_tokens.pop(token, None)
employee_sessions.pop(webUser, None)
return None
session["last_activity"] = now
return webUser
# ============================================================================
def remove_employee_session_by_token(token: str):
webUser = active_tokens.pop(token, None)
if webUser:
employee_sessions.pop(webUser, None)
logger.info(f"Sesión cerrada para el usuario {webUser}")
# ============================================================================
def get_employee_name(webUser: str) -> str:
session = employee_sessions.get(webUser)
if not session:
return "-"
return f"{session.get('first_name', '')} {session.get('last_name', '')}".strip()
# ============================================================================
def get_emp_id(webUser: str):
session = employee_sessions.get(webUser)
if not session:
return "-"
return session.get("emp_id", "-")
# ============================================================================
+526
View File
@@ -0,0 +1,526 @@
# API Reference
> **Base URL:** `http://<host>:8000`
> **API Version:** 1.1.0
> **Content-Type:** `application/json`
---
## Headers Globales
| Header | Requerido | Descripción |
|--------|-----------|-------------|
| `Content-Type` | Sí | `application/json` |
| `X-Session-Token` | Sí (excepto `/api/Login`) | Token obtenido en el login |
| `X-Request-ID` | Opcional | UUID para trazabilidad; si no se envía, el servidor lo genera |
---
## Formato de Errores
Todas las respuestas de error siguen el mismo formato:
```json
{
"detail": "Mensaje de error de SAP o del servidor"
}
```
| Código | Significado |
|--------|-------------|
| 400 | Error de negocio SAP (ej: código duplicado, validación fallida) |
| 401 | Token inválido/expirado, o credenciales incorrectas |
| 404 | Recurso no encontrado en SAP |
| 500 | Error de conexión con SAP o error interno |
---
## Autenticación
### `POST /api/Login`
Inicia sesión de empleado. Valida contra SAP EmployeesInfo usando el apellido como contraseña.
**Request body:**
```json
{
"Username": "123",
"Password": "Garcia"
}
```
**Response 200:**
```json
{
"message": "Login exitoso",
"token": "aB3x...token_urlsafe_32_chars...",
"username": "Juan"
}
```
**Response 401:**
```json
{
"detail": "Usuario o contraseña incorrectos"
}
```
---
### `POST /api/Logout`
Cierra la sesión del empleado.
**Headers:** `X-Session-Token: <token>`
**Response 200:**
```json
{
"message": "Logout exitoso"
}
```
---
## Socios de Negocio
### `GET /api/BusinessPartners`
Lista paginada de socios de negocio.
**Query parameters:**
| Parámetro | Tipo | Default | Descripción |
|-----------|------|---------|-------------|
| `skip` | int | 0 | Registros a saltar (`$skip`) |
| `top` | int | 50 | Registros por página (`$top`) |
| `fields` | string | — | Campos a retornar, separados por coma (`$select`) |
| `filter` | string | — | Filtro OData (`$filter`). Activa `B1S-CaseInsensitive` |
**Response 200:**
```json
{
"odata.metadata": "https://.../$metadata#BusinessPartners",
"value": [
{
"CardCode": "C001",
"CardName": "Cliente Demo",
"CardType": "C"
}
],
"odata.count": 150
}
```
---
### `GET /api/BusinessPartners/{bp_code}`
Obtiene un socio de negocio por `CardCode`.
**Response 200:**
```json
{
"CardCode": "C001",
"CardName": "Cliente Demo",
"CardType": "C",
"Phone1": "123456789",
"EmailAddress": "cliente@demo.com"
}
```
---
### `POST /api/BusinessPartners`
Crea un nuevo socio de negocio.
**Request body:**
```json
{
"CardCode": "C001",
"CardName": "Cliente Demo",
"CardType": "C"
}
```
**Response 201:**
```json
{
"CardCode": "C001"
}
```
---
### `PATCH /api/BusinessPartners/{bp_code}`
Actualiza un socio de negocio existente.
**Request body:**
```json
{
"CardName": "Nuevo Nombre",
"Phone1": "987654321"
}
```
**Response 204:** Sin contenido.
---
## Artículos
### `GET /api/Items`
Lista paginada de artículos.
**Query parameters:**
| Parámetro | Tipo | Default | Descripción |
|-----------|------|---------|-------------|
| `skip` | int | 0 | Registros a saltar |
| `top` | int | 50 | Registros por página |
| `fields` | string | — | Campos a retornar (`$select`) |
| `filter` | string | — | Filtro OData (`$filter`) |
**Response 200:**
```json
{
"odata.metadata": "https://.../$metadata#Items",
"value": [
{
"ItemCode": "A001",
"ItemName": "Artículo Demo",
"InventoryUOM": "UN"
}
],
"odata.count": 250
}
```
---
### `GET /api/Items/{item_code}`
Obtiene un artículo por `ItemCode`.
**Response 200:**
```json
{
"ItemCode": "A001",
"ItemName": "Artículo Demo",
"InventoryUOM": "UN",
"SalesPrice": 100.50
}
```
---
### `POST /api/Items`
Crea un nuevo artículo.
**Request body:**
```json
{
"ItemCode": "A001",
"ItemName": "Artículo Demo",
"InventoryUOM": "UN"
}
```
**Response 201:**
```json
{
"ItemCode": "A001"
}
```
---
### `PATCH /api/Items/{item_code}`
Actualiza un artículo existente.
**Request body:**
```json
{
"ItemName": "Nuevo Nombre",
"SalesPrice": 120.00
}
```
**Response 204:** Sin contenido.
---
## Ventas
### Facturas (A/R Invoices)
#### `GET /api/Invoices`
Lista paginada de facturas de venta.
**Query parameters:**
| Parámetro | Tipo | Default | Descripción |
|-----------|------|---------|-------------|
| `skip` | int | 0 | Registros a saltar |
| `top` | int | 50 | Registros por página |
| `fields` | string | — | Campos a retornar |
| `filter` | string | — | Filtro OData |
| `orderby` | string | — | Ordenamiento (`$orderby`) |
**Response 200:**
```json
{
"odata.metadata": "https://.../$metadata#Invoices",
"value": [
{
"DocEntry": 1001,
"CardCode": "C001",
"DocTotal": 1500.00,
"DocDate": "2026-07-06"
}
],
"odata.count": 45
}
```
---
#### `POST /api/Invoices`
Crea una factura de venta.
**Response 201:**
```json
{
"DocEntry": 1001
}
```
---
#### `GET /api/Invoices/{invoice_key}`
Obtiene una factura por `DocEntry`.
---
#### `PATCH /api/Invoices/{invoice_key}`
Actualiza una factura. Usa header `B1S-ReplaceCollectionsOnPatch: true`.
**Response 204:** Sin contenido.
---
### Pedidos (Sales Orders)
#### `GET /api/Orders`
Lista paginada de pedidos de venta. Mismos parámetros que Invoices.
#### `POST /api/Orders`
Crea un pedido de venta.
**Response 201:**
```json
{
"DocEntry": 2001
}
```
#### `GET /api/Orders/{order_key}`
Obtiene un pedido por `DocEntry`.
#### `PATCH /api/Orders/{order_key}`
Actualiza un pedido. Usa header `B1S-ReplaceCollectionsOnPatch: true`.
**Response 204:** Sin contenido.
---
## Compras
### Órdenes de Compra
#### `GET /api/PurchaseOrders`
Lista paginada. Mismos parámetros que Invoices/Orders.
#### `GET /api/PurchaseOrders/{key}`
Obtiene una orden de compra por `DocEntry`.
#### `PATCH /api/PurchaseOrders/{key}`
Actualiza una orden de compra. Usa header `B1S-ReplaceCollectionsOnPatch: true`.
**Response 204:** Sin contenido.
---
### Facturas de Compra (A/P Invoices)
#### `GET /api/PurchaseInvoices`
Lista paginada. Mismos parámetros que Invoices/Orders.
#### `GET /api/PurchaseInvoices/{key}`
Obtiene una factura de compra por `DocEntry`.
#### `PATCH /api/PurchaseInvoices/{key}`
Actualiza una factura de compra. Usa header `B1S-ReplaceCollectionsOnPatch: true`.
**Response 204:** Sin contenido.
---
## Catálogos
### `GET /api/Currencies`
Lista de monedas.
**Response 200:**
```json
{
"value": [
{ "Code": "USD", "Name": "US Dollar" },
{ "Code": "EUR", "Name": "Euro" }
]
}
```
---
### `GET /api/BusinessPartnerGroups`
Lista de grupos de socios de negocio.
**Response 200:**
```json
{
"value": [
{ "Number": 1, "GroupName": "Clientes Nacionales" }
]
}
```
---
### `GET /api/Warehouses`
Lista paginada de almacenes. Acepta `skip`, `top`, `fields`, `filter`.
---
### `GET /api/ItemGroups`
Lista de grupos de artículos.
**Response 200:**
```json
{
"value": [
{ "Number": 1, "GroupName": "Materia Prima" }
]
}
```
---
### `GET /api/SalesTaxCodes`
Lista paginada de códigos de IVA. Acepta `skip`, `top`, `fields`, `filter`.
---
## Lotes
### `GET /api/BatchNumberDetails`
Lista paginada de lotes/números de serie. Acepta `skip`, `top`, `fields`, `filter`, `orderby`.
---
### `GET /api/BatchNumberDetails/{batch_id}`
Obtiene un lote por ID.
---
### `PATCH /api/BatchNumberDetails/{batch_id}`
Actualiza un lote.
**Response 204:** Sin contenido.
---
## Permisos
### `GET /api/user/menu`
Retorna las rutas de menú permitidas para el empleado autenticado.
**Headers:** `X-Session-Token: <token>`
**Response 200:**
```json
{
"allowedPaths": [
"/inventario/*",
"/ventas/*",
"/compras/*"
]
}
```
**Response 401:**
```json
{
"detail": "Usuario no autenticado"
}
```
---
## Resumen de Códigos de Respuesta
| Método | Éxito | Cuerpo en éxito | Error SAP | Error de conexión |
|--------|-------|----------------|-----------|-------------------|
| GET | 200 | JSON del recurso | 4xx con `detail` | 500 |
| POST | 201 | `{"CardCode"/"ItemCode"/"DocEntry": valor}` | 4xx con `detail` | 500 |
| PATCH | 204 | Sin cuerpo | 4xx con `detail` | 500 |
+183
View File
@@ -0,0 +1,183 @@
# Arquitectura General
## Diagrama de Componentes
```mermaid
graph TB
subgraph "FastAPI Application"
M[main.py\nEntry Point]
C[context.py\nRequest ID Context]
L[logger_config.py\nStructured Logging]
subgraph "Core"
SAP[core/sap.py\nSAP Session & Proxy]
SES[core/session.py\nEmployee Sessions]
end
subgraph "Routers"
A[auth.py\nLogin / Logout]
BP[business_partners.py\nSocios de Negocio]
IT[items.py\nArtículos]
SL[sales.py\nFacturas / Pedidos]
PR[purchases.py\nÓrdenes / Facturas Compra]
CT[catalogs.py\nCatálogos]
BT[batch.py\nLotes]
PM[permissions.py\nPermisos de Menú]
end
MP[menu_permissions.json]
end
subgraph "External"
SAP_SL[SAP B1 Service Layer]
end
M --> C
M --> L
C --> L
M --> A
M --> BP
M --> IT
M --> SL
M --> PR
M --> CT
M --> BT
M --> PM
A --> SAP
A --> SES
BP --> SAP
IT --> SAP
SL --> SAP
PR --> SAP
CT --> SAP
BT --> SAP
PM --> MP
SAP --> SAP_SL
SES --> SAP
```
## Pipeline de Middleware
Las peticiones atraviesan los siguientes middleware en orden:
```mermaid
flowchart LR
REQ[Request] --> CORS[CORSMiddleware\nallow_origins=*]
CORS --> CID[correlation_id_middleware\nX-Request-ID]
CID --> SES_MW[session_middleware\nValida X-Session-Token]
SES_MW --> ROUTER{Router}
ROUTER -->|/api/Login| A[auth.py]
ROUTER -->|Otros /api/*| SAP_CORE[core/sap.py]
```
### Detalle de Middleware
| Middleware | Función |
| -------------------------- | ----------------------------------------------------------- |
| `CORSMiddleware` | Permite peticiones desde cualquier origen |
| `correlation_id_middleware` | Genera o extrae `X-Request-ID` para trazabilidad en logs |
| `session_middleware` | Valida token de sesión, adjunta `emp_id` a `request.state` |
## Arquitectura de Sesiones
Existen **dos capas de sesión independientes**:
```mermaid
flowchart LR
subgraph Employee["Employee Session"]
T["Token aleatorio → emp_id"]
E["employee_sessions dict"]
TK["active_tokens dict"]
T --> TK
TK --> E
end
subgraph SAP["SAP B1 Session"]
S["sap_session_store"]
M["Manager Credentials (.env)"]
S -.->|Renueva en 401| M
end
```
| Capa | Almacenamiento | Ámbito | Timeout | Renovación |
| ------------- | ----------------- | -------- | ------------- | -------------------- |
| SAP B1 | `sap_session_store` | Global | Gestionado SAP | Automática al recibir 401 |
| Empleado | `active_tokens` | Por empleado | 30 min inactividad | Login manual |
## Estructura de Rutas
| Método | Ruta | Router | Descripción |
| -------- | --------------------------------- | -------------------- | ------------------------ |
| `POST` | `/api/Login` | `auth.py` | Autenticar empleado |
| `POST` | `/api/Logout` | `auth.py` | Cerrar sesión |
| `GET` | `/api/BusinessPartners` | `business_partners` | Listar socios |
| `GET` | `/api/BusinessPartners/{code}` | `business_partners` | Obtener socio |
| `POST` | `/api/BusinessPartners` | `business_partners` | Crear socio |
| `PATCH` | `/api/BusinessPartners/{code}` | `business_partners` | Actualizar socio |
| `GET` | `/api/Items` | `items.py` | Listar artículos |
| `GET` | `/api/Items/{code}` | `items.py` | Obtener artículo |
| `POST` | `/api/Items` | `items.py` | Crear artículo |
| `PATCH` | `/api/Items/{code}` | `items.py` | Actualizar artículo |
| `GET` | `/api/Invoices` | `sales.py` | Listar facturas |
| `GET` | `/api/Invoices/{key}` | `sales.py` | Obtener factura |
| `POST` | `/api/Invoices` | `sales.py` | Crear factura |
| `PATCH` | `/api/Invoices/{key}` | `sales.py` | Actualizar factura |
| `GET` | `/api/Orders` | `sales.py` | Listar pedidos |
| `GET` | `/api/Orders/{key}` | `sales.py` | Obtener pedido |
| `POST` | `/api/Orders` | `sales.py` | Crear pedido |
| `PATCH` | `/api/Orders/{key}` | `sales.py` | Actualizar pedido |
| `GET` | `/api/PurchaseOrders` | `purchases.py` | Listar órdenes de compra |
| `GET` | `/api/PurchaseOrders/{key}` | `purchases.py` | Obtener orden de compra |
| `PATCH` | `/api/PurchaseOrders/{key}` | `purchases.py` | Actualizar orden compra |
| `GET` | `/api/PurchaseInvoices` | `purchases.py` | Listar facturas compra |
| `GET` | `/api/PurchaseInvoices/{key}` | `purchases.py` | Obtener factura compra |
| `PATCH` | `/api/PurchaseInvoices/{key}` | `purchases.py` | Actualizar factura compra|
| `GET` | `/api/Currencies` | `catalogs.py` | Listar monedas |
| `GET` | `/api/BusinessPartnerGroups` | `catalogs.py` | Listar grupos de socios |
| `GET` | `/api/Warehouses` | `catalogs.py` | Listar almacenes |
| `GET` | `/api/ItemGroups` | `catalogs.py` | Listar grupos artículos |
| `GET` | `/api/SalesTaxCodes` | `catalogs.py` | Listar códigos de IVA |
| `GET` | `/api/BatchNumberDetails` | `batch.py` | Listar lotes |
| `GET` | `/api/BatchNumberDetails/{id}` | `batch.py` | Obtener lote |
| `PATCH` | `/api/BatchNumberDetails/{id}` | `batch.py` | Actualizar lote |
| `GET` | `/api/user/menu` | `permissions.py` | Obtener menú permitido |
## Dependencias del Proyecto
```mermaid
graph TD
main --> context
main --> logger_config
logger_config --> context
main --> auth
main --> business_partners
main --> items
main --> sales
main --> purchases
main --> catalogs
main --> batch
main --> permissions
auth --> sap_core
auth --> session_core
business_partners --> sap_core
items --> sap_core
sales --> sap_core
purchases --> sap_core
catalogs --> sap_core
batch --> sap_core
permissions --> menu_json[menu_permissions.json]
```
## Configuración por Entorno
La aplicación carga siempre `.env` (un solo archivo, sin sufijos de entorno).
`.env.aberio` se conserva como plantilla de referencia; para desplegar contra
ese entorno, copiar su contenido dentro de `.env`.
| Archivo | URL SAP | Company DB |
| ------------------ | ------------------------------- | ----------------------- |
| `.env` | `192.168.1.175:50000` | `SBO_PRUEBA_TAGUM_22` |
| `.env.aberio` | `192.168.9.3:50000` | `PRUEBA_20260609` |
+108
View File
@@ -0,0 +1,108 @@
# Flujo de Autenticación
## Login
```mermaid
sequenceDiagram
participant F as Frontend
participant API as FastAPI
participant S as core/session.py
participant SAP_C as core/sap.py
participant SAP as SAP Service Layer
F->>API: POST /api/Login\n{Username, Password}
Note over F,API: Username = emp_id\nPassword = LastName
API->>SAP_C: forward_to_sapb1(GET, "EmployeesInfo({empId})")
SAP_C->>SAP: GET /b1s/v1/EmployeesInfo({empId})
SAP-->>SAP_C: {FirstName, LastName, ...}
SAP_C-->>API: Employee data
API->>API: ¿Password == LastName?
alt Coincide
API->>S: create_employee_session(emp_id, first_name, last_name)
S->>S: Genera token_urlsafe(32)
S-->>API: token
API->>SAP_C: ensure_sap_session()
SAP_C->>SAP: POST /Login (manager creds)
SAP-->>SAP_C: B1SESSION cookie
SAP_C-->>API: OK
API-->>F: 200 {message, token, username}
else No coincide
API-->>F: 401 Invalid credentials
end
```
## Logout
```mermaid
sequenceDiagram
participant F as Frontend
participant API as FastAPI
participant S as core/session.py
F->>API: POST /api/Logout\nX-Session-Token: <token>
API->>S: remove_employee_session_by_token(token)
S->>S: Elimina active_tokens[token]\ny employee_sessions[emp_id]
S-->>API: OK
API-->>F: 200 {message: "Sesión cerrada"}
```
## Validación de Sesión (Middleware)
Cada petición a `/api/*` (excepto `/api/Login`) pasa por el `session_middleware`:
```mermaid
sequenceDiagram
participant F as Frontend
participant MW as session_middleware
participant S as core/session.py
participant R as Router
F->>MW: Request /api/Items\nX-Session-Token: <token>
MW->>S: validate_and_refresh_session(token)
S->>S: Busca token en active_tokens
alt Token válido
S->>S: Actualiza last_activity
S-->>MW: emp_id
MW->>MW: Adjunta emp_id a request.state
MW->>R: Continúa al router
R-->>F: 200 Response
else Token expirado (>30 min)
S->>S: Elimina token y sesión
S-->>MW: None
MW-->>F: 401 Token expirado
else Token no existe
S-->>MW: None
MW-->>F: 401 No autorizado
end
```
## Ciclo de Vida de la Sesión de Empleado
```mermaid
stateDiagram-v2
[*] --> Activa: Login exitoso\ngenera token
Activa --> Expirada: 30 min sin actividad
Activa --> Cerrada: Logout explícito
Expirada --> [*]
Cerrada --> [*]
note right of Activa
Se refresca last_activity
en cada petición válida
end note
```
## Mecanismo de Renovación
La sesión del empleado se renueva automáticamente en **cada petición válida**:
1. El frontend envía `X-Session-Token` en el header
2. `validate_and_refresh_session()` actualiza `last_activity` al timestamp actual
3. El timeout de 30 minutos se mide desde la última actividad
4. Si pasan 30 min sin peticiones, el token se invalida automáticamente
+45
View File
@@ -0,0 +1,45 @@
# Backend SN — Documentación del Proyecto
Backend de integración entre una aplicación frontend (Next.js) y **SAP Business One Service Layer**. Actúa como proxy/middleware autenticando empleados, gestionando sesiones y enrutando operaciones CRUD hacia SAP.
## Stack Tecnológico
| Componente | Tecnología |
| ------------- | ----------------- |
| Framework | FastAPI (Python) |
| Servidor ASGI | Uvicorn |
| HTTP Client | `requests` |
| Logging | JSON estructurado |
| Sesiones | In-memory |
## Diagrama de Contexto del Sistema
```mermaid
graph TB
subgraph "Frontend"
F[App SN - Next.js]
end
subgraph "Backend SN - FastAPI"
API[API Proxy\n:8000]
end
subgraph "SAP Business One"
SL[Service Layer\nREST API]
DB[(SAP DB\nSBO_PRUEBA_*)]
end
F -->|HTTP /api/*| API
API -->|HTTP /b1s/v1| SL
SL --> DB
```
## Documentos
| Documento | Descripción |
| -------------------------------------------- | -------------------------------------------- |
| [Arquitectura](architecture.md) | Componentes, dependencias, middlewares, APIs |
| [Flujo de Autenticación](auth-flow.md) | Login, logout, sesiones, middleware |
| [Proxy SAP](sap-proxy.md) | Enrutamiento a SAP, re-autenticación, OData |
| [API Reference](api-reference.md) | Todos los endpoints, ejemplos request/response |
| [Visualización de logs](logs-viewing.md) | Cómo leer `logs/backend.log` con `jq`/`lnav` |
+175
View File
@@ -0,0 +1,175 @@
# Plan: logging real por sesión de usuario
## Problema actual (diagnosticado)
- `main.py:32` fuerza `setup_logging(environment="debug")` siempre — el modo
`"production"` (JSON estructurado) de `logger_config.py` nunca se activa,
sin importar el entorno real de despliegue.
- `core/sap.py`, `core/api_gateway.py` y `routers/auth.py` usan `print()` en
vez del `logger` real. Esto significa:
- No queda nada en el archivo rotativo `logs/backend.log` (solo en la
consola / `docker compose logs`).
- No tiene formato JSON buscable, ni `request_id` de correlación.
- **Grave**: `routers/auth.py` imprime la contraseña (apellido) en texto
plano dos veces:
- línea ~27: `print(f"Contraseña (Apellido) recibida: {empLastName}")`
- línea ~45: `print(f"Apellido en SAP: {employee_data.get('LastName')}")`
## Objetivo
Además de arreglar lo anterior, dejar un log **por sesión de usuario
conectado**: que cada línea de log quede etiquetada con qué empleado
(`emp_id` + nombre) hizo qué acción (`GET /Items`, `POST /BusinessPartners`,
etc.), en JSON, para poder cargarlo después en un monitor/dashboard y buscar
acciones por usuario.
## Diseño
`context.py` ya tiene un `ContextVar` para el `request_id` que se inyecta
automáticamente en cada log a través de `logger_config.JsonFormatter`. Se
replica el mismo patrón para el usuario conectado, así **todo log queda
etiquetado automáticamente sin tocar cada llamada individual**.
### 1. `context.py`
Agregar un `ContextVar` para el usuario actual, mismo patrón que
`request_id_ctx`:
```python
current_user_ctx: ContextVar[dict] = ContextVar(
"current_user", default={"emp_id": "-", "username": "-"}
)
def get_current_user() -> dict:
return current_user_ctx.get()
def set_current_user(emp_id: str, username: str):
current_user_ctx.set({"emp_id": emp_id, "username": username})
```
### 2. `core/session.py`
Agregar un helper que devuelva "Nombre Apellido" a partir de `emp_id`,
leyendo el dict `employee_sessions` que ya existe:
```python
def get_employee_name(emp_id: str) -> str:
session = employee_sessions.get(emp_id)
if not session:
return "-"
return f"{session.get('first_name', '')} {session.get('last_name', '')}".strip()
```
### 3. `main.py`
En `session_middleware`, justo después de validar el token y obtener
`emp_id` (donde hoy se hace `request.state.emp_id = emp_id`), setear el
contexto de usuario:
```python
from core.session import validate_and_refresh_session, get_employee_name
from context import set_current_user
...
emp_id = validate_and_refresh_session(token)
if not emp_id:
...
set_current_user(emp_id, get_employee_name(emp_id))
request.state.emp_id = emp_id
```
También en este archivo: dejar de forzar `"debug"` en `setup_logging`,
leerlo de una variable de entorno nueva (por defecto `"production"`, para
que Docker quede bien sin configurar nada extra):
```python
LOG_ENVIRONMENT = os.getenv("LOG_ENVIRONMENT", "production")
...
@asynccontextmanager
async def lifespan(app: FastAPI):
setup_logging(environment=LOG_ENVIRONMENT)
yield
```
### 4. `logger_config.py`
Extender `JsonFormatter` para incluir `emp_id`/`username` desde el
contexto en cada línea, y soportar campos extra estructurados
(`http_method`, `endpoint`, `status_code`) cuando el log los trae vía
`extra={...}`:
```python
from context import get_request_id, get_current_user
class JsonFormatter(logging.Formatter):
EXTRA_FIELDS = ("http_method", "endpoint", "status_code")
def format(self, record):
user = get_current_user()
log_record = {
"timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
"level": record.levelname,
"request_id": get_request_id(),
"emp_id": user["emp_id"],
"username": user["username"],
"message": record.getMessage(),
"module": record.module,
"function": record.funcName,
"line": record.lineno,
}
for field in self.EXTRA_FIELDS:
if hasattr(record, field):
log_record[field] = getattr(record, field)
if record.exc_info:
log_record["exception"] = self.formatException(record.exc_info)
return json.dumps(log_record)
```
Resultado esperado por línea, por ejemplo:
```json
{"timestamp": "2026-08-14T...", "level": "INFO", "request_id": "...", "emp_id": "11", "username": "Juan Pérez", "http_method": "GET", "endpoint": "Items", "status_code": 200, "message": "Solicitud SAP completada", "module": "sap", "function": "forward_to_sapb1", "line": 137}
```
### 5. `core/sap.py` / `core/api_gateway.py`
Reemplazar cada `print(...)` por `logger.info/warning/error(...)`,
agregando `extra={"http_method": method, "endpoint": endpoint_path,
"status_code": response.status_code}` en las líneas de request/respuesta,
para que queden como campos estructurados y no solo texto libre. Quitar la
variable `I = " "` (indentación para prints, ya no hace falta) y los
emojis (el nivel del log ya indica severidad).
### 6. `routers/auth.py`
- Agregar `logger = logging.getLogger(__name__)`.
- Reemplazar todos los `print()` por `logger.info/warning/error(...)`.
- **No loguear nunca `empLastName` ni `employee_data.get('LastName')`** —
solo el `emp_id` y si el login fue exitoso o no.
- En cuanto se confirma el empleado (login exitoso), llamar
`set_current_user(empId, f"{first_name} {last_name}")` para que el
propio log del login quede etiquetado también (el middleware no aplica
a `/api/Login` porque no requiere token todavía).
### 7. `docker-compose.yml`
Agregar volumen para que el log persista en el host aunque se reconstruya
el contenedor (si no, se perdería cada `--build`, igual que pasaba con
`.env` antes de agregar su volumen):
```yaml
backend:
volumes:
- ./mini-backend/menu_permissions.json:/app/menu_permissions.json:ro
- ./mini-backend/logs:/app/logs
```
## Verificación al implementar
1. Levantar el backend (local o Docker).
2. Loguearse con un usuario de prueba y hacer un par de llamadas reales
(GET a algún catálogo, POST/PATCH a algo).
3. Revisar `logs/backend.log`: cada línea debe traer `emp_id`, `username`,
`http_method`, `endpoint`, `status_code` correctos.
4. `grep` de la contraseña de prueba sobre todo `logs/backend.log` (y la
salida de consola) para confirmar que **no aparece en ningún lado**.
5. Confirmar que `docker compose build` sigue funcionando sin errores.
+56
View File
@@ -0,0 +1,56 @@
# Visualización de logs
El backend escribe en `logs/backend.log` una línea JSON por evento (ver
[logging-plan.md](logging-plan.md)), con campos como `emp_id`, `username`,
`http_method`, `endpoint` y `status_code`. Es el formato correcto para
buscar/cargar en un dashboard, pero incómodo de leer a simple vista con
`cat`/`tail`. Estas son las dos formas recomendadas de verlo cómodo desde la
terminal.
## `jq` — filtrar y colorear en vivo
Instalación: `brew install jq`
```bash
# seguir el log en vivo, con formato e indentación
tail -f logs/backend.log | jq .
# solo las líneas de un empleado puntual
tail -f logs/backend.log | jq 'select(.emp_id == 10)'
# solo errores/warnings
tail -f logs/backend.log | jq 'select(.level == "ERROR" or .level == "WARNING")'
# solo llamadas a un endpoint específico
tail -f logs/backend.log | jq 'select(.endpoint == "Items")'
# ver solo unos campos, en una línea por evento
tail -f logs/backend.log | jq -c '{ts: .timestamp, user: .username, ep: .endpoint, status: .status_code}'
```
## `lnav` — visor interactivo
Instalación: `brew install lnav`
```bash
lnav logs/backend.log
```
`lnav` detecta el formato JSON automáticamente y da un visor interactivo con:
- colores por nivel (`INFO`/`WARNING`/`ERROR`).
- búsqueda de texto con `/`.
- filtros en vivo con `:filter-in` / `:filter-out` (por ejemplo
`:filter-in emp_id.:.10` para quedarte solo con el empleado 10).
- queries tipo SQL sobre los campos del log con `;` (por ejemplo
`;SELECT emp_id, username, endpoint, status_code FROM backend_log WHERE level = 'ERROR'`).
Es la opción más cómoda para explorar el log completo o investigar un
incidente puntual sin escribir comandos `jq` largos.
## Dashboard centralizado (a futuro)
Si más adelante se necesita buscar por usuario desde una UI (no la terminal)
o cruzar logs de varios despliegues, la opción estándar es enviar
`logs/backend.log` a algo como Grafana Loki + Promtail. Es infraestructura
adicional, no necesaria mientras el volumen de logs se maneje bien con `jq`/`lnav`.
+118
View File
@@ -0,0 +1,118 @@
# Flujo de Proxy SAP
## Función Central: `forward_to_sapb1`
Todas las operaciones CRUD pasan por esta función en `core/sap.py`, que actúa como proxy hacia SAP Service Layer.
```mermaid
flowchart TD
START([Inicio]) --> INPUT["method, endpoint_path,\njson_data, params, headers"]
INPUT --> BUILD["Construye URL:\nSAPB1_SL_URL / endpoint_path"]
BUILD --> HEADERS["Adjunta headers:\nCookie: B1SESSION=...\nContent-Type: application/json"]
HEADERS --> SEND[Envía request a SAP]
SEND --> CHECK{Respuesta}
CHECK -->|2xx| SUCCESS[Retorna Response al router]
CHECK -->|401| REFRESH["Force refresh:\nensure_sap_session()"]
CHECK -->|Timeout / Error| FAIL[ConnectionError]
REFRESH --> RETRY{Es reintento?}
RETRY -->|No| SEND2["Reintenta request\ncon nueva sesión"]
RETRY -->|Sí| ERROR["Extrae error.message.value\nde SAP y lanza HTTPException"]
SEND2 --> CHECK2{Respuesta}
CHECK2 -->|2xx| SUCCESS
CHECK2 -->|4xx/5xx| ERROR
SUCCESS --> END([Fin])
FAIL --> END
ERROR --> END
style ERROR fill:#f96,stroke:#a33
```
## Diagrama de Secuencia — Operación CRUD Típica
Ejemplo con `GET /api/Items`:
```mermaid
sequenceDiagram
participant F as Frontend
participant MW as Middleware
participant R as router/items.py
participant C as core/sap.py
participant SAP as SAP Service Layer
F->>MW: GET /api/Items?$skip=0&$top=20\nX-Session-Token: [token]
MW->>MW: Valida sesión
MW->>R: request.state.emp_id = "123"
R->>C: forward_to_sapb1(\n GET, "Items?$skip=0&$top=20&$inlinecount=allpages")
C->>SAP: GET /b1s/v1/Items?$skip=0&$top=20\nCookie: B1SESSION=abc123
SAP-->>C: 200 - value: [...], odata.count: 150
C-->>R: Response
R-->>F: 200 - data: [...], total: 150
```
## Re-autenticación Automática ante 401
Cuando SAP rechaza la sesión actual (401), el sistema renueva automáticamente la sesión de SAP y reintenta:
```mermaid
sequenceDiagram
participant R as Router
participant C as core/sap.py
participant SAP as SAP Service Layer
R->>C: forward_to_sapb1(GET, "Items?...")
C->>SAP: GET /b1s/v1/Items?...\nCookie: B1SESSION=abc123
SAP-->>C: 401 Unauthorized
Note over C: Sesión SAP expirada\n→ Re-autentica y reintenta
C->>SAP: POST /Login\nUserName: manager, Password: ..., CompanyDB: ...
SAP-->>C: 200 SessionId: xyz789
C->>C: Actualiza sap_session_store
C->>SAP: GET /b1s/v1/Items?...\nCookie: B1SESSION=xyz789
SAP-->>C: 200 - value: [...]
C-->>R: Response 200
Note over R,C: Si el reintento también falla (4xx/5xx)\n→ se extrae error.message.value\n→ se lanza HTTPException
```
## Parámetros OData y Headers SAP Soportados
### Query Parameters (OData)
| Parámetro | Uso | Ejemplo |
| -------------- | ---------------------------------- | -------------------------------- |
| `$skip` | Paginación — desplazamiento | `$skip=20` |
| `$top` | Paginación — límite por página | `$top=20` |
| `$select` | Selección de campos | `$select=ItemCode,ItemName` |
| `$filter` | Filtrado | `$filter=ItemCode eq 'A001'` |
| `$orderby` | Ordenamiento | `$orderby=ItemCode asc` |
| `$inlinecount` | Contar registros totales | `$inlinecount=allpages` |
### Headers SAP
| Header | Función |
| ----------------------------- | ----------------------------------------- |
| `B1S-PageSize` | Tamaño de página personalizado |
| `B1S-CaseInsensitive` | Búsqueda sin distinción mayúsc/minúscula |
| `B1S-ReplaceCollectionsOnPatch` | Reemplazar colecciones completas en PATCH |
## Manejo de Errores
| Situación | Comportamiento |
| ----------------------- | ---------------------------------------------------------- |
| SAP devuelve 2xx | Retorna `Response` al router (200 body, 201 body, 204 vacío) |
| SAP devuelve 401 | Re-autentica automáticamente y reintenta 1 vez |
| SAP devuelve 4xx/5xx | Extrae `error.message.value` y lanza `HTTPException(status_code, detail)` |
| Timeout de conexión | Lanza `ConnectionError` |
| Error de red | Lanza `ConnectionError` |
| Reintento también falla | Lanza `HTTPException` con el mensaje de error SAP |
| SAP no disponible | `HTTPException(500)` |
+85
View File
@@ -0,0 +1,85 @@
import os
import logging
import json
import sys
from datetime import datetime, timezone
from context import get_request_id, get_current_user # Importamos las funciones
from logging.handlers import RotatingFileHandler
# ============================================================================
class JsonFormatter(logging.Formatter):
"""Formateador personalizado para exportar logs en formato JSON puro."""
EXTRA_FIELDS = ("http_method", "endpoint", "status_code")
def format(self, record):
user = get_current_user()
log_record = {
"timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
"level": record.levelname,
"request_id": get_request_id(), # ⚡️ Inyección automática del ID de rastreo
"webUser": user["webUser"],
"emp_id": user["emp_id"],
"full_name": user["full_name"],
"message": record.getMessage(),
"module": record.module,
"function": record.funcName,
"line": record.lineno
}
for field in self.EXTRA_FIELDS:
if hasattr(record, field):
log_record[field] = getattr(record, field)
# Si el log incluye excepciones, las añade al JSON
if record.exc_info:
log_record["exception"] = self.formatException(record.exc_info)
return json.dumps(log_record, ensure_ascii=False)
# ============================================================================
def setup_logging(environment: str = "production"):
"""Configura el logger global de la aplicación."""
root_logger = logging.getLogger()
# Evita duplicar logs si la función se ejecuta más de una vez
if root_logger.hasHandlers():
root_logger.handlers.clear()
# Definir el nivel mínimo de captura (INFO por defecto)
root_logger.setLevel(logging.INFO)
# Handler para la consola (Standard Output)
console_handler = logging.StreamHandler(sys.stdout)
if environment == "production":
# En producción usamos JSON estructurado
console_handler.setFormatter(JsonFormatter())
else:
# En desarrollo usamos texto limpio y con colores opcionales
readable_formatter = logging.Formatter(
fmt="%(asctime)s [%(levelname)s] %(module)s.%(funcName)s:%(lineno)d - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
console_handler.setFormatter(readable_formatter)
root_logger.addHandler(console_handler)
# 2. CREAR CARPETA Y ARCHIVO FÍSICO EN DISCO
log_dir = "logs"
if not os.path.exists(log_dir):
os.makedirs(log_dir) # Crea la carpeta 'logs/' si no existe
# Usamos 'RotatingFileHandler' para que el archivo no crezca infinitamente.
# Cuando llegue a 5MB, lo cierra, lo renombra a backend.log.1 y crea uno nuevo.
file_handler = RotatingFileHandler(
filename=os.path.join(log_dir, "backend.log"),
maxBytes=5 * 1024 * 1024, # 5 Megabytes
backupCount=3, # Mantener máximo 3 archivos viejos de respaldo
encoding="utf-8"
)
file_handler.setFormatter(JsonFormatter())
root_logger.addHandler(file_handler)
# Silenciar logs ruidosos de librerías externas (opcional)
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
# ============================================================================
+108
View File
@@ -0,0 +1,108 @@
import os
import uuid
import uvicorn
import urllib3
import logging
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from contextlib import asynccontextmanager
from dotenv import load_dotenv
from logger_config import setup_logging
from context import set_request_id, get_request_id, set_current_user
from core.session import validate_and_refresh_session, get_employee_name, get_emp_id
from routers.auth import router as auth_router
from routers.business_partners import router as bp_router
from routers.items import router as items_router
from routers.sales import router as sales_router
from routers.purchases import router as purchases_router
from routers.catalogs import router as catalogs_router
from routers.batch import router as batch_router
from routers.permissions import router as permissions_router
from routers.apigateway import router as apigateway_router
from routers.reports import router as reports_router
# ============================================================================
load_dotenv(dotenv_path=".env")
logger = logging.getLogger(__name__)
LOG_ENVIRONMENT = os.getenv("LOG_ENVIRONMENT", "production")
# ============================================================================
@asynccontextmanager
async def lifespan(app: FastAPI):
setup_logging(environment=LOG_ENVIRONMENT)
yield
app = FastAPI(
title="SAP Business One Integration API",
description="API para integrar Socios de negocios con SAP Business One Service Layer.",
version="1.1.0",
lifespan=lifespan
)
app.include_router(auth_router)
app.include_router(bp_router)
app.include_router(items_router)
app.include_router(sales_router)
app.include_router(purchases_router)
app.include_router(catalogs_router)
app.include_router(batch_router)
app.include_router(permissions_router)
app.include_router(apigateway_router)
app.include_router(reports_router)
# ============================================================================
@app.middleware("http")
async def correlation_id_middleware(request: Request, call_next):
req_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
set_request_id(req_id)
response = await call_next(request)
response.headers["X-Request-ID"] = get_request_id()
return response
# ============================================================================
@app.middleware("http")
async def session_middleware(request: Request, call_next):
if request.url.path == "/api/Login":
return await call_next(request)
if request.url.path.startswith("/api/"):
token = request.headers.get("X-Session-Token")
if not token:
logger.warning(f"Request sin token: {request.method} {request.url.path}")
return JSONResponse(
status_code=401,
content={"detail": "Se requiere autenticación. Inicie sesión primero."}
)
webUser = validate_and_refresh_session(token)
if not webUser:
logger.warning(f"Token inválido o expirado en: {request.method} {request.url.path}")
return JSONResponse(
status_code=401,
content={"detail": "Sesión expirada. Debe iniciar sesión nuevamente."}
)
set_current_user(webUser, get_employee_name(webUser), get_emp_id(webUser))
request.state.webUser = webUser
return await call_next(request)
# ============================================================================
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
urllib3.disable_warnings()
# ============================================================================
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000, log_level="debug", access_log=True)
+22
View File
@@ -0,0 +1,22 @@
{
"renato.ortega@tagumedica.com": [
"*"
],
"rodrigo.catari@tagumedica.com": [
"/inventario/*"
],
"jose.huayta@tagumedica.com": [
"/inventario/articulos",
"/ventas/*",
"/compras/*"
],
"ricardo.merino@tagumedica.com": [
"/socios/*"
],
"renato.bustamante@tagumedica.com": [
"/inventario/articulos",
"/inventario/lotes",
"/ventas/*",
"/compras/*"
]
}
+35
View File
@@ -0,0 +1,35 @@
annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.13.0
brotli==1.2.0
certifi==2026.5.20
cffi==2.1.1
charset-normalizer==3.4.7
click==8.4.1
cssselect2==0.9.0
dotenv==0.9.9
fastapi==0.136.3
fonttools==4.63.0
h11==0.16.0
hdbcli==2.29.25
idna==3.17
Jinja2==3.1.6
MarkupSafe==3.0.3
pillow==12.3.0
pycparser==3.0
pydantic==2.13.4
pydantic_core==2.46.4
pydyf==0.12.1
pyphen==0.18.1
python-dotenv==1.2.2
requests==2.34.2
starlette==1.2.0
tinycss2==1.5.1
tinyhtml5==2.1.0
typing-inspection==0.4.2
typing_extensions==4.15.0
urllib3==2.7.0
uvicorn==0.48.0
weasyprint==69.0
webencodings==0.5.1
zopfli==0.4.3
View File
Whitespace-only changes.
+52
View File
@@ -0,0 +1,52 @@
import base64
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from core.api_gateway import forward_to_api_gateway
from typing import Any, List
from io import BytesIO
router = APIRouter(
tags=["APIGateway - Crystal Reports"]
)
@router.get("/api/apigateway/reports")
async def list_reports():
resp = forward_to_api_gateway(
method="GET",
endpoint="rs/v1/LoadAuthorizedCRList"
)
data = resp.json()
if isinstance(data, list):
return data
if isinstance(data, dict):
return data.get("value", data.get("AuthorizedCRList", [data]))
return []
@router.get("/api/apigateway/reports/{docCode}/parameters")
async def get_report_parameters(docCode: str):
resp = forward_to_api_gateway(
method="GET",
endpoint=f"rs/v1/LoadCR",
params={"DocCode": docCode}
)
return resp.json()
@router.post("/api/apigateway/reports/{docCode}/export")
async def export_report_pdf(docCode: str, body: List[Any]):
resp = forward_to_api_gateway(
method="POST",
endpoint="rs/v1/ExportPDFData",
params={"DocCode": docCode},
json_data=body,
)
pdf_bytes = base64.b64decode(resp.text)
return StreamingResponse(
BytesIO(pdf_bytes),
media_type="application/pdf",
headers={
"Content-Disposition": f'inline; filename="{docCode}.pdf"',
}
)
+90
View File
@@ -0,0 +1,90 @@
import os
import logging
from fastapi import APIRouter, Request, HTTPException
from core.sap import forward_to_sapb1, ensure_sap_session
from core.session import create_employee_session, remove_employee_session_by_token
from context import set_current_user
from dotenv import load_dotenv
# Cargar variables de entorno desde .env
load_dotenv(dotenv_path=".env")
SAPB1_SL_COMPANYDB = os.getenv("SAPB1_SL_COMPANYDB", "")
logger = logging.getLogger(__name__)
router = APIRouter(
tags=["Autenticación"]
)
@router.post("/api/Login")
async def login(request: Request):
data = await request.json()
webUser = data.get("Username")
password = data.get("Password")
logger.info(f"Intento de login recibido para el usuario web {webUser}")
webUser_escaped = (webUser or "").replace("'", "''")
try:
employee_response = forward_to_sapb1(
method="GET",
endpoint_path="EmployeesInfo",
params={
"$select": "EmployeeID,FirstName,LastName,Active,U_WEB_USER,U_WEB_PASS",
"$filter": f"U_WEB_USER eq '{webUser_escaped}'"
}
)
except HTTPException:
raise HTTPException(status_code=401, detail="Usuario o contraseña incorrectos")
except Exception as exc:
logger.error(f"Error al consultar empleados en SAP a través de proxy: {exc}")
raise HTTPException(status_code=500, detail=f"Error al consultar empleados en SAP: {exc}")
employees = employee_response.json().get("value", [])
employee_data = employees[0] if employees else None
if employee_data is None:
logger.warning(f"Login fallido: no se encontró ningún empleado con U_WEB_USER {webUser}")
raise HTTPException(status_code=401, detail="Usuario no registrado")
empId = employee_data.get("EmployeeID")
if employee_data.get("Active") != "tYES":
logger.warning(f"Login fallido: el empleado {empId} está inactivo")
raise HTTPException(status_code=401, detail="Usuario inactivo")
if employee_data.get("U_WEB_PASS") != password:
logger.warning(f"Login fallido: la contraseña no coincide para el empleado {empId}")
raise HTTPException(status_code=401, detail="Usuario o contraseña incorrectos")
full_name = f"{employee_data.get('FirstName', '')} {employee_data.get('LastName', '')}".strip()
set_current_user(webUser, full_name, empId)
logger.info(f"Login exitoso para el usuario {webUser} (empleado SAP {empId})")
token = create_employee_session(
webUser=webUser,
emp_id=empId,
first_name=employee_data.get('FirstName', ''),
last_name=employee_data.get('LastName', '')
)
ensure_sap_session()
return {
"message": "Login exitoso",
"token": token,
"username": employee_data.get('FirstName'),
"company": SAPB1_SL_COMPANYDB
}
@router.post("/api/Logout")
async def logout(request: Request):
token = request.headers.get("X-Session-Token")
if token:
remove_employee_session_by_token(token)
return {"message": "Logout exitoso"}
+8
View File
@@ -0,0 +1,8 @@
from core.crud_router import make_crud_router
router = make_crud_router(
"BatchNumberDetails",
tags=["Lotes"],
supports_orderby=True,
supports_create=False,
)
@@ -0,0 +1,8 @@
from core.crud_router import make_crud_router
router = make_crud_router(
"BusinessPartners",
tags=["Socios de Negocio"],
key_field="CardCode",
key_quoted=True,
)
+79
View File
@@ -0,0 +1,79 @@
from fastapi import APIRouter
from core.sap import forward_to_sapb1
from core.crud_router import make_crud_router
router = APIRouter(
tags=["Catálogos"]
)
for _resource in ("Warehouses", "SalesTaxCodes", "FactoringIndicators"):
router.include_router(
make_crud_router(
_resource,
tags=["Catálogos"],
supports_get_by_key=False,
supports_create=False,
supports_patch=False,
)
)
@router.get("/api/Currencies")
async def get_currencies():
return forward_to_sapb1(
method="GET",
endpoint_path="Currencies",
params={"$select": "Code,Name", "$top": 200}
).json()
@router.get("/api/BusinessPartnerGroups")
async def get_business_partner_groups():
return forward_to_sapb1(
method="GET",
endpoint_path="BusinessPartnerGroups",
params={"$select": "Number,GroupName"}
).json()
@router.get("/api/Sunat/BusinessPartners/DocumentType")
async def get_sunat_businesspartners_document_type():
sap_params = {
"$skip": 0,
"$top": 200,
"$inlinecount": "allpages"
}
sap_params["$filter"] = "TableName eq 'OCRD' and Name eq 'EXX_TIPODOCU'"
base_headers = {"B1S-PageSize": "200"}
return forward_to_sapb1(
method="GET",
endpoint_path="UserFieldsMD",
params=sap_params,
extra_headers=base_headers
).json()
@router.get("/api/ItemGroups")
async def get_item_groups():
sap_params = {
"$skip": 0,
"$top": 200,
"$inlinecount": "allpages"
}
sap_params["$select"] = "Number,GroupName"
base_headers = {"B1S-PageSize": "200"}
return forward_to_sapb1(
method="GET",
endpoint_path="ItemGroups",
params=sap_params,
extra_headers=base_headers
).json()
#
+8
View File
@@ -0,0 +1,8 @@
from core.crud_router import make_crud_router
router = make_crud_router(
"Items",
tags=["Artículos"],
key_field="ItemCode",
key_quoted=True,
)
+26
View File
@@ -0,0 +1,26 @@
import json
import os
from fastapi import APIRouter, HTTPException, Request
MENU_PERMISSIONS_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "menu_permissions.json")
router = APIRouter(tags=["Permisos"])
def load_permissions() -> dict:
if not os.path.exists(MENU_PERMISSIONS_PATH):
return {}
with open(MENU_PERMISSIONS_PATH, "r") as f:
return json.load(f)
@router.get("/api/user/menu")
async def get_user_menu(request: Request):
webUser = str(getattr(request.state, "webUser", ""))
if not webUser:
raise HTTPException(status_code=401, detail="Usuario no autenticado")
permissions = load_permissions()
allowed_paths = permissions.get(webUser, [])
return {"allowedPaths": allowed_paths}
+18
View File
@@ -0,0 +1,18 @@
from fastapi import APIRouter
from core.crud_router import make_crud_router
router = APIRouter()
_MARKETING_DOC_PATCH_HEADERS = {"B1S-ReplaceCollectionsOnPatch": "true"}
for _resource in ("PurchaseOrders", "PurchaseDeliveryNotes", "PurchaseInvoices"):
router.include_router(
make_crud_router(
_resource,
tags=["Compras"],
key_field="DocEntry",
supports_orderby=True,
patch_headers=_MARKETING_DOC_PATCH_HEADERS,
)
)
+172
View File
@@ -0,0 +1,172 @@
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 los
# 0.3in de margen superior e inferior definidos en el @page de stickers.html).
PAGINA_ALTO_LANDSCAPE = {"A4": "7.67in", "Carta": "7.9in"}
# 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"'},
)
+18
View File
@@ -0,0 +1,18 @@
from fastapi import APIRouter
from core.crud_router import make_crud_router
router = APIRouter()
_MARKETING_DOC_PATCH_HEADERS = {"B1S-ReplaceCollectionsOnPatch": "true"}
for _resource in ("Invoices", "Orders", "DeliveryNotes"):
router.include_router(
make_crud_router(
_resource,
tags=["Ventas"],
key_field="DocEntry",
supports_orderby=True,
patch_headers=_MARKETING_DOC_PATCH_HEADERS,
)
)
@@ -0,0 +1,127 @@
<!DOCTYPE html>
<html>
<head>
<style>
@font-face {
font-family: 'Orbitron';
src: url('fonts/Orbitron-Bold.ttf') format('truetype');
font-weight: bold;
}
@page {
size: {{ paper_size }} landscape;
margin: 0.3in;
}
body {
font-family: Arial, Helvetica, sans-serif;
margin: 0;
padding: 0;
}
.pagina {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 0.15in;
height: {{ pagina_alto }};
page-break-after: always;
}
.pagina:last-child {
page-break-after: auto;
}
.sticker {
border: 2px solid #000;
padding: 0.3in 0.35in;
box-sizing: border-box;
position: relative;
display: flex;
flex-direction: column;
}
.titulo {
color: #E8781E;
font-family: 'Orbitron', 'Arial Black', Arial, sans-serif;
font-size: 20pt;
font-weight: bold;
letter-spacing: 1px;
margin-bottom: 16px;
}
.campo {
margin-bottom: 14px;
}
.campo-direccion {
min-height: 0.65in;
}
.campo-label {
font-family: Arial, Helvetica, sans-serif;
font-size: 13pt;
font-weight: normal;
}
.campo-valor {
font-family: Arial, Helvetica, sans-serif;
font-size: 13pt;
font-weight: bold;
}
.contador {
font-family: Arial, Helvetica, sans-serif;
font-size: 38pt;
font-weight: bold;
}
.documento-linea {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-top: auto;
}
.documento-linea .campo {
margin-bottom: 0;
}
</style>
</head>
<body>
{% for pagina in paginas %}
<div class="pagina">
{% for sticker in pagina %}
<div class="sticker">
<div class="titulo">TAGUMEDICA</div>
<div class="campo">
<div class="campo-label">DATOS DEL CLIENTE:</div>
<div class="campo-valor">{{ datos.cliente }}</div>
</div>
<div class="campo campo-direccion">
<div class="campo-label">DIRECCIÓN:</div>
<div class="campo-valor">{{ datos.direccion }}</div>
</div>
<div class="campo">
<div class="campo-label">LUGAR:</div>
<div class="campo-valor">{{ datos.lugar }}</div>
</div>
<div class="documento-linea">
<div class="campo">
<div class="campo-label">DOCUMENTO:</div>
<div class="campo-valor">{{ datos.documento }}</div>
</div>
<div class="contador">{{ sticker.numero }}/{{ sticker.total }}</div>
</div>
</div>
{% endfor %}
</div>
{% endfor %}
</body>
</html>
@@ -0,0 +1,42 @@
info:
name: ExportPDFData
type: http
seq: 1
http:
method: POST
url: https://192.168.1.175:60000/rs/v1/ExportPDFData?DocCode=DLN20015
params:
- name: DocCode
value: DLN20015
type: query
body:
type: json
data: |-
[
{
"name": "Dockey@",
"type": "xsd:decimal",
"value": [
[
"137138"
]
]
},
{
"name": "CANTIDAD",
"type": "xsd:decimal",
"value": [
[
"5"
]
]
}
]
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,15 @@
info:
name: LoadAuthorizedCRList
type: http
http:
method: GET
url: https://192.168.1.175:60000/rs/v1/LoadAuthorizedCRList
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
​
+18
View File
@@ -0,0 +1,18 @@
info:
name: LoadCR
type: http
http:
method: GET
url: https://192.168.1.175:60000/rs/v1/LoadCR?DocCode=DLN20015
params:
- name: DocCode
value: DLN20015
type: query
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
+23
View File
@@ -0,0 +1,23 @@
info:
name: Login
type: http
http:
method: POST
url: https://192.168.1.175:60000/login
body:
type: json
data: |-
{
"CompanyDB":"SBO_TAGUMEDICA_21",
"UserName": "manager",
"Password": "m1r1",
"DBInstance":"NDB@192.168.1.175:30013"
}
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,10 @@
opencollection: 1.0.0
info:
name: ApiGateway
bundled: false
extensions:
bruno:
ignore:
- node_modules
- .git
+22
View File
@@ -0,0 +1,22 @@
info:
name: Login
type: http
seq: 1
http:
method: POST
url: http://127.0.0.1:8000/api/Login
body:
type: json
data: |-
{
"UserName":56,
"Password":"mngr"
}
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,10 @@
opencollection: 1.0.0
info:
name: apiSN
bundled: false
extensions:
bruno:
ignore:
- node_modules
- .git
+21
View File
@@ -0,0 +1,21 @@
info:
name: Login
type: http
http:
method: POST
url: http://127.0.0.1:8000/api/Login
body:
type: json
data: |-
{
"Username":10,
"Password":"COMENA AGUIRRE"
}
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
+20
View File
@@ -0,0 +1,20 @@
info:
name: Logout
type: http
http:
method: POST
url: http://127.0.0.1:8000/api/Logout
body:
type: json
data: |-
{
"SessionId":"0427de1e-68c2-11f1-c000-005056a55f84-139742384043904-16982"
}
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,10 @@
opencollection: 1.0.0
info:
name: backend
bundled: false
extensions:
bruno:
ignore:
- node_modules
- .git
@@ -0,0 +1,21 @@
info:
name: BusinessPartners
type: http
http:
method: GET
url: https://192.168.9.3:50000/b1s/v1/BusinessPartners?$select=CardCode, CardName,FederalTaxID&$filter=contains(CardName, 'FERRETERIA') and CardType eq 'cCustomer'
params:
- name: $select
value: CardCode, CardName,FederalTaxID
type: query
- name: $filter
value: contains(CardName, 'FERRETERIA') and CardType eq 'cCustomer'
type: query
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,19 @@
info:
name: DeliveryNotes
type: http
seq: 1
http:
method: GET
url: https://192.168.9.3:50000/b1s/v1/DeliveryNotes?$filter=Cancelled eq 'tNO' and U_BPP_MDSD eq 'T001' and U_BPP_MDTD eq '09' and contains(U_BPP_MDCD,'128452')
params:
- name: $filter
value: Cancelled eq 'tNO' and U_BPP_MDSD eq 'T001' and U_BPP_MDTD eq '09' and contains(U_BPP_MDCD,'128452')
type: query
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
+25
View File
@@ -0,0 +1,25 @@
info:
name: Invoices
type: http
seq: 1
http:
method: GET
url: https://192.168.9.3:50000/b1s/v1/Invoices?$select=DocEntry,DocNum,FederalTaxID,CardCode,CardName,NumAtCard,DocCurrency,DocTotal,DocTotalFc,FolioPrefixString,FolioNumber,DocumentStatus,DocumentSubType&$filter=DocumentStatus eq 'bost_Open'&$orderby=DocEntry desc
params:
- name: $select
value: DocEntry,DocNum,FederalTaxID,CardCode,CardName,NumAtCard,DocCurrency,DocTotal,DocTotalFc,FolioPrefixString,FolioNumber,DocumentStatus,DocumentSubType
type: query
- name: $filter
value: DocumentStatus eq 'bost_Open'
type: query
- name: $orderby
value: DocEntry desc
type: query
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
+14
View File
@@ -0,0 +1,14 @@
info:
name: Items
type: http
http:
method: GET
url: https://192.168.9.3:50000/b1s/v1/Items
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
+23
View File
@@ -0,0 +1,23 @@
info:
name: Login
type: http
seq: 1
http:
method: POST
url: https://192.168.9.3:50000/b1s/v1/Login
body:
type: json
data: |-
{
"CompanyDB":"PRUEBA_20260701",
"UserName":"manager",
"Password":"s1st3m4s"
}
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,25 @@
info:
name: PurchaseInvoices
type: http
seq: 1
http:
method: GET
url: https://192.168.9.3:50000/b1s/v1/PurchaseInvoices?$select=DocEntry,DocNum,FederalTaxID,CardCode,CardName,NumAtCard,DocCurrency,DocTotal,DocTotalFc,FolioPrefixString,FolioNumber,DocumentStatus,DocumentSubType&$filter=DocumentStatus eq 'bost_Open'&$orderby=DocEntry desc
params:
- name: $select
value: DocEntry,DocNum,FederalTaxID,CardCode,CardName,NumAtCard,DocCurrency,DocTotal,DocTotalFc,FolioPrefixString,FolioNumber,DocumentStatus,DocumentSubType
type: query
- name: $filter
value: DocumentStatus eq 'bost_Open'
type: query
- name: $orderby
value: DocEntry desc
type: query
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -0,0 +1,10 @@
opencollection: 1.0.0
info:
name: sap-aberio
bundled: false
extensions:
bruno:
ignore:
- node_modules
- .git
+82
View File
@@ -0,0 +1,82 @@
@url=https://192.168.1.175:50000/b1s/v2
###
# @noRejectUnauthorized
POST {{url}}/Login HTTP/1.1
Content-Type: application/json
{
"CompanyDB": "SBO_TAGUMEDICA_21",
"UserName": "manager",
"Password": "m1r1"
}
###
# @noRejectUnauthorized
GET {{url}}/$metadata
Content-Type: application/json
###
# @noRejectUnauthorized
GET {{url}}/EmployeesInfo?$select=EmployeeID,FirstName,LastName,Active,U_WEB_USER,U_WEB_PASS&$filter=U_WEB_USER eq 'renato.ortega@tagumedica.com'
Content-Type: application/json
###
# @noRejectUnauthorized
GET {{url}}/Items?$top=20&$orderby=ItemCode desc
Content-Type: application/json
###
# @noRejectUnauthorized
GET {{url}}/DeliveryNotes(29360)
Content-Type: application/json
###
# @noRejectUnauthorized
GET {{url}}/UserFieldsMD?$filter=TableName eq 'OCRD' and Name eq 'EXX_TIPODOCU'
Content-Type: application/json
###
# @noRejectUnauthorized
GET {{url}}/Warehouses
Content-Type: application/json
###
# @noRejectUnauthorized
GET {{url}}/PurchaseInvoices(135235)
Content-Type: application/json
###
# @noRejectUnauthorized
GET {{url}}/Orders?$filter=DocumentStatus eq 'bost_Open'
Content-Type: application/json
###
# @noRejectUnauthorized
POST {{url}}/MessagesService_GetInbox HTTP/1.1
Content-Type: application/json
###
# @noRejectUnauthorized
GET {{url}}/Messages(173810)
Content-Type: application/json
###
# @noRejectUnauthorized
GET {{url}}/ItemGroups?$select=Number,GroupName
Content-Type: application/json
###
# @noRejectUnauthorized
POST {{url}}/FactoringIndicators?$select=IndicatorCode,IndicatorName&$filter=U_EXX_REGVEN eq 'Y'
Content-Type: application/json
###
# @noRejectUnauthorized
GET {{url}}/Currencies
Content-Type: application/json
###
# @noRejectUnauthorized
GET {{url}}/BatchNumberDetails
Content-Type: application/json