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,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)` |
|
||||
Reference in new issue
Block a user