# OrderVision API — Guía

Fastify 5 · SQL Server (erp) · JWT · Firebase · Gemini · Zod

Base URL: `http://192.168.1.3:3005`

---

## Estado del servicio

| Endpoint | Propósito |
| --- | --- |
| `GET /health` | Liveness — responde 200 si el proceso está arriba |
| `GET /ready`  | Readiness — ejecuta `SELECT 1` contra SQL Server (200 OK, 503 si DB caída) |

```bash
curl http://192.168.1.3:3005/ready
```

---

## Autenticación

Todos los endpoints (salvo `/health`, `/ready`, `/auth/login`, `/` y `/docs`) requieren JWT:

```
Authorization: Bearer <token>
```

### POST /auth/login

```bash
curl -X POST http://192.168.1.3:3005/auth/login \
  -H "Content-Type: application/json" \
  -d '{"codigo":"<USUARIO>","pwd":"<PASSWORD>"}'
```

Respuesta entrega `token` (JWT) y `firebaseToken`.

---

## Clients  *(JWT)*

Read-only sobre la tabla `dbo.clientes` del ERP. Excluye inactivos por defecto.

### GET /clients

| Parámetro | Tipo | Default | Descripción |
| --- | --- | --- | --- |
| `page` | int ≥ 1 | 1 | Página |
| `limit` | int 1-100 | 10 | Tamaño de página |
| `search` | string | — | Busca en código, nombre o RNC |
| `includeInactive` | bool | false | Incluir clientes inactivos |

```bash
curl -H "Authorization: Bearer $TOKEN" \
  "http://192.168.1.3:3005/clients?search=ALPA&limit=5"
```

### GET /clients/:codigo

```bash
curl -H "Authorization: Bearer $TOKEN" \
  http://192.168.1.3:3005/clients/8AA01
```

### Forma de la respuesta

```json
{
  "codigo": "8AA01",
  "nombre": "ALMACENES ALPA",
  "rnc": null,
  "telefono": "8095242249",
  "email": null,
  "vendedor": "MG",
  "moneda": "DOP",
  "ciudad": "Barahona, R. D.",
  "sector": null,
  "limite_credito": 10000,
  "inactivo": false,
  "excento": false,
  "direcciones_entrega": [
    {
      "id": 12,
      "nombre": "Sucursal Centro",
      "direccion": "Calle Duarte 123",
      "sector": "Centro",
      "ciudad": "Barahona",
      "pais": "DO"
    }
  ]
}
```

`direcciones_entrega` es un arreglo de sucursales alternativas de entrega leído
desde `dbo.sucursal_cliente` (join `cliente = clientes.codigo`). Clientes sin
sucursales devuelven `[]`. Útil para resolver `direccion_entrega` al crear
órdenes vía `/orders/insertar`.

`excento` indica si el cliente está exento de ITBIS (lee `clientes.excento`).

---

## Merchandise  *(JWT)*

Read-only. Fuente: `inventario_vision` (mercancías) UNION `mercs WHERE servicio=1 AND inactivo=0` (servicios facturables sin stock físico). Los servicios devuelven `existencia_inventario`, `empaque_cantidad`, `paleta_cantidad` en `0`.

### GET /merchandise

| Parámetro | Tipo | Default | Descripción |
| --- | --- | --- | --- |
| `page` | int | 1 | Página |
| `limit` | int 1-100 | 10 | Tamaño |
| `search` | string | — | Busca en `nombre` y `codigo` (palabras separadas por espacio, AND) |
| `tipo` | enum `todo`/`mercancia`/`servicio` | `todo` | Filtra por clase de item |
| `cliente` | string | — | Código de cliente (`clientes.codigo`). Si se envía, `precio` se resuelve desde la lista de precio del cliente (`preciosd` vía `clientes.listap`); si no, `precio` = `mercs.precio1`. |

```bash
# Listado con precio según lista del cliente 8AA01
curl -H "Authorization: Bearer $TOKEN" \
  "http://192.168.1.3:3005/merchandise?cliente=8AA01&limit=5"

# Sin cliente → precio = mercs.precio1
curl -H "Authorization: Bearer $TOKEN" \
  "http://192.168.1.3:3005/merchandise?search=ASIENTO"
```

### GET /merchandise/:codigo

```bash
curl -H "Authorization: Bearer $TOKEN" \
  "http://192.168.1.3:3005/merchandise/AAINOD?cliente=8AA01"
```

