primer commit

This commit is contained in:
George committed 2026-08-17 11:58:38 -05:00
1 parent ab3c613165
commit b2fb4e2d0a
18 files changed
+1291 -277

No files matched your search

+208 -4
View File
@@ -39,14 +39,17 @@ Todas las respuestas de error siguen el mismo formato:
### `POST /api/Login`
Inicia sesión de empleado. Valida contra SAP EmployeesInfo usando el apellido como contraseña.
Inicia sesión de empleado. Busca en SAP `EmployeesInfo` al empleado cuyo
campo de usuario `U_WEB_USER` coincida con `Username`, y valida `Password`
contra su campo `U_WEB_PASS`. También exige que el empleado esté activo
(`Active == "tYES"`).
**Request body:**
```json
{
"Username": "123",
"Password": "Garcia"
"Username": "jgarcia",
"Password": "********"
}
```
@@ -56,7 +59,8 @@ Inicia sesión de empleado. Valida contra SAP EmployeesInfo usando el apellido c
{
"message": "Login exitoso",
"token": "aB3x...token_urlsafe_32_chars...",
"username": "Juan"
"username": "Juan",
"company": "NOMBRE_COMPANYDB"
}
```
@@ -68,6 +72,10 @@ Inicia sesión de empleado. Valida contra SAP EmployeesInfo usando el apellido c
}
```
Otros posibles `detail` en 401: `"Usuario no registrado"` (no existe ningún
empleado con ese `U_WEB_USER`) y `"Usuario inactivo"` (el empleado existe
pero `Active != "tYES"`).
---
### `POST /api/Logout`
@@ -364,6 +372,36 @@ Actualiza un pedido. Usa header `B1S-ReplaceCollectionsOnPatch: true`.
---
### Guías de Remisión de Venta (Delivery Notes)
#### `GET /api/DeliveryNotes`
Lista paginada de guías de remisión de venta. Mismos parámetros que Invoices/Orders.
#### `POST /api/DeliveryNotes`
Crea una guía de remisión de venta.
**Response 201:**
```json
{
"DocEntry": 3001
}
```
#### `GET /api/DeliveryNotes/{key}`
Obtiene una guía de remisión por `DocEntry`.
#### `PATCH /api/DeliveryNotes/{key}`
Actualiza una guía de remisión. Usa header `B1S-ReplaceCollectionsOnPatch: true`.
**Response 204:** Sin contenido.
---
## Compras
### Órdenes de Compra
@@ -372,6 +410,18 @@ Actualiza un pedido. Usa header `B1S-ReplaceCollectionsOnPatch: true`.
Lista paginada. Mismos parámetros que Invoices/Orders.
#### `POST /api/PurchaseOrders`
Crea una orden de compra.
**Response 201:**
```json
{
"DocEntry": 4001
}
```
#### `GET /api/PurchaseOrders/{key}`
Obtiene una orden de compra por `DocEntry`.
@@ -390,6 +440,18 @@ Actualiza una orden de compra. Usa header `B1S-ReplaceCollectionsOnPatch: true`.
Lista paginada. Mismos parámetros que Invoices/Orders.
#### `POST /api/PurchaseInvoices`
Crea una factura de compra.
**Response 201:**
```json
{
"DocEntry": 4501
}
```
#### `GET /api/PurchaseInvoices/{key}`
Obtiene una factura de compra por `DocEntry`.
@@ -402,6 +464,36 @@ Actualiza una factura de compra. Usa header `B1S-ReplaceCollectionsOnPatch: true
---
### Guías de Recepción de Compra (Purchase Delivery Notes)
#### `GET /api/PurchaseDeliveryNotes`
Lista paginada. Mismos parámetros que Invoices/Orders.
#### `POST /api/PurchaseDeliveryNotes`
Crea una guía de recepción de compra.
**Response 201:**
```json
{
"DocEntry": 5001
}
```
#### `GET /api/PurchaseDeliveryNotes/{key}`
Obtiene una guía de recepción de compra por `DocEntry`.
#### `PATCH /api/PurchaseDeliveryNotes/{key}`
Actualiza una guía de recepción de compra. Usa header `B1S-ReplaceCollectionsOnPatch: true`.
**Response 204:** Sin contenido.
---
## Catálogos
### `GET /api/Currencies`
@@ -465,6 +557,31 @@ Lista paginada de códigos de IVA. Acepta `skip`, `top`, `fields`, `filter`.
---
### `GET /api/FactoringIndicators`
Lista paginada de indicadores de factoring. Acepta `skip`, `top`, `fields`, `filter`.
---
### `GET /api/Sunat/BusinessPartners/DocumentType`
Lista los valores del campo de usuario `EXX_TIPODOCU` (tabla `OCRD`, tipo de
documento SUNAT del socio de negocio), consultando `UserFieldsMD` en SAP.
Endpoint fijo, sin parámetros de paginación.
**Response 200:**
```json
{
"value": [
{ "FieldID": 1, "Description": "DNI" },
{ "FieldID": 2, "Description": "RUC" }
]
}
```
---
## Lotes
### `GET /api/BatchNumberDetails`
@@ -517,6 +634,93 @@ Retorna las rutas de menú permitidas para el empleado autenticado.
---
## Reportes (API Gateway — Crystal Reports)
Estos endpoints no van contra el Service Layer sino contra el **SAP API
Gateway** (`core/api_gateway.py`), que expone los reportes Crystal Reports
autorizados para la compañía.
### `GET /api/apigateway/reports`
Lista los reportes Crystal Reports autorizados para el usuario/compañía.
**Response 200:**
```json
[
{ "DocCode": "R001", "DocName": "Guía de Remisión" }
]
```
---
### `GET /api/apigateway/reports/{docCode}/parameters`
Obtiene los parámetros de entrada que requiere un reporte puntual (por
`DocCode`) antes de exportarlo.
---
### `POST /api/apigateway/reports/{docCode}/export`
Exporta el reporte a PDF. El body es la lista de parámetros que el reporte
necesita (formato definido por el propio API Gateway / Crystal Reports).
**Request body:**
```json
[
{ "name": "DocEntry", "value": "1001" }
]
```
**Response 200:** `application/pdf` (streaming, `Content-Disposition: inline`).
---
## Reportes (Jinja/HTML → PDF)
Generados desde plantillas HTML propias (`templates/reports/`) renderizadas
con Jinja2 y convertidas a PDF con WeasyPrint, a partir de datos consultados
directamente en HANA (`core/hana.py`), sin pasar por el Service Layer.
### `POST /api/reports/stickers`
Genera el sticker de una guía de remisión de venta (`DeliveryNotes`) para
imprimir etiquetas de despacho.
**Request body:**
```json
{
"doc_entry": 3001,
"cantidad": 4,
"formato": "pdf",
"papel": "Carta"
}
```
| Campo | Tipo | Requerido | Descripción |
|-------|------|-----------|-------------|
| `doc_entry` | int | Sí | `DocEntry` de la guía de remisión en HANA |
| `cantidad` | int | Sí | Cantidad de stickers a generar |
| `formato` | `"pdf"` \| `"html"` | No (default `"pdf"`) | Formato de salida |
| `papel` | `"A4"` \| `"Carta"` | No (default `"Carta"`) | Tamaño de papel del PDF |
**Response 200:** `application/pdf` o `text/html` según `formato`, streaming.
**Response 404:**
```json
{
"detail": "No se encontró el documento 3001"
}
```
**Response 500:** error al consultar HANA.
---
## Resumen de Códigos de Respuesta
| Método | Éxito | Cuerpo en éxito | Error SAP | Error de conexión |
+129 -65
View File
@@ -12,24 +12,32 @@ graph TB
subgraph "Core"
SAP[core/sap.py\nSAP Session & Proxy]
SES[core/session.py\nEmployee Sessions]
HANA[core/hana.py\nConexión directa HANA]
AG[core/api_gateway.py\nSesión y Proxy APIGateway]
CRUD[core/crud_router.py\nFactory de routers CRUD OData]
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]
SL[sales.py\nFacturas / Pedidos / Guías]
PR[purchases.py\nÓrdenes / Facturas / Guías Compra]
CT[catalogs.py\nCatálogos]
BT[batch.py\nLotes]
PM[permissions.py\nPermisos de Menú]
APG[apigateway.py\nCrystal Reports]
RP[reports.py\nReportes Jinja/HTML → PDF]
end
MP[menu_permissions.json]
TPL[templates/reports/*.html]
end
subgraph "External"
SAP_SL[SAP B1 Service Layer]
SAP_AG[SAP B1 API Gateway\nrs/v1/*]
SAP_HANA[(SAP HANA)]
end
M --> C
@@ -44,21 +52,42 @@ graph TB
M --> CT
M --> BT
M --> PM
M --> APG
M --> RP
A --> SAP
A --> SES
BP --> SAP
IT --> SAP
SL --> SAP
PR --> SAP
CT --> SAP
BT --> SAP
BP --> CRUD
IT --> CRUD
SL --> CRUD
PR --> CRUD
CT --> CRUD
BT --> CRUD
CRUD --> SAP
PM --> MP
APG --> AG
RP --> HANA
RP --> TPL
SAP --> SAP_SL
SES --> SAP
AG --> SAP_AG
HANA --> SAP_HANA
```
`business_partners.py`, `items.py`, `sales.py`, `purchases.py`, `batch.py` y
la mayor parte de `catalogs.py` ya no implementan cada endpoint a mano: usan
`core/crud_router.py`, una factory (`make_crud_router`) que genera el
`list/get/create/update` típico de un recurso OData (mismo bloque de
`$skip`/`$top`/`$filter` y llamada a `forward_to_sapb1`) a partir de un
nombre de recurso y unos flags (si soporta `$orderby`, si expone `POST`,
headers extra para el `PATCH`, etc). Esto evita que un copy-paste entre
recursos deje, por ejemplo, un `PATCH` apuntando al endpoint de otro
recurso. Un router de documentos de mercadeo (ventas o compras) puede
generar varios recursos a la vez iterando esa factory, por eso `sales.py` y
`purchases.py` ya no son un único router sino un `APIRouter` que incluye uno
por cada tipo de documento.
## Pipeline de Middleware
Las peticiones atraviesan los siguientes middleware en orden:
@@ -70,7 +99,7 @@ flowchart LR
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]
ROUTER -->|Otros /api/*| DEST[routers → core/sap.py, core/api_gateway.py o core/hana.py]
```
### Detalle de Middleware
@@ -79,71 +108,86 @@ flowchart LR
| -------------------------- | ----------------------------------------------------------- |
| `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` |
| `session_middleware` | Valida token de sesión, adjunta el usuario (`webUser`) a `request.state` y al contexto de logging |
## Arquitectura de Sesiones
Existen **dos capas de sesión independientes**:
Existen **tres capas de sesión/credenciales independientes**:
```mermaid
flowchart LR
subgraph Employee["Employee Session"]
T["Token aleatorio → emp_id"]
T["Token aleatorio → webUser"]
E["employee_sessions dict"]
TK["active_tokens dict"]
T --> TK
TK --> E
end
subgraph SAP["SAP B1 Session"]
subgraph SAP["SAP B1 Service Layer Session"]
S["sap_session_store"]
M["Manager Credentials (.env)"]
S -.->|Renueva en 401| M
MC["Manager Credentials (.env: SAPB1_SL_*)"]
S -.->|Renueva en 401| MC
end
subgraph AG["SAP API Gateway Session"]
AS["api_gateway_session (requests.Session)"]
AC["Manager Credentials (.env: APIGATEWAY_*)"]
AS -.->|Renueva en 401| AC
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 |
| Capa | Almacenamiento | Ámbito | Timeout | Renovación |
| -------------- | -------------------- | ------------ | ------------------- | --------------------------- |
| SAP Service Layer | `sap_session_store` (`core/sap.py`) | Global | Gestionado SAP | Automática al recibir 401 |
| SAP API Gateway | `api_gateway_session` (`core/api_gateway.py`) | Global | Gestionado SAP | Automática al recibir 401 |
| Empleado | `active_tokens` / `employee_sessions` (`core/session.py`) | Por empleado | 30 min inactividad | Login manual |
`core/hana.py` no mantiene sesión: abre una conexión nueva a HANA por
petición (`hana_connection()`, context manager) y la cierra al terminar.
## 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 |
| -------- | --------------------------------- | --------------------- | ------------------------ |
| `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/POST/PATCH` | `/api/Invoices[/{key}]` | `sales.py` | Facturas de venta |
| `GET/POST/PATCH` | `/api/Orders[/{key}]` | `sales.py` | Pedidos de venta |
| `GET/POST/PATCH` | `/api/DeliveryNotes[/{key}]` | `sales.py` | Guías de remisión de venta |
| `GET/PATCH` | `/api/PurchaseOrders[/{key}]` | `purchases.py` | Órdenes de compra |
| `GET/PATCH` | `/api/PurchaseDeliveryNotes[/{key}]` | `purchases.py` | Guías de recepción de compra |
| `GET/PATCH` | `/api/PurchaseInvoices[/{key}]` | `purchases.py` | Facturas de compra |
| `GET` | `/api/Currencies` | `catalogs.py` | Listar monedas |
| `GET` | `/api/BusinessPartnerGroups` | `catalogs.py` | Listar grupos de socios |
| `GET` | `/api/ItemGroups` | `catalogs.py` | Listar grupos artículos |
| `GET` | `/api/Warehouses` | `catalogs.py` | Listar almacenes |
| `GET` | `/api/SalesTaxCodes` | `catalogs.py` | Listar códigos de IVA |
| `GET` | `/api/FactoringIndicators` | `catalogs.py` | Listar indicadores de factoring |
| `GET` | `/api/Sunat/BusinessPartners/DocumentType` | `catalogs.py` | Tipos de documento SUNAT (campo de usuario `EXX_TIPODOCU`) |
| `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 |
| `GET` | `/api/apigateway/reports` | `apigateway.py` | Listar reportes Crystal Reports autorizados |
| `GET` | `/api/apigateway/reports/{docCode}/parameters` | `apigateway.py` | Parámetros de un reporte Crystal Reports |
| `POST` | `/api/apigateway/reports/{docCode}/export` | `apigateway.py` | Exportar reporte Crystal Reports a PDF |
| `POST` | `/api/reports/stickers` | `reports.py` | Generar PDF/HTML de stickers de guía de remisión (consulta directa a HANA + plantilla Jinja + WeasyPrint) |
Los recursos servidos por `core/crud_router.py` (todos los de la tabla salvo
`auth`, `permissions`, `apigateway` y `reports`) soportan además `skip`,
`top`, `fields` y `filter` como query params, y algunos (`Invoices`,
`Orders`, `DeliveryNotes`, `PurchaseOrders`, `PurchaseDeliveryNotes`,
`PurchaseInvoices`, `BatchNumberDetails`) también `orderby`. Ver
[API Reference](api-reference.md) para el detalle completo por endpoint.
## Dependencias del Proyecto
@@ -160,24 +204,44 @@ graph TD
main --> catalogs
main --> batch
main --> permissions
main --> apigateway_router[apigateway]
main --> reports_router[reports]
auth --> sap_core
auth --> session_core
business_partners --> sap_core
items --> sap_core
sales --> sap_core
purchases --> sap_core
business_partners --> crud_router
items --> crud_router
sales --> crud_router
purchases --> crud_router
catalogs --> crud_router
catalogs --> sap_core
batch --> sap_core
batch --> crud_router
crud_router --> sap_core
permissions --> menu_json[menu_permissions.json]
apigateway_router --> api_gateway_core[api_gateway]
reports_router --> hana_core[hana]
reports_router --> templates_reports[templates/reports/*.html]
```
## 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`.
La aplicación carga siempre `mini-backend/.env` (un solo archivo por
despliegue, sin sufijos de entorno) mediante `load_dotenv`. Ese archivo
**no se versiona** (está en `.gitignore`); la plantilla de referencia con
todas las variables necesarias y valores de ejemplo es
[`mini-backend/.env.example`](../.env.example).
| Archivo | URL SAP | Company DB |
| ------------------ | ------------------------------- | ----------------------- |
| `.env` | `192.168.1.175:50000` | `SBO_PRUEBA_TAGUM_22` |
| `.env.aberio` | `192.168.9.3:50000` | `PRUEBA_20260609` |
Grupos de variables:
| Grupo | Variables | Usado por |
| --------------------- | -------------------------------------------------------------------------- | --------------------- |
| SAP Service Layer | `SAPB1_SL_URL`, `SAPB1_SL_USER`, `SAPB1_SL_PASSWORD`, `SAPB1_SL_COMPANYDB`, `SAPB1_SL_TIMEOUT`, `SAPB1_SL_VERIFY_SSL` | `core/sap.py`, `routers/auth.py` |
| SAP API Gateway | `APIGATEWAY_URL`, `APIGATEWAY_USER`, `APIGATEWAY_PASSWORD`, `APIGATEWAY_COMPANYDB`, `APIGATEWAY_DBINSTANCE`, `APIGATEWAY_TIMEOUT` | `core/api_gateway.py` |
| HANA (acceso directo) | `HANA_HOST`, `HANA_PORT`, `HANA_USER`, `HANA_PASSWORD`, `HANA_SCHEMA` | `core/hana.py`, `routers/reports.py` |
| Logging | `LOG_ENVIRONMENT` (`production` = JSON estructurado, cualquier otro valor = texto legible en consola) | `main.py`, `logger_config.py` |
Al desplegar para un cliente nuevo (ver flujo de branches por cliente en
`CLAUDE.md` de la raíz), se copia `.env.example` a `.env` dentro de
`mini-backend/` y se completa con las credenciales reales de SAP Business
One / HANA de ese cliente. Las credenciales reales nunca deben
documentarse ni commitearse.
+14 -14
View File
@@ -11,17 +11,17 @@ sequenceDiagram
participant SAP as SAP Service Layer
F->>API: POST /api/Login\n{Username, Password}
Note over F,API: Username = emp_id\nPassword = LastName
Note over F,API: Username = webUser (U_WEB_USER)\nPassword = U_WEB_PASS
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->>SAP_C: forward_to_sapb1(GET, "EmployeesInfo")\n$filter=U_WEB_USER eq '{webUser}'
SAP_C->>SAP: GET /b1s/v1/EmployeesInfo?$filter=...
SAP-->>SAP_C: {EmployeeID, FirstName, LastName, Active, U_WEB_PASS, ...}
SAP_C-->>API: Employee data (o lista vacía)
API->>API: ¿Password == LastName?
alt Coincide
API->>S: create_employee_session(emp_id, first_name, last_name)
S->>S: Genera token_urlsafe(32)
API->>API: ¿Existe empleado?\n¿Active == "tYES"?\n¿U_WEB_PASS == Password?
alt Todo válido
API->>S: create_employee_session(webUser, empId, first_name, last_name)
S->>S: Genera token_urlsafe(32)\n(invalida token anterior del mismo webUser)
S-->>API: token
API->>SAP_C: ensure_sap_session()
@@ -29,9 +29,9 @@ sequenceDiagram
SAP-->>SAP_C: B1SESSION cookie
SAP_C-->>API: OK
API-->>F: 200 {message, token, username}
else No coincide
API-->>F: 401 Invalid credentials
API-->>F: 200 {message, token, username: FirstName, company: CompanyDB}
else No existe / inactivo / password no coincide
API-->>F: 401 (mensaje según el caso: "Usuario no registrado",\n"Usuario inactivo" o "Usuario o contraseña incorrectos")
end
```
@@ -68,8 +68,8 @@ sequenceDiagram
alt Token válido
S->>S: Actualiza last_activity
S-->>MW: emp_id
MW->>MW: Adjunta emp_id a request.state
S-->>MW: webUser
MW->>MW: set_current_user(webUser, full_name, emp_id)\nrequest.state.webUser = webUser
MW->>R: Continúa al router
R-->>F: 200 Response
else Token expirado (>30 min)
+17 -12
View File
@@ -1,6 +1,6 @@
# Backend SN — Documentación del Proyecto
# Backend — 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.
Backend de integración entre el frontend `mini-frontend` (React 19 + TypeScript + Vite) y **SAP Business One Service Layer**. Actúa como proxy/middleware autenticando empleados, gestionando sesiones y enrutando operaciones CRUD hacia SAP. Este proyecto vive junto al frontend dentro del monorepo `mini-erp` (ver `CLAUDE.md` en la raíz del repo).
## Stack Tecnológico
@@ -17,29 +17,34 @@ Backend de integración entre una aplicación frontend (Next.js) y **SAP Busines
```mermaid
graph TB
subgraph "Frontend"
F[App SN - Next.js]
F[mini-frontend - React + Vite]
end
subgraph "Backend SN - FastAPI"
subgraph "Backend - FastAPI"
API[API Proxy\n:8000]
end
subgraph "SAP Business One"
SL[Service Layer\nREST API]
DB[(SAP DB\nSBO_PRUEBA_*)]
AG[API Gateway\nCrystal Reports]
DB[(SAP DB / HANA)]
end
F -->|HTTP /api/*| API
API -->|HTTP /b1s/v1| SL
API -->|HTTP rs/v1/*| AG
API -->|SQL directo| DB
SL --> DB
AG --> 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` |
| 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 |
| [Diseño de logging](logging-plan.md) | Contexto de usuario/request, formato JSON |
| [Visualización de logs](logs-viewing.md) | Cómo leer `logs/backend.log` con `jq`/`lnav` |
+58 -149
View File
@@ -1,175 +1,84 @@
# Plan: logging real por sesión de usuario
# Diseño de Logging
## 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')}")`
> Nota: este documento describía originalmente un plan pendiente. Ya está
> implementado tal como se describe abajo; se conserva el nombre del
> archivo por los enlaces existentes desde `index.md`.
## 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.
Cada línea de log queda etiquetada automáticamente con el `request_id` de
la petición HTTP y con el empleado conectado que la originó (`webUser`,
`emp_id`, `full_name`), en formato JSON, sin tener que pasar esos datos a
mano en cada llamada a `logger.info/warning/error`.
## Diseño
## Piezas
`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**.
### `context.py`
### 1. `context.py`
Dos `ContextVar` por request (aisladas entre corrutinas concurrentes de
FastAPI):
Agregar un `ContextVar` para el usuario actual, mismo patrón que
`request_id_ctx`:
- `request_id_ctx` — expuesta via `get_request_id()` / `set_request_id()`.
- `current_user_ctx` — dict `{"webUser", "emp_id", "full_name"}`, expuesta
via `get_current_user()` / `set_current_user(webUser, full_name="", emp_id="-")`.
Por defecto ambas quedan en `"-"` cuando no hay usuario autenticado
(por ejemplo, en el propio `POST /api/Login` antes de validar credenciales).
```python
current_user_ctx: ContextVar[dict] = ContextVar(
"current_user", default={"emp_id": "-", "username": "-"}
)
### `main.py`
def get_current_user() -> dict:
return current_user_ctx.get()
- `correlation_id_middleware` llama a `set_request_id()` con el header
`X-Request-ID` entrante o un UUID generado si no viene.
- `session_middleware`, tras validar el token con
`validate_and_refresh_session()`, llama a
`set_current_user(webUser, get_employee_name(webUser), get_emp_id(webUser))`
para que el resto de la petición (incluyendo las llamadas a
`forward_to_sapb1`) quede etiquetada con ese usuario.
- `routers/auth.py` también llama a `set_current_user(...)` en un login
exitoso, porque `/api/Login` no pasa por el `session_middleware`
(todavía no hay token) y de otro modo su propio log quedaría sin usuario.
- `LOG_ENVIRONMENT` (variable de entorno, default `"production"`) decide el
formato de salida en `setup_logging()`.
def set_current_user(emp_id: str, username: str):
current_user_ctx.set({"emp_id": emp_id, "username": username})
```
### `logger_config.py` — `JsonFormatter`
### 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:
En modo `"production"` (default), cada línea de log —tanto en consola como
en `logs/backend.log`— es un JSON con esta forma:
```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}
{"timestamp": "2026-08-14T15:32:10.123456+00:00", "level": "INFO", "request_id": "3f2a...", "webUser": "jgarcia", "emp_id": "11", "full_name": "Juan García", "http_method": "GET", "endpoint": "Items", "status_code": 200, "message": "[200] GET Items", "module": "sap", "function": "forward_to_sapb1", "line": 141}
```
### 5. `core/sap.py` / `core/api_gateway.py`
Campos siempre presentes: `timestamp`, `level`, `request_id`, `webUser`,
`emp_id`, `full_name`, `message`, `module`, `function`, `line`. Campos
opcionales (`http_method`, `endpoint`, `status_code`) se agregan solo si el
`logger.info/warning/error(...)` que generó la línea los pasó vía
`extra={...}` — este es el patrón que usan `core/sap.py`,
`core/api_gateway.py` y `routers/reports.py` para dejar trazabilidad de
cada llamada saliente. Si el log viene de una excepción (`exc_info`), se
agrega también `exception` con el traceback formateado.
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).
En cualquier otro valor de `LOG_ENVIRONMENT` (por ejemplo `"development"`),
la consola usa un formato de texto plano legible
(`%(asctime)s [%(levelname)s] %(module)s.%(funcName)s:%(lineno)d - %(message)s`)
en vez de JSON — pensado para desarrollo local, no para buscar en el
archivo. El archivo `logs/backend.log` (rotativo, 5 MB x 3 backups vía
`RotatingFileHandler`) siempre se escribe en JSON sin importar el entorno.
### 6. `routers/auth.py`
## Qué NO se loguea
- 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).
`routers/auth.py` nunca escribe `Password` / `U_WEB_PASS` en los logs, ni en
éxito ni en fallo de login — solo el `webUser`, el `EmployeeID` y si el
intento fue exitoso o el motivo del rechazo (`"Usuario no registrado"`,
`"Usuario inactivo"`, contraseña incorrecta). Al auditar un cambio en el
flujo de login, verificar que esta regla se mantenga.
### 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
## Verificación
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.
3. Revisar `logs/backend.log`: cada línea debe traer `webUser`, `emp_id`,
`full_name`, y las líneas de proxy SAP también `http_method`,
`endpoint`, `status_code`.
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.
+10 -10
View File
@@ -1,11 +1,11 @@
# 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.
[logging-plan.md](logging-plan.md)), con campos como `webUser`, `emp_id`,
`full_name`, `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
@@ -15,8 +15,8 @@ Instalación: `brew install jq`
# 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 las líneas de un empleado puntual (emp_id es string, ej. "11")
tail -f logs/backend.log | jq 'select(.emp_id == "11")'
# solo errores/warnings
tail -f logs/backend.log | jq 'select(.level == "ERROR" or .level == "WARNING")'
@@ -25,7 +25,7 @@ tail -f logs/backend.log | jq 'select(.level == "ERROR" or .level == "WARNING")'
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}'
tail -f logs/backend.log | jq -c '{ts: .timestamp, user: .full_name, ep: .endpoint, status: .status_code}'
```
## `lnav` — visor interactivo
@@ -41,9 +41,9 @@ lnav logs/backend.log
- 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).
`:filter-in emp_id.:.11` para quedarte solo con el empleado 11).
- queries tipo SQL sobre los campos del log con `;` (por ejemplo
`;SELECT emp_id, username, endpoint, status_code FROM backend_log WHERE level = 'ERROR'`).
`;SELECT emp_id, full_name, 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.
+13 -1
View File
@@ -2,7 +2,7 @@
## 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.
Todas las operaciones CRUD pasan por esta función en `core/sap.py`, que actúa como proxy hacia SAP Service Layer. La mayoría de los routers (`business_partners`, `items`, `sales`, `purchases`, `batch`, parte de `catalogs`) no la llaman directamente: pasan por `core/crud_router.py`, una factory que arma el `list/get/create/update` de un recurso OData y delega en `forward_to_sapb1` (ver [Arquitectura](architecture.md)). El proxy hacia el API Gateway de Crystal Reports (`core/api_gateway.py`, usado por `routers/apigateway.py`) sigue el mismo patrón de sesión-global + reintento en 401, mostrado más abajo bajo "Otros proxies".
```mermaid
flowchart TD
@@ -116,3 +116,15 @@ sequenceDiagram
| Error de red | Lanza `ConnectionError` |
| Reintento también falla | Lanza `HTTPException` con el mensaje de error SAP |
| SAP no disponible | `HTTPException(500)` |
## Otros proxies (fuera de Service Layer)
| Proxy | Módulo | Sesión | Consumido por |
| ----- | ------ | ------ | -------------- |
| API Gateway (Crystal Reports, `rs/v1/*`) | `core/api_gateway.py` (`forward_to_api_gateway`) | `requests.Session` con cookies, global, se re-loguea en 401 igual que el Service Layer | `routers/apigateway.py` |
| HANA (SQL directo) | `core/hana.py` (`hana_connection`) | Sin sesión persistente: abre y cierra una conexión `hdbcli` por request | `routers/reports.py` |
`core/hana.py` no pasa por `forward_to_sapb1` ni maneja OData: se usa para
reportes que necesitan una consulta SQL puntual contra HANA (por ejemplo,
armar el detalle de una guía de remisión para imprimir stickers) en vez de
paginar un recurso del Service Layer.