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

Base para el flujo de ramas por cliente.

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

No files matched your search

+526
View File
@@ -0,0 +1,526 @@
# API Reference
> **Base URL:** `http://<host>:8000`
> **API Version:** 1.1.0
> **Content-Type:** `application/json`
---
## Headers Globales
| Header | Requerido | Descripción |
|--------|-----------|-------------|
| `Content-Type` | Sí | `application/json` |
| `X-Session-Token` | Sí (excepto `/api/Login`) | Token obtenido en el login |
| `X-Request-ID` | Opcional | UUID para trazabilidad; si no se envía, el servidor lo genera |
---
## Formato de Errores
Todas las respuestas de error siguen el mismo formato:
```json
{
"detail": "Mensaje de error de SAP o del servidor"
}
```
| Código | Significado |
|--------|-------------|
| 400 | Error de negocio SAP (ej: código duplicado, validación fallida) |
| 401 | Token inválido/expirado, o credenciales incorrectas |
| 404 | Recurso no encontrado en SAP |
| 500 | Error de conexión con SAP o error interno |
---
## Autenticación
### `POST /api/Login`
Inicia sesión de empleado. Valida contra SAP EmployeesInfo usando el apellido como contraseña.
**Request body:**
```json
{
"Username": "123",
"Password": "Garcia"
}
```
**Response 200:**
```json
{
"message": "Login exitoso",
"token": "aB3x...token_urlsafe_32_chars...",
"username": "Juan"
}
```
**Response 401:**
```json
{
"detail": "Usuario o contraseña incorrectos"
}
```
---
### `POST /api/Logout`
Cierra la sesión del empleado.
**Headers:** `X-Session-Token: <token>`
**Response 200:**
```json
{
"message": "Logout exitoso"
}
```
---
## Socios de Negocio
### `GET /api/BusinessPartners`
Lista paginada de socios de negocio.
**Query parameters:**
| Parámetro | Tipo | Default | Descripción |
|-----------|------|---------|-------------|
| `skip` | int | 0 | Registros a saltar (`$skip`) |
| `top` | int | 50 | Registros por página (`$top`) |
| `fields` | string | — | Campos a retornar, separados por coma (`$select`) |
| `filter` | string | — | Filtro OData (`$filter`). Activa `B1S-CaseInsensitive` |
**Response 200:**
```json
{
"odata.metadata": "https://.../$metadata#BusinessPartners",
"value": [
{
"CardCode": "C001",
"CardName": "Cliente Demo",
"CardType": "C"
}
],
"odata.count": 150
}
```
---
### `GET /api/BusinessPartners/{bp_code}`
Obtiene un socio de negocio por `CardCode`.
**Response 200:**
```json
{
"CardCode": "C001",
"CardName": "Cliente Demo",
"CardType": "C",
"Phone1": "123456789",
"EmailAddress": "cliente@demo.com"
}
```
---
### `POST /api/BusinessPartners`
Crea un nuevo socio de negocio.
**Request body:**
```json
{
"CardCode": "C001",
"CardName": "Cliente Demo",
"CardType": "C"
}
```
**Response 201:**
```json
{
"CardCode": "C001"
}
```
---
### `PATCH /api/BusinessPartners/{bp_code}`
Actualiza un socio de negocio existente.
**Request body:**
```json
{
"CardName": "Nuevo Nombre",
"Phone1": "987654321"
}
```
**Response 204:** Sin contenido.
---
## Artículos
### `GET /api/Items`
Lista paginada de artículos.
**Query parameters:**
| Parámetro | Tipo | Default | Descripción |
|-----------|------|---------|-------------|
| `skip` | int | 0 | Registros a saltar |
| `top` | int | 50 | Registros por página |
| `fields` | string | — | Campos a retornar (`$select`) |
| `filter` | string | — | Filtro OData (`$filter`) |
**Response 200:**
```json
{
"odata.metadata": "https://.../$metadata#Items",
"value": [
{
"ItemCode": "A001",
"ItemName": "Artículo Demo",
"InventoryUOM": "UN"
}
],
"odata.count": 250
}
```
---
### `GET /api/Items/{item_code}`
Obtiene un artículo por `ItemCode`.
**Response 200:**
```json
{
"ItemCode": "A001",
"ItemName": "Artículo Demo",
"InventoryUOM": "UN",
"SalesPrice": 100.50
}
```
---
### `POST /api/Items`
Crea un nuevo artículo.
**Request body:**
```json
{
"ItemCode": "A001",
"ItemName": "Artículo Demo",
"InventoryUOM": "UN"
}
```
**Response 201:**
```json
{
"ItemCode": "A001"
}
```
---
### `PATCH /api/Items/{item_code}`
Actualiza un artículo existente.
**Request body:**
```json
{
"ItemName": "Nuevo Nombre",
"SalesPrice": 120.00
}
```
**Response 204:** Sin contenido.
---
## Ventas
### Facturas (A/R Invoices)
#### `GET /api/Invoices`
Lista paginada de facturas de venta.
**Query parameters:**
| Parámetro | Tipo | Default | Descripción |
|-----------|------|---------|-------------|
| `skip` | int | 0 | Registros a saltar |
| `top` | int | 50 | Registros por página |
| `fields` | string | — | Campos a retornar |
| `filter` | string | — | Filtro OData |
| `orderby` | string | — | Ordenamiento (`$orderby`) |
**Response 200:**
```json
{
"odata.metadata": "https://.../$metadata#Invoices",
"value": [
{
"DocEntry": 1001,
"CardCode": "C001",
"DocTotal": 1500.00,
"DocDate": "2026-07-06"
}
],
"odata.count": 45
}
```
---
#### `POST /api/Invoices`
Crea una factura de venta.
**Response 201:**
```json
{
"DocEntry": 1001
}
```
---
#### `GET /api/Invoices/{invoice_key}`
Obtiene una factura por `DocEntry`.
---
#### `PATCH /api/Invoices/{invoice_key}`
Actualiza una factura. Usa header `B1S-ReplaceCollectionsOnPatch: true`.
**Response 204:** Sin contenido.
---
### Pedidos (Sales Orders)
#### `GET /api/Orders`
Lista paginada de pedidos de venta. Mismos parámetros que Invoices.
#### `POST /api/Orders`
Crea un pedido de venta.
**Response 201:**
```json
{
"DocEntry": 2001
}
```
#### `GET /api/Orders/{order_key}`
Obtiene un pedido por `DocEntry`.
#### `PATCH /api/Orders/{order_key}`
Actualiza un pedido. Usa header `B1S-ReplaceCollectionsOnPatch: true`.
**Response 204:** Sin contenido.
---
## Compras
### Órdenes de Compra
#### `GET /api/PurchaseOrders`
Lista paginada. Mismos parámetros que Invoices/Orders.
#### `GET /api/PurchaseOrders/{key}`
Obtiene una orden de compra por `DocEntry`.
#### `PATCH /api/PurchaseOrders/{key}`
Actualiza una orden de compra. Usa header `B1S-ReplaceCollectionsOnPatch: true`.
**Response 204:** Sin contenido.
---
### Facturas de Compra (A/P Invoices)
#### `GET /api/PurchaseInvoices`
Lista paginada. Mismos parámetros que Invoices/Orders.
#### `GET /api/PurchaseInvoices/{key}`
Obtiene una factura de compra por `DocEntry`.
#### `PATCH /api/PurchaseInvoices/{key}`
Actualiza una factura de compra. Usa header `B1S-ReplaceCollectionsOnPatch: true`.
**Response 204:** Sin contenido.
---
## Catálogos
### `GET /api/Currencies`
Lista de monedas.
**Response 200:**
```json
{
"value": [
{ "Code": "USD", "Name": "US Dollar" },
{ "Code": "EUR", "Name": "Euro" }
]
}
```
---
### `GET /api/BusinessPartnerGroups`
Lista de grupos de socios de negocio.
**Response 200:**
```json
{
"value": [
{ "Number": 1, "GroupName": "Clientes Nacionales" }
]
}
```
---
### `GET /api/Warehouses`
Lista paginada de almacenes. Acepta `skip`, `top`, `fields`, `filter`.
---
### `GET /api/ItemGroups`
Lista de grupos de artículos.
**Response 200:**
```json
{
"value": [
{ "Number": 1, "GroupName": "Materia Prima" }
]
}
```
---
### `GET /api/SalesTaxCodes`
Lista paginada de códigos de IVA. Acepta `skip`, `top`, `fields`, `filter`.
---
## Lotes
### `GET /api/BatchNumberDetails`
Lista paginada de lotes/números de serie. Acepta `skip`, `top`, `fields`, `filter`, `orderby`.
---
### `GET /api/BatchNumberDetails/{batch_id}`
Obtiene un lote por ID.
---
### `PATCH /api/BatchNumberDetails/{batch_id}`
Actualiza un lote.
**Response 204:** Sin contenido.
---
## Permisos
### `GET /api/user/menu`
Retorna las rutas de menú permitidas para el empleado autenticado.
**Headers:** `X-Session-Token: <token>`
**Response 200:**
```json
{
"allowedPaths": [
"/inventario/*",
"/ventas/*",
"/compras/*"
]
}
```
**Response 401:**
```json
{
"detail": "Usuario no autenticado"
}
```
---
## Resumen de Códigos de Respuesta
| Método | Éxito | Cuerpo en éxito | Error SAP | Error de conexión |
|--------|-------|----------------|-----------|-------------------|
| GET | 200 | JSON del recurso | 4xx con `detail` | 500 |
| POST | 201 | `{"CardCode"/"ItemCode"/"DocEntry": valor}` | 4xx con `detail` | 500 |
| PATCH | 204 | Sin cuerpo | 4xx con `detail` | 500 |
+183
View File
@@ -0,0 +1,183 @@
# Arquitectura General
## Diagrama de Componentes
```mermaid
graph TB
subgraph "FastAPI Application"
M[main.py\nEntry Point]
C[context.py\nRequest ID Context]
L[logger_config.py\nStructured Logging]
subgraph "Core"
SAP[core/sap.py\nSAP Session & Proxy]
SES[core/session.py\nEmployee Sessions]
end
subgraph "Routers"
A[auth.py\nLogin / Logout]
BP[business_partners.py\nSocios de Negocio]
IT[items.py\nArtículos]
SL[sales.py\nFacturas / Pedidos]
PR[purchases.py\nÓrdenes / Facturas Compra]
CT[catalogs.py\nCatálogos]
BT[batch.py\nLotes]
PM[permissions.py\nPermisos de Menú]
end
MP[menu_permissions.json]
end
subgraph "External"
SAP_SL[SAP B1 Service Layer]
end
M --> C
M --> L
C --> L
M --> A
M --> BP
M --> IT
M --> SL
M --> PR
M --> CT
M --> BT
M --> PM
A --> SAP
A --> SES
BP --> SAP
IT --> SAP
SL --> SAP
PR --> SAP
CT --> SAP
BT --> SAP
PM --> MP
SAP --> SAP_SL
SES --> SAP
```
## Pipeline de Middleware
Las peticiones atraviesan los siguientes middleware en orden:
```mermaid
flowchart LR
REQ[Request] --> CORS[CORSMiddleware\nallow_origins=*]
CORS --> CID[correlation_id_middleware\nX-Request-ID]
CID --> SES_MW[session_middleware\nValida X-Session-Token]
SES_MW --> ROUTER{Router}
ROUTER -->|/api/Login| A[auth.py]
ROUTER -->|Otros /api/*| SAP_CORE[core/sap.py]
```
### Detalle de Middleware
| Middleware | Función |
| -------------------------- | ----------------------------------------------------------- |
| `CORSMiddleware` | Permite peticiones desde cualquier origen |
| `correlation_id_middleware` | Genera o extrae `X-Request-ID` para trazabilidad en logs |
| `session_middleware` | Valida token de sesión, adjunta `emp_id` a `request.state` |
## Arquitectura de Sesiones
Existen **dos capas de sesión independientes**:
```mermaid
flowchart LR
subgraph Employee["Employee Session"]
T["Token aleatorio → emp_id"]
E["employee_sessions dict"]
TK["active_tokens dict"]
T --> TK
TK --> E
end
subgraph SAP["SAP B1 Session"]
S["sap_session_store"]
M["Manager Credentials (.env)"]
S -.->|Renueva en 401| M
end
```
| Capa | Almacenamiento | Ámbito | Timeout | Renovación |
| ------------- | ----------------- | -------- | ------------- | -------------------- |
| SAP B1 | `sap_session_store` | Global | Gestionado SAP | Automática al recibir 401 |
| Empleado | `active_tokens` | Por empleado | 30 min inactividad | Login manual |
## Estructura de Rutas
| Método | Ruta | Router | Descripción |
| -------- | --------------------------------- | -------------------- | ------------------------ |
| `POST` | `/api/Login` | `auth.py` | Autenticar empleado |
| `POST` | `/api/Logout` | `auth.py` | Cerrar sesión |
| `GET` | `/api/BusinessPartners` | `business_partners` | Listar socios |
| `GET` | `/api/BusinessPartners/{code}` | `business_partners` | Obtener socio |
| `POST` | `/api/BusinessPartners` | `business_partners` | Crear socio |
| `PATCH` | `/api/BusinessPartners/{code}` | `business_partners` | Actualizar socio |
| `GET` | `/api/Items` | `items.py` | Listar artículos |
| `GET` | `/api/Items/{code}` | `items.py` | Obtener artículo |
| `POST` | `/api/Items` | `items.py` | Crear artículo |
| `PATCH` | `/api/Items/{code}` | `items.py` | Actualizar artículo |
| `GET` | `/api/Invoices` | `sales.py` | Listar facturas |
| `GET` | `/api/Invoices/{key}` | `sales.py` | Obtener factura |
| `POST` | `/api/Invoices` | `sales.py` | Crear factura |
| `PATCH` | `/api/Invoices/{key}` | `sales.py` | Actualizar factura |
| `GET` | `/api/Orders` | `sales.py` | Listar pedidos |
| `GET` | `/api/Orders/{key}` | `sales.py` | Obtener pedido |
| `POST` | `/api/Orders` | `sales.py` | Crear pedido |
| `PATCH` | `/api/Orders/{key}` | `sales.py` | Actualizar pedido |
| `GET` | `/api/PurchaseOrders` | `purchases.py` | Listar órdenes de compra |
| `GET` | `/api/PurchaseOrders/{key}` | `purchases.py` | Obtener orden de compra |
| `PATCH` | `/api/PurchaseOrders/{key}` | `purchases.py` | Actualizar orden compra |
| `GET` | `/api/PurchaseInvoices` | `purchases.py` | Listar facturas compra |
| `GET` | `/api/PurchaseInvoices/{key}` | `purchases.py` | Obtener factura compra |
| `PATCH` | `/api/PurchaseInvoices/{key}` | `purchases.py` | Actualizar factura compra|
| `GET` | `/api/Currencies` | `catalogs.py` | Listar monedas |
| `GET` | `/api/BusinessPartnerGroups` | `catalogs.py` | Listar grupos de socios |
| `GET` | `/api/Warehouses` | `catalogs.py` | Listar almacenes |
| `GET` | `/api/ItemGroups` | `catalogs.py` | Listar grupos artículos |
| `GET` | `/api/SalesTaxCodes` | `catalogs.py` | Listar códigos de IVA |
| `GET` | `/api/BatchNumberDetails` | `batch.py` | Listar lotes |
| `GET` | `/api/BatchNumberDetails/{id}` | `batch.py` | Obtener lote |
| `PATCH` | `/api/BatchNumberDetails/{id}` | `batch.py` | Actualizar lote |
| `GET` | `/api/user/menu` | `permissions.py` | Obtener menú permitido |
## Dependencias del Proyecto
```mermaid
graph TD
main --> context
main --> logger_config
logger_config --> context
main --> auth
main --> business_partners
main --> items
main --> sales
main --> purchases
main --> catalogs
main --> batch
main --> permissions
auth --> sap_core
auth --> session_core
business_partners --> sap_core
items --> sap_core
sales --> sap_core
purchases --> sap_core
catalogs --> sap_core
batch --> sap_core
permissions --> menu_json[menu_permissions.json]
```
## Configuración por Entorno
La aplicación carga siempre `.env` (un solo archivo, sin sufijos de entorno).
`.env.aberio` se conserva como plantilla de referencia; para desplegar contra
ese entorno, copiar su contenido dentro de `.env`.
| Archivo | URL SAP | Company DB |
| ------------------ | ------------------------------- | ----------------------- |
| `.env` | `192.168.1.175:50000` | `SBO_PRUEBA_TAGUM_22` |
| `.env.aberio` | `192.168.9.3:50000` | `PRUEBA_20260609` |
+108
View File
@@ -0,0 +1,108 @@
# Flujo de Autenticación
## Login
```mermaid
sequenceDiagram
participant F as Frontend
participant API as FastAPI
participant S as core/session.py
participant SAP_C as core/sap.py
participant SAP as SAP Service Layer
F->>API: POST /api/Login\n{Username, Password}
Note over F,API: Username = emp_id\nPassword = LastName
API->>SAP_C: forward_to_sapb1(GET, "EmployeesInfo({empId})")
SAP_C->>SAP: GET /b1s/v1/EmployeesInfo({empId})
SAP-->>SAP_C: {FirstName, LastName, ...}
SAP_C-->>API: Employee data
API->>API: ¿Password == LastName?
alt Coincide
API->>S: create_employee_session(emp_id, first_name, last_name)
S->>S: Genera token_urlsafe(32)
S-->>API: token
API->>SAP_C: ensure_sap_session()
SAP_C->>SAP: POST /Login (manager creds)
SAP-->>SAP_C: B1SESSION cookie
SAP_C-->>API: OK
API-->>F: 200 {message, token, username}
else No coincide
API-->>F: 401 Invalid credentials
end
```
## Logout
```mermaid
sequenceDiagram
participant F as Frontend
participant API as FastAPI
participant S as core/session.py
F->>API: POST /api/Logout\nX-Session-Token: <token>
API->>S: remove_employee_session_by_token(token)
S->>S: Elimina active_tokens[token]\ny employee_sessions[emp_id]
S-->>API: OK
API-->>F: 200 {message: "Sesión cerrada"}
```
## Validación de Sesión (Middleware)
Cada petición a `/api/*` (excepto `/api/Login`) pasa por el `session_middleware`:
```mermaid
sequenceDiagram
participant F as Frontend
participant MW as session_middleware
participant S as core/session.py
participant R as Router
F->>MW: Request /api/Items\nX-Session-Token: <token>
MW->>S: validate_and_refresh_session(token)
S->>S: Busca token en active_tokens
alt Token válido
S->>S: Actualiza last_activity
S-->>MW: emp_id
MW->>MW: Adjunta emp_id a request.state
MW->>R: Continúa al router
R-->>F: 200 Response
else Token expirado (>30 min)
S->>S: Elimina token y sesión
S-->>MW: None
MW-->>F: 401 Token expirado
else Token no existe
S-->>MW: None
MW-->>F: 401 No autorizado
end
```
## Ciclo de Vida de la Sesión de Empleado
```mermaid
stateDiagram-v2
[*] --> Activa: Login exitoso\ngenera token
Activa --> Expirada: 30 min sin actividad
Activa --> Cerrada: Logout explícito
Expirada --> [*]
Cerrada --> [*]
note right of Activa
Se refresca last_activity
en cada petición válida
end note
```
## Mecanismo de Renovación
La sesión del empleado se renueva automáticamente en **cada petición válida**:
1. El frontend envía `X-Session-Token` en el header
2. `validate_and_refresh_session()` actualiza `last_activity` al timestamp actual
3. El timeout de 30 minutos se mide desde la última actividad
4. Si pasan 30 min sin peticiones, el token se invalida automáticamente
+45
View File
@@ -0,0 +1,45 @@
# Backend SN — Documentación del Proyecto
Backend de integración entre una aplicación frontend (Next.js) y **SAP Business One Service Layer**. Actúa como proxy/middleware autenticando empleados, gestionando sesiones y enrutando operaciones CRUD hacia SAP.
## Stack Tecnológico
| Componente | Tecnología |
| ------------- | ----------------- |
| Framework | FastAPI (Python) |
| Servidor ASGI | Uvicorn |
| HTTP Client | `requests` |
| Logging | JSON estructurado |
| Sesiones | In-memory |
## Diagrama de Contexto del Sistema
```mermaid
graph TB
subgraph "Frontend"
F[App SN - Next.js]
end
subgraph "Backend SN - FastAPI"
API[API Proxy\n:8000]
end
subgraph "SAP Business One"
SL[Service Layer\nREST API]
DB[(SAP DB\nSBO_PRUEBA_*)]
end
F -->|HTTP /api/*| API
API -->|HTTP /b1s/v1| SL
SL --> DB
```
## Documentos
| Documento | Descripción |
| -------------------------------------------- | -------------------------------------------- |
| [Arquitectura](architecture.md) | Componentes, dependencias, middlewares, APIs |
| [Flujo de Autenticación](auth-flow.md) | Login, logout, sesiones, middleware |
| [Proxy SAP](sap-proxy.md) | Enrutamiento a SAP, re-autenticación, OData |
| [API Reference](api-reference.md) | Todos los endpoints, ejemplos request/response |
| [Visualización de logs](logs-viewing.md) | Cómo leer `logs/backend.log` con `jq`/`lnav` |
+175
View File
@@ -0,0 +1,175 @@
# Plan: logging real por sesión de usuario
## Problema actual (diagnosticado)
- `main.py:32` fuerza `setup_logging(environment="debug")` siempre — el modo
`"production"` (JSON estructurado) de `logger_config.py` nunca se activa,
sin importar el entorno real de despliegue.
- `core/sap.py`, `core/api_gateway.py` y `routers/auth.py` usan `print()` en
vez del `logger` real. Esto significa:
- No queda nada en el archivo rotativo `logs/backend.log` (solo en la
consola / `docker compose logs`).
- No tiene formato JSON buscable, ni `request_id` de correlación.
- **Grave**: `routers/auth.py` imprime la contraseña (apellido) en texto
plano dos veces:
- línea ~27: `print(f"Contraseña (Apellido) recibida: {empLastName}")`
- línea ~45: `print(f"Apellido en SAP: {employee_data.get('LastName')}")`
## Objetivo
Además de arreglar lo anterior, dejar un log **por sesión de usuario
conectado**: que cada línea de log quede etiquetada con qué empleado
(`emp_id` + nombre) hizo qué acción (`GET /Items`, `POST /BusinessPartners`,
etc.), en JSON, para poder cargarlo después en un monitor/dashboard y buscar
acciones por usuario.
## Diseño
`context.py` ya tiene un `ContextVar` para el `request_id` que se inyecta
automáticamente en cada log a través de `logger_config.JsonFormatter`. Se
replica el mismo patrón para el usuario conectado, así **todo log queda
etiquetado automáticamente sin tocar cada llamada individual**.
### 1. `context.py`
Agregar un `ContextVar` para el usuario actual, mismo patrón que
`request_id_ctx`:
```python
current_user_ctx: ContextVar[dict] = ContextVar(
"current_user", default={"emp_id": "-", "username": "-"}
)
def get_current_user() -> dict:
return current_user_ctx.get()
def set_current_user(emp_id: str, username: str):
current_user_ctx.set({"emp_id": emp_id, "username": username})
```
### 2. `core/session.py`
Agregar un helper que devuelva "Nombre Apellido" a partir de `emp_id`,
leyendo el dict `employee_sessions` que ya existe:
```python
def get_employee_name(emp_id: str) -> str:
session = employee_sessions.get(emp_id)
if not session:
return "-"
return f"{session.get('first_name', '')} {session.get('last_name', '')}".strip()
```
### 3. `main.py`
En `session_middleware`, justo después de validar el token y obtener
`emp_id` (donde hoy se hace `request.state.emp_id = emp_id`), setear el
contexto de usuario:
```python
from core.session import validate_and_refresh_session, get_employee_name
from context import set_current_user
...
emp_id = validate_and_refresh_session(token)
if not emp_id:
...
set_current_user(emp_id, get_employee_name(emp_id))
request.state.emp_id = emp_id
```
También en este archivo: dejar de forzar `"debug"` en `setup_logging`,
leerlo de una variable de entorno nueva (por defecto `"production"`, para
que Docker quede bien sin configurar nada extra):
```python
LOG_ENVIRONMENT = os.getenv("LOG_ENVIRONMENT", "production")
...
@asynccontextmanager
async def lifespan(app: FastAPI):
setup_logging(environment=LOG_ENVIRONMENT)
yield
```
### 4. `logger_config.py`
Extender `JsonFormatter` para incluir `emp_id`/`username` desde el
contexto en cada línea, y soportar campos extra estructurados
(`http_method`, `endpoint`, `status_code`) cuando el log los trae vía
`extra={...}`:
```python
from context import get_request_id, get_current_user
class JsonFormatter(logging.Formatter):
EXTRA_FIELDS = ("http_method", "endpoint", "status_code")
def format(self, record):
user = get_current_user()
log_record = {
"timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
"level": record.levelname,
"request_id": get_request_id(),
"emp_id": user["emp_id"],
"username": user["username"],
"message": record.getMessage(),
"module": record.module,
"function": record.funcName,
"line": record.lineno,
}
for field in self.EXTRA_FIELDS:
if hasattr(record, field):
log_record[field] = getattr(record, field)
if record.exc_info:
log_record["exception"] = self.formatException(record.exc_info)
return json.dumps(log_record)
```
Resultado esperado por línea, por ejemplo:
```json
{"timestamp": "2026-08-14T...", "level": "INFO", "request_id": "...", "emp_id": "11", "username": "Juan Pérez", "http_method": "GET", "endpoint": "Items", "status_code": 200, "message": "Solicitud SAP completada", "module": "sap", "function": "forward_to_sapb1", "line": 137}
```
### 5. `core/sap.py` / `core/api_gateway.py`
Reemplazar cada `print(...)` por `logger.info/warning/error(...)`,
agregando `extra={"http_method": method, "endpoint": endpoint_path,
"status_code": response.status_code}` en las líneas de request/respuesta,
para que queden como campos estructurados y no solo texto libre. Quitar la
variable `I = " "` (indentación para prints, ya no hace falta) y los
emojis (el nivel del log ya indica severidad).
### 6. `routers/auth.py`
- Agregar `logger = logging.getLogger(__name__)`.
- Reemplazar todos los `print()` por `logger.info/warning/error(...)`.
- **No loguear nunca `empLastName` ni `employee_data.get('LastName')`** —
solo el `emp_id` y si el login fue exitoso o no.
- En cuanto se confirma el empleado (login exitoso), llamar
`set_current_user(empId, f"{first_name} {last_name}")` para que el
propio log del login quede etiquetado también (el middleware no aplica
a `/api/Login` porque no requiere token todavía).
### 7. `docker-compose.yml`
Agregar volumen para que el log persista en el host aunque se reconstruya
el contenedor (si no, se perdería cada `--build`, igual que pasaba con
`.env` antes de agregar su volumen):
```yaml
backend:
volumes:
- ./mini-backend/menu_permissions.json:/app/menu_permissions.json:ro
- ./mini-backend/logs:/app/logs
```
## Verificación al implementar
1. Levantar el backend (local o Docker).
2. Loguearse con un usuario de prueba y hacer un par de llamadas reales
(GET a algún catálogo, POST/PATCH a algo).
3. Revisar `logs/backend.log`: cada línea debe traer `emp_id`, `username`,
`http_method`, `endpoint`, `status_code` correctos.
4. `grep` de la contraseña de prueba sobre todo `logs/backend.log` (y la
salida de consola) para confirmar que **no aparece en ningún lado**.
5. Confirmar que `docker compose build` sigue funcionando sin errores.
+56
View File
@@ -0,0 +1,56 @@
# Visualización de logs
El backend escribe en `logs/backend.log` una línea JSON por evento (ver
[logging-plan.md](logging-plan.md)), con campos como `emp_id`, `username`,
`http_method`, `endpoint` y `status_code`. Es el formato correcto para
buscar/cargar en un dashboard, pero incómodo de leer a simple vista con
`cat`/`tail`. Estas son las dos formas recomendadas de verlo cómodo desde la
terminal.
## `jq` — filtrar y colorear en vivo
Instalación: `brew install jq`
```bash
# seguir el log en vivo, con formato e indentación
tail -f logs/backend.log | jq .
# solo las líneas de un empleado puntual
tail -f logs/backend.log | jq 'select(.emp_id == 10)'
# solo errores/warnings
tail -f logs/backend.log | jq 'select(.level == "ERROR" or .level == "WARNING")'
# solo llamadas a un endpoint específico
tail -f logs/backend.log | jq 'select(.endpoint == "Items")'
# ver solo unos campos, en una línea por evento
tail -f logs/backend.log | jq -c '{ts: .timestamp, user: .username, ep: .endpoint, status: .status_code}'
```
## `lnav` — visor interactivo
Instalación: `brew install lnav`
```bash
lnav logs/backend.log
```
`lnav` detecta el formato JSON automáticamente y da un visor interactivo con:
- colores por nivel (`INFO`/`WARNING`/`ERROR`).
- búsqueda de texto con `/`.
- filtros en vivo con `:filter-in` / `:filter-out` (por ejemplo
`:filter-in emp_id.:.10` para quedarte solo con el empleado 10).
- queries tipo SQL sobre los campos del log con `;` (por ejemplo
`;SELECT emp_id, username, endpoint, status_code FROM backend_log WHERE level = 'ERROR'`).
Es la opción más cómoda para explorar el log completo o investigar un
incidente puntual sin escribir comandos `jq` largos.
## Dashboard centralizado (a futuro)
Si más adelante se necesita buscar por usuario desde una UI (no la terminal)
o cruzar logs de varios despliegues, la opción estándar es enviar
`logs/backend.log` a algo como Grafana Loki + Promtail. Es infraestructura
adicional, no necesaria mientras el volumen de logs se maneje bien con `jq`/`lnav`.
+118
View File
@@ -0,0 +1,118 @@
# Flujo de Proxy SAP
## Función Central: `forward_to_sapb1`
Todas las operaciones CRUD pasan por esta función en `core/sap.py`, que actúa como proxy hacia SAP Service Layer.
```mermaid
flowchart TD
START([Inicio]) --> INPUT["method, endpoint_path,\njson_data, params, headers"]
INPUT --> BUILD["Construye URL:\nSAPB1_SL_URL / endpoint_path"]
BUILD --> HEADERS["Adjunta headers:\nCookie: B1SESSION=...\nContent-Type: application/json"]
HEADERS --> SEND[Envía request a SAP]
SEND --> CHECK{Respuesta}
CHECK -->|2xx| SUCCESS[Retorna Response al router]
CHECK -->|401| REFRESH["Force refresh:\nensure_sap_session()"]
CHECK -->|Timeout / Error| FAIL[ConnectionError]
REFRESH --> RETRY{Es reintento?}
RETRY -->|No| SEND2["Reintenta request\ncon nueva sesión"]
RETRY -->|Sí| ERROR["Extrae error.message.value\nde SAP y lanza HTTPException"]
SEND2 --> CHECK2{Respuesta}
CHECK2 -->|2xx| SUCCESS
CHECK2 -->|4xx/5xx| ERROR
SUCCESS --> END([Fin])
FAIL --> END
ERROR --> END
style ERROR fill:#f96,stroke:#a33
```
## Diagrama de Secuencia — Operación CRUD Típica
Ejemplo con `GET /api/Items`:
```mermaid
sequenceDiagram
participant F as Frontend
participant MW as Middleware
participant R as router/items.py
participant C as core/sap.py
participant SAP as SAP Service Layer
F->>MW: GET /api/Items?$skip=0&$top=20\nX-Session-Token: [token]
MW->>MW: Valida sesión
MW->>R: request.state.emp_id = "123"
R->>C: forward_to_sapb1(\n GET, "Items?$skip=0&$top=20&$inlinecount=allpages")
C->>SAP: GET /b1s/v1/Items?$skip=0&$top=20\nCookie: B1SESSION=abc123
SAP-->>C: 200 - value: [...], odata.count: 150
C-->>R: Response
R-->>F: 200 - data: [...], total: 150
```
## Re-autenticación Automática ante 401
Cuando SAP rechaza la sesión actual (401), el sistema renueva automáticamente la sesión de SAP y reintenta:
```mermaid
sequenceDiagram
participant R as Router
participant C as core/sap.py
participant SAP as SAP Service Layer
R->>C: forward_to_sapb1(GET, "Items?...")
C->>SAP: GET /b1s/v1/Items?...\nCookie: B1SESSION=abc123
SAP-->>C: 401 Unauthorized
Note over C: Sesión SAP expirada\n→ Re-autentica y reintenta
C->>SAP: POST /Login\nUserName: manager, Password: ..., CompanyDB: ...
SAP-->>C: 200 SessionId: xyz789
C->>C: Actualiza sap_session_store
C->>SAP: GET /b1s/v1/Items?...\nCookie: B1SESSION=xyz789
SAP-->>C: 200 - value: [...]
C-->>R: Response 200
Note over R,C: Si el reintento también falla (4xx/5xx)\n→ se extrae error.message.value\n→ se lanza HTTPException
```
## Parámetros OData y Headers SAP Soportados
### Query Parameters (OData)
| Parámetro | Uso | Ejemplo |
| -------------- | ---------------------------------- | -------------------------------- |
| `$skip` | Paginación — desplazamiento | `$skip=20` |
| `$top` | Paginación — límite por página | `$top=20` |
| `$select` | Selección de campos | `$select=ItemCode,ItemName` |
| `$filter` | Filtrado | `$filter=ItemCode eq 'A001'` |
| `$orderby` | Ordenamiento | `$orderby=ItemCode asc` |
| `$inlinecount` | Contar registros totales | `$inlinecount=allpages` |
### Headers SAP
| Header | Función |
| ----------------------------- | ----------------------------------------- |
| `B1S-PageSize` | Tamaño de página personalizado |
| `B1S-CaseInsensitive` | Búsqueda sin distinción mayúsc/minúscula |
| `B1S-ReplaceCollectionsOnPatch` | Reemplazar colecciones completas en PATCH |
## Manejo de Errores
| Situación | Comportamiento |
| ----------------------- | ---------------------------------------------------------- |
| SAP devuelve 2xx | Retorna `Response` al router (200 body, 201 body, 204 vacío) |
| SAP devuelve 401 | Re-autentica automáticamente y reintenta 1 vez |
| SAP devuelve 4xx/5xx | Extrae `error.message.value` y lanza `HTTPException(status_code, detail)` |
| Timeout de conexión | Lanza `ConnectionError` |
| Error de red | Lanza `ConnectionError` |
| Reintento también falla | Lanza `HTTPException` con el mensaje de error SAP |
| SAP no disponible | `HTTPException(500)` |