Acepta también `?cliente=<codigo>` para resolver `precio` por lista del cliente.

### Respuesta

```json
{
  "codigo": "AAINOD",
  "nombre": "ASIENTO Y TAPA PARA INODORO",
  "existencia_inventario": 0,
  "empaque_cantidad": 0,
  "paleta_cantidad": 0,
  "servicio": false,
  "excento": false,
  "precio": 150.00
}
```

`servicio=true` → item es servicio facturable (mercs.servicio). `excento=true` → exento de ITBIS (mercs.excento). `precio` = unitario, resuelto por `preciosd` (lista del cliente vía `clientes.listap`) si se envía `cliente`; si no, `mercs.precio1`. Cliente inexistente devuelve 404.

---

## Orders — Inserción vía SP  *(JWT)*

Crea cabecera + detalle en `imtr`/`imtrd` ejecutando el SP `dbo.insertar_orden`.
La sucursal se fija en `L1` dentro del SP — **no debe enviarse** en el body.

### POST /orders/insertar

**Body:**

```json
{
  "usuario":   "string ≤10 chars",
  "cliente":   "string ≤20 chars (clientes.codigo)",
  "comentario":"string ≤200 chars (opcional)",
  "almacen":   "string ≤5 chars",
  "moneda":    "USD o DOP (obligatorio)",
  "descuento": "porcentaje 0-100 (opcional, default 0) — aplica a toda la orden",
  "fecha_entrega": "YYYY-MM-DD (opcional)",
  "direccion_entrega": "string ≤4000 chars (opcional) — destino físico de entrega. Si se omite, el SP usa `clientes.direccion`.",
  "detalle": [
    {
      "merc":     "string ≤60 chars (mercs.codigo)",
      "descrip":  "string ≤60 chars (opcional)",
      "cantidad": "número positivo",
      "precio":   "número >= 0 (obligatorio) — precio unitario"
    }
  ]
}
```

**Ejemplo:**

```bash
curl -X POST http://192.168.1.3:3005/orders/insertar \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "usuario": "JPEREZ",
    "cliente": "8AA01",
    "comentario": "Entrega urgente",
    "almacen": "L1",
    "moneda": "DOP",
    "descuento": 10,
    "fecha_entrega": "2026-05-25",
    "direccion_entrega": "Av. 27 de Febrero #123, Santo Domingo",
    "detalle": [
      { "merc": "AAINOD", "descrip": "ASIENTO Y TAPA", "cantidad": 3, "precio": 150.00 },
      { "merc": "ACADUA", "cantidad": 2, "precio": 75.50 }
    ]
  }'
```

**Respuesta 201:**

```json
{
  "success": true,
  "message": "Orden insertada correctamente",
  "data": {
    "control_generado": "L1000001559210",
    "numero_orden": "000942241",
    "cliente": "8AA01",
    "nombre_cliente": "ALMACENES ALPA",
    "valor": 602.73,
    "itbis": 108.49,
    "total": 711.22,
    "fecha_creacion": "2026-05-19T18:30:00.000Z",
    "fecha_entrega": "2026-05-25"
  }
}
```

### Lógica del SP

- `precio` por línea viene del body (obligatorio). El SP no consulta `preciosd` ni `mercs.precio1`: el cliente envía el precio unitario ya resuelto.
- `moneda` de cabecera viene del body (obligatorio, `USD` o `DOP`). Tasa de cambio se lee de `tasas` según la moneda (fallback 1).
- ITBIS fijo 18%.
- Genera `control` de cabecera y por cada línea vía `dbo.next_id`.
- Cabecera: `doc='ORV'`, `numero='Nuevo'`, `diascr=30`, `vence=30`, `sucursal='L1'`.
- `descuento` global (porcentaje 0-100, opcional). Aplica el mismo `%` a cada línea: `dvalor = valor * descuento/100`, `neto = valor - dvalor`, `itbis = neto * 0.18`. Cabecera `descuento` = suma de `dvalor` de líneas.
- `direccion_entrega` (opcional) se persiste en `imtr.destino`. Si el body no la envía (o llega vacía), el SP usa `clientes.direccion` del cliente referenciado. Si el cliente tampoco tiene dirección, `imtr.destino` queda en `NULL`.

---

## AI  *(JWT)*

### POST /ai/extract-order

Recibe texto/imagen/PDF y devuelve una orden estructurada (Gemini).
Útil para alimentar el body de `/orders/insertar`.

---

## Prompt sugerido — IA del cliente

Para que la IA del lado del cliente genere el body de `/orders/insertar` a partir de texto libre:

```
Eres un asistente que crea órdenes de venta en OrderVision.
Cuando el usuario describa lo que quiere ordenar (voz, correo, WhatsApp, foto),
debes:

1) Construir el cuerpo JSON exacto para:

   POST http://192.168.1.3:3005/orders/insertar
   Headers:
     Authorization: Bearer <JWT>
     Content-Type: application/json

2) Enviar la petición y reportar al usuario el control_generado y el total.

ESTRUCTURA DEL BODY:
{
  "usuario":   "<código del usuario logueado, ≤10 chars, MAYÚSCULAS>",
  "cliente":   "<código de clientes.codigo, ≤20 chars, MAYÚSCULAS>",
  "comentario":"<texto libre, ≤200 chars, opcional>",
  "almacen":   "<código de almacén, ≤5 chars, MAYÚSCULAS>",
  "moneda":    "<USD o DOP, obligatorio>",
  "descuento": <porcentaje 0-100, opcional, default 0>,
  "fecha_entrega": "<YYYY-MM-DD, opcional>",
  "direccion_entrega": "<texto ≤4000 chars, opcional — destino físico; si se omite, el SP usa clientes.direccion>",
  "detalle": [
    {
      "merc":     "<código de mercs.codigo, ≤60 chars, MAYÚSCULAS>",
      "descrip":  "<descripción ≤60 chars, opcional>",
      "cantidad": <número positivo>,
      "precio":   <número >= 0, obligatorio — precio unitario>
    }
  ]
}

REGLAS
- NO envíes sucursal — el servidor la fija en L1.
- No inventes códigos. Si el usuario menciona un producto/cliente por nombre
  y no tienes el código exacto, pide aclaración antes de llamar al endpoint.
  Para resolver códigos puedes consultar /clients?search=... y
  /merchandise?search=...
- Convierte cantidades habladas a número (media docena = 6, dos y medio = 2.5).
- Si el endpoint responde con error, muestra el mensaje al usuario tal cual.

EJEMPLO
Usuario: "Soy JPEREZ. Orden para Almacenes Alpa 8AA01 desde almacén L1:
          tres asientos AAINOD y dos ACADUA. Anótale entrega urgente."

Body que envías:
{
  "usuario": "JPEREZ",
  "cliente": "8AA01",
  "comentario": "ENTREGA URGENTE",
  "almacen": "L1",
  "moneda": "DOP",
  "detalle": [
    { "merc": "AAINOD", "descrip": "ASIENTO Y TAPA PARA INODORO", "cantidad": 3, "precio": 150.00 },
    { "merc": "ACADUA", "descrip": "ASA CAJA DURALON",            "cantidad": 2, "precio": 75.50 }
  ]
}
```

---

## Rates — Tasas de cambio  *(JWT)*

Fuente: función oficial del ERP `dbo.lista_tasas` (tabla `tasas`, alimentada a diario).
`compra` es la tasa que usa `dbo.insertar_orden` para las órdenes; `valor` es la de venta.

### GET /rates
Todas las monedas con su tasa vigente.

### GET /rates/:moneda

**No se envía ningún valor** — solo la moneda en la URL. El endpoint **devuelve** las dos tasas vigentes; para convertir precios usa `compra`.

```bash
# Lo que TÚ envías (solo la moneda):
curl -H "Authorization: Bearer $TOKEN" http://192.168.1.3:3005/rates/USD

# Lo que el sistema TE DEVUELVE:
{ "success": true, "data": { "moneda": "USD", "valor": 59.4770, "compra": 58.6823, "fecha": "2026-06-11T00:00:00.000Z" } }

# Conversión: precioUSD = precioDOP / data.compra
```

404 si la moneda no tiene tasa registrada.

---

## Eventos del ERP (despacho / facturado automático)

> **Para el desarrollador del ERP.** Esta sección describe cómo el ERP le avisa a
> OrderVision cuando una orden se **despacha** o se **factura**, para que
> OrderVision actualice el estado del pedido **sin intervención manual**.

### Idea general

El flujo es **push + pull**:

1. **El ERP avisa** (push): apenas se despacha o factura una orden, el ERP hace
   un `POST /erp/eventos` con un aviso corto (solo el número de control y el tipo
   de evento). **No** envía cantidades.
2. **OrderVision busca el detalle** (pull): al recibir el aviso, OrderVision
   ejecuta el stored procedure `dbo.ov_orden_estado` para leer cuánto va
   despachado/facturado de cada mercancía y actualiza el pedido.

```
  ERP                                   OrderVision
   │  POST /erp/eventos                     │
   │  { tipo, control, eventId } ─────────► │  valida firma + eventId
   │                                        │
   │  ◄──── EXEC dbo.ov_orden_estado ────── │  pide acumulados por merc
   │  merc | despachada_acum | facturada_acum
   │  ─────────────────────────────────────►│  actualiza orden + pedido
```

OrderVision aplica las cantidades por **valor absoluto acumulado** (no por
diferencia que mande el ERP). Esto hace el proceso **idempotente**: reenviar el
mismo aviso, o que lleguen fuera de orden, nunca duplica ni descuadra.

---

### POST /erp/eventos

Webhook que recibe el aviso del ERP.

#### Autenticación — firma HMAC

No usa JWT. Cada petición se firma con un **secreto compartido** (lo entrega
OrderVision). El ERP debe:

1. Serializar el body a JSON (el **string exacto** que va en el cuerpo).
2. Calcular `HMAC-SHA256(secreto, cuerpoCrudo)` en **hex**.
3. Mandarlo en el header `x-ov-signature`.

OrderVision recalcula la firma sobre el cuerpo crudo recibido y la compara. Si
no coincide → `401`. **Importante:** firmar exactamente los mismos bytes que se
envían (no re-serializar con otro orden de llaves ni espacios distintos).

#### Body

| Campo | Tipo | Obligatorio | Descripción |
| --- | --- | --- | --- |
| `eventId` | string | **sí** | Identificador **único** del evento (lo genera el ERP). Sirve para no procesar dos veces el mismo aviso. Ej: `DESP-2026-000123`. |
| `tipo` | `"DESPACHO"` \| `"FACTURA"` | **sí** | Qué ocurrió. |
| `control` | string | **sí** | Número de control de la orden en el ERP (ej. `L1000001569510`). Es el doc id de la orden en OrderVision. |
| `numeroOrden` | string | no | Número de orden ERP (informativo). |
| `numeroFactura` | string | no | En `FACTURA`: número de factura. Se guarda y se muestra. |
| `numeroConduce` | string | no | En `DESPACHO`: número de conduce. |
| `fechaEvento` | string ISO | no | Cuándo ocurrió (informativo). |
| `detalle` | array | no | **Ignorado en producción.** OrderVision lee las cantidades del SP, no del body. Solo se usa en pruebas internas. |

```json
{
  "eventId": "DESP-2026-000123",
  "tipo": "DESPACHO",
  "control": "L1000001569510",
  "numeroConduce": "C-00045",
  "fechaEvento": "2026-06-08T14:30:00.000Z"
}
```

#### Respuestas

| HTTP | `data.resultado` | Significado | ¿El ERP reintenta? |
| --- | --- | --- | --- |
| `200` | `procesado` | Aviso aplicado, pedido/orden actualizados. | No |
| `200` | `duplicado` | Ese `eventId` ya se había procesado. Se ignora sin tocar nada. | No |
| `200` | `ignorado` | El `control` no existe en OrderVision (orden no creada desde aquí). | No |
| `401` | — | Firma inválida o ausente. | No (revisar secreto) |
| `503` | — | Integración apagada (todavía no activada en OrderVision). | Sí, más tarde |
| `500` | — | Error temporal procesando. | **Sí** (reintentar) |

```json
{
  "success": true,
  "message": "Evento procesado",
  "data": {
    "eventId": "DESP-2026-000123",
    "control": "L1000001569510",
    "resultado": "procesado",
    "pedidoCode": "PED-0000016",
    "statusOrden": "Despachada"
  }
}
```

**Recomendación de reintentos del lado ERP:** reintentar solo ante `5xx` o
timeout, con backoff. Ante `200` (incluido `duplicado`/`ignorado`) o `401`, no
reintentar. Como el proceso es idempotente por `eventId`, reintentar es seguro.

---

### Stored procedure `dbo.ov_orden_estado`

**Ya está creado por OrderVision** (procedimiento de solo lectura). El
desarrollador del ERP **no necesita crearlo**; solo le falta emitir el webhook.
OrderVision lo ejecuta al recibir cada evento para saber el estado **acumulado**
de la orden.

Implementación actual (sobre el esquema del ERP):

- `despachada_acum`: `imtrd.cantidad_despachada` de las líneas de la propia orden (ORV).
- `facturada_acum`: suma de las líneas de la factura `FT` cuyo `control = <control de la orden> + '-ft'` (excluye anuladas/canceladas).
- `despachada_acum` nunca es menor que `facturada_acum` (facturar implica despachar).

Si en el futuro cambia la forma de registrar despacho/factura en el ERP, se
ajusta este SP del lado de OrderVision.

**Firma:** `EXEC dbo.ov_orden_estado @control = '<control>'`

**Devuelve** una fila por mercancía de esa orden, con los **acumulados totales
hasta el momento** (no el delta del último movimiento):

| Columna | Tipo | Descripción |
| --- | --- | --- |
| `merc` | varchar | Código de la mercancía (`mercs.codigo`). |
| `despachada_acum` | numérico | Total despachado de esa merc en la orden, a la fecha. |
| `facturada_acum` | numérico | Total facturado de esa merc en la orden, a la fecha. |

Ejemplo de salida para una orden de 1500 uds con 500 despachadas:

| merc | despachada_acum | facturada_acum |
| --- | --- | --- |
| BEPODU | 500 | 0 |

> **Clave:** son **acumulados**. Si luego se despachan 300 más, el SP debe
> devolver `despachada_acum = 800` (no `300`). OrderVision calcula la diferencia
> internamente.

---

### Estado de activación

La integración llega **apagada** (responde `503`). OrderVision la activa cuando
el ERP esté listo y se haya intercambiado el secreto. Pasos para arrancar:

1. El ERP implementa el `POST /erp/eventos` (el SP `dbo.ov_orden_estado` ya está creado por OrderVision).
2. OrderVision entrega el **secreto compartido** para la firma.
3. Se hace una prueba con una orden real (un despacho parcial).
4. OrderVision activa el flag y la actualización pasa a ser automática.

Mientras tanto, el cierre manual (despachar/facturar a mano en OrderVision) sigue
disponible como respaldo de emergencia.

---

## Cambios recientes

| Fecha | Cambio |
| --- | --- |
| 2026-06-11 | **Nuevo:** `GET /rates` y `GET /rates/:moneda` — tasa de cambio vigente del ERP (`dbo.lista_tasas`). |
| 2026-06-08 | **Nuevo:** `POST /erp/eventos` (webhook firmado HMAC) para despacho/facturado automático desde el ERP + contrato del SP `dbo.ov_orden_estado`. Apagado hasta activación. |
| 2026-05-26 | `/merchandise` y `/merchandise/:codigo` ahora devuelven `precio`. Param opcional `cliente=<codigo>` resuelve la lista de precio del cliente (`preciosd` vía `clientes.listap`); sin `cliente`, `precio` = `mercs.precio1`. |
| 2026-05-25 | `/clients` ahora expone `excento` (ITBIS). `/merchandise` expone `excento` y `servicio`, acepta param `tipo` (`todo`/`mercancia`/`servicio`) y la lista incluye servicios activos vía UNION con `mercs`. |
| 2026-05-22 | `/clients` y `/clients/:codigo` ahora incluyen `direcciones_entrega` (sucursales alternativas desde `dbo.sucursal_cliente`). |
| 2026-05-22 | `direccion_entrega` (opcional) en `/orders/insertar` → `imtr.destino`. Fallback automático a `clientes.direccion` cuando se omite. |
| 2026-05-20 | **Breaking:** `moneda` (USD/DOP) y `precio` por línea ahora son obligatorios en `/orders/insertar`. SP ya no consulta `preciosd`/`mercs.precio1` ni `clientes.moneda`. |
| 2026-05-19 | Endpoint `/orders/insertar` + SP `dbo.insertar_orden` adaptado al schema real (vence/fpago int, longitudes ajustadas). |
| 2026-05-19 | SP forzado a `sucursal='L1'`; campo removido del body. |
| 2026-05-19 | Endpoint `/clients` (list + getByCode) sobre tabla `clientes` del ERP. |
| 2026-05-19 | Endpoint `/ready` con probe de DB. |
| 2026-05-19 | Guía HTML en `/` y descarga MD en `/guide.md`. |
