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:
commit
ab3c613165
154 files changed
+16872
No files matched your search
@@ -0,0 +1,9 @@
|
||||
.env
|
||||
.env.aberio
|
||||
backend-env/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
logs/
|
||||
test/
|
||||
docs/
|
||||
.DS_Store
|
||||
@@ -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"]
|
||||
@@ -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})
|
||||
# ============================================================================
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
# ============================================================================
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
@@ -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", "-")
|
||||
# ============================================================================
|
||||
@@ -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 |
|
||||
@@ -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` |
|
||||
@@ -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
|
||||
@@ -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` |
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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)` |
|
||||
@@ -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)
|
||||
# ============================================================================
|
||||
@@ -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)
|
||||
@@ -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/*"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
Whitespace-only changes.
@@ -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"',
|
||||
}
|
||||
)
|
||||
@@ -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"}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
#
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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"'},
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user