# Estándar de Integraciones Animal Charlie

Animal Charlie centraliza las APIs externas en una capa de integraciones. Los módulos internos no deben hablar con cada proveedor externo ni conocer sus parámetros originales. Deben pedir una **capacidad normalizada** a Animal Charlie y recibir una respuesta común.

En la navegación vive en `Herramientas > Integraciones`: configura conexiones compartidas por toda la aplicación, pero cada módulo sigue consumiéndolas a través del backend y de sus permisos propios.

## Carcasa Y Navegación

La cabecera común muestra el estado agregado y deja `Actualizar` como acción principal mientras está visible el catálogo; la documentación pasa a `Más`. Al abrir una consola, `Consultar` o `Enviar` se convierte en la única acción principal contextual. El layout `master-detail` mantiene el registro de APIs en `index` y la integración seleccionada con su consola en `workbench`. En iPad Air vertical ambas zonas se apilan, conservando primero la selección y después la consulta activa.

Endpoint único para módulos:

```http
POST /api/integrations/query
Authorization: Bearer {token}
Content-Type: application/json
```

## Regla de arquitectura

- Los módulos llaman siempre a `/api/integrations/query`.
- La petición usa `integration`, `capability` y `filters`.
- El backend traduce los filtros al proveedor externo que corresponda.
- El backend normaliza la respuesta antes de devolverla.
- Si mañana se cambia CIMAVet por otra fuente, el módulo no debería cambiar si la capacidad sigue igual.

## Formato de petición

```json
{
  "integration": "cimavet",
  "capability": "veterinary_medicines.search",
  "filters": {
    "query": "eq-zona"
  }
}
```

Campos:

| Campo | Tipo | Obligatorio | Descripción |
|---|---|---:|---|
| `integration` | string | Sí | Integración registrada. Ejemplo: `cimavet`, `ligal` o `cegcol`. |
| `capability` | string | Sí | Acción normalizada que se solicita. Ejemplo: `veterinary_medicines.search`, `ligal.tests.search` o `cegcol.censo.search`. |
| `filters` | object | No | Filtros normalizados de Animal Charlie. |
| `includeRaw` | boolean | No | Si es `true`, incluye la respuesta bruta externa para depuración. Exige `integrations.write` o un rol operativo equivalente; una sesión de solo lectura recibe `403 integration_raw_forbidden`. No usar en módulos de negocio. |

## Exposición segura del catálogo

- `GET /api/integrations` exige acceso efectivo al módulo y nunca publica credenciales, query strings ni fragmentos de las URLs configuradas.
- El estado público de WhatsApp se reduce a disponibilidad, conexión, QR, cuenta saneada y trazabilidad básica del upstream; errores y filtros reflejados pasan por redacción de tokens, contraseñas, secretos, sesiones y payloads base64.
- La descarga LIGAL exige acceso efectivo a Integraciones. Tener una sesión válida en otro módulo no basta.
- Las operaciones brutas permanecen disponibles para diagnóstico autorizado, pero no convierten `integrations.read` en permiso de extracción de respuestas externas completas.

## Formato de respuesta

Todas las integraciones deben responder con esta envoltura:

```json
{
  "ok": true,
  "integration": "cimavet",
  "capability": {
    "requested": "veterinary_medicines.search",
    "resolved": "veterinary_medicines.search"
  },
  "source": {
    "provider": "AEMPS",
    "system": "CIMAVet",
    "adapter": "animal-charlie-cimavet-v1",
    "fetchedAt": "2026-05-18T21:00:00Z",
    "upstream": {
      "path": "medicamentos",
      "status": 200
    }
  },
  "request": {
    "filters": {
      "query": "eq-zona"
    },
    "translatedQuery": {
      "nombre": "eq-zona"
    }
  },
  "result": {
    "kind": "veterinary_medicine",
    "total": 1,
    "page": 1,
    "pageSize": 25,
    "items": []
  }
}
```

Campos estables para consumir:

| Campo | Uso |
|---|---|
| `ok` | Indica que la integración respondió correctamente. |
| `capability.resolved` | Capacidad final ejecutada. Permite alias internos. |
| `source` | Trazabilidad del proveedor, adaptador y momento de consulta. |
| `request.translatedQuery` | Parámetros a los que Animal Charlie tradujo la petición. Útil para depurar. |
| `result.kind` | Tipo normalizado de los elementos devueltos. |
| `result.items` | Lista normalizada que deben usar los módulos. |
| `result.item` | Objeto único normalizado cuando la capacidad devuelve detalle. |

## Formato de error

Los módulos consumidores deben tratar los errores como errores del adaptador, no como errores del proveedor externo. El backend intenta devolver siempre un `error` estable y un `detail` legible:

```json
{
  "error": "outlook_not_configured",
  "detail": "Configura OUTLOOK_TENANT_ID, OUTLOOK_CLIENT_ID, OUTLOOK_CLIENT_SECRET y OUTLOOK_MAILBOX."
}
```

Errores habituales:

| Error | Significado | Acción del módulo |
|---|---|---|
| `unknown_integration` | La integración no está registrada. | No reintentar; revisar configuración del módulo. |
| `unknown_capability` | La capacidad no existe en esa integración. | Usar una capacidad publicada en `GET /api/integrations`. |
| `*_not_configured` | Faltan credenciales externas. | Mostrar estado de configuración pendiente. |
| `cegcol_credentials_required` | Faltan `CEGCOL_CLIENT_ID`, `CEGCOL_CLIENT_SECRET`, usuario o contraseña. | Mostrar CEGCOL sin credenciales; no pedir secretos en UI. |
| `cegcol_return_error` | CEGCOL respondió `returnCode` distinto de `0`. | Mostrar `detail`/`returnCode` y revisar filtros. |
| `integration_write_confirmation_required` | Operación de escritura sin `confirmWrite=true`. | Pedir confirmación explícita al usuario. |
| `integration_write_forbidden` | Usuario sin `integrations.write` ni rol legacy de escritura. | No reintentar con el mismo usuario. |
| `*_graph_error`, `*_api_error`, `*_bridge_error` | El proveedor externo rechazó o no pudo completar la operación. | Mostrar `detail` y registrar trazabilidad. |

Las escrituras externas nunca deben ejecutarse de forma implícita. Cualquier capacidad con `method: "write"` requiere `confirmWrite=true` y permiso global `integrations.write`, manteniendo compatibilidad con los roles legacy que ya podían escribir.

## Cómo consultar desde frontend

Usar el helper de `app.js`:

```js
const data = await integrationRequest("cimavet", "veterinary_medicines.search", {
  query: "eq-zona",
});

const medicines = data.result.items;
```

Detalle por código nacional:

```js
const data = await integrationRequest("cimavet", "veterinary_presentations.get", {
  nationalCode: "570828",
});

const presentation = data.result.item;
```

Consulta LIGAL:

```js
const data = await integrationRequest("ligal", "ligal.tests.search", {
  top: 20,
  skip: 0,
  orderBy: "Code",
});

const tests = data.result.items;
```

Consulta CEGCOL:

```js
const data = await integrationRequest("cegcol", "cegcol.controles.search", {
  fecha: "2026-05-31",
  status: 0,
  rega: "ES150000000000",
});

const controles = data.result.items;
```

## Cómo consultar desde backend

Dentro de `server.py`, usar el adaptador en vez de construir URLs externas desde otro módulo:

```python
response, status = self.execute_cimavet_query(
    "veterinary_medicines.search",
    {"query": "eq-zona"},
)

items = response["result"]["items"]
```

Para exponer una consulta a otro endpoint interno, ese endpoint debe llamar al adaptador y devolver datos ya normalizados o transformarlos a su propio caso de uso.

Para LIGAL:

```python
response, status = self.execute_ligal_query(
    "ligal.customers.search",
    {"top": 20, "skip": 0, "orderBy": "Code"},
    user=user,
)

customers = response["result"]["items"]
```

Para CEGCOL:

```python
response, status = self.execute_cegcol_query(
    "cegcol.censo.search",
    {"fechaIni": "2026-05-01", "fechaFin": "2026-05-31", "status": 0},
    user=user,
)

animals = response["result"]["items"]
```

Para mensajería y correo:

```python
response, status = self.execute_outlook_query(
    "outlook.mail.messages.search",
    {"folder": "Inbox", "top": 25},
    user=user,
)

messages = response["result"]["items"]
```

```python
response, status = self.execute_whatsapp_query(
    "whatsapp.messages.search",
    {"limit": 30},
    user=user,
)

messages = response["result"]["items"]
```

```python
response, status = self.execute_telegram_query(
    "telegram.updates.receive",
    {"limit": 30, "timeout": 0},
    user=user,
)

updates = response["result"]["items"]
```

Cuando un módulo necesita escribir, debe construir una acción explícita de usuario y pasar `confirmWrite=true` solo después de confirmarla:

```python
response, status = self.execute_outlook_query(
    "outlook.mail.messages.send",
    {
        "to": "cliente@empresa.com",
        "subject": "Factura",
        "body": "Adjuntamos factura.",
        "confirmWrite": True,
    },
    user=user,
)
```

## Catálogo de integraciones

Consultar integraciones disponibles:

```http
GET /api/integrations
Authorization: Bearer {token}
```

La respuesta incluye:

- `integrations[].id`
- `integrations[].endpoint`
- `integrations[].capabilities[]`
- `integrations[].connection`
- `integrations[].statistics`
- `policy.access`

`connection` describe cómo está conectada la integración sin entregar secretos:

| Campo | Uso |
|---|---|
| `connection.mode` | Tipo de integración. Ejemplo: `adapter` o `local_bridge`. |
| `connection.baseUrl` | URL base del proveedor externo. |
| `connection.docs` | Documento interno de referencia. |
| `connection.auth` | Tipo de autenticación externa. Ejemplo: `oauth_password_grant`. |
| `connection.credentials` | `configured`, `missing` o `not_required`. |
| `connection.timeoutSeconds` | Timeout configurado para llamadas externas. |
| `connection.token.cached` | Indica si existe token externo cacheado y vigente. No incluye el token. |
| `connection.token.expiresInSeconds` | Vida restante aproximada del token cacheado. |

`statistics` permite ver cobertura y actividad operativa:

| Campo | Uso |
|---|---|
| `statistics.capabilities.total` | Número de capacidades registradas. |
| `statistics.capabilities.read` | Capacidades de lectura. |
| `statistics.capabilities.write` | Capacidades de escritura. |
| `statistics.capabilities.filters` | Total de filtros normalizados declarados. |
| `statistics.runtime.totalCalls` | Consultas ejecutadas desde que arrancó el servidor. |
| `statistics.runtime.successCalls` | Consultas con respuesta correcta. |
| `statistics.runtime.errorCalls` | Consultas con error o respuesta no OK. |
| `statistics.runtime.lastStatus` | Último código HTTP devuelto por el adaptador. |
| `statistics.runtime.lastCapability` | Última capacidad ejecutada. |
| `statistics.runtime.byCapability` | Contadores por capacidad. |

Los contadores `runtime` viven en memoria y se reinician al reiniciar el proceso. Sirven para operación inmediata en el módulo Integraciones, no como auditoría histórica.

## UI operativa del módulo

La pantalla `#integraciones` está pensada como consola de operación, no como página de documentación. Mantiene cuatro zonas estables:

- `#integrationsOverview`: banda de resumen con activas, atención, capacidades y consultas runtime.
- `.integration-list-panel`: índice lateral de proveedores con estado, categoría, capacidades y llamadas.
- `.integration-selected-panel`: cabecera de la integración seleccionada con metadatos, documentación y `#integrationSubnav`.
- `.integration-tools-panel`: superficie secundaria y cerrada por defecto para pruebas, filtros adicionales y payloads técnicos; el camino inicial conserva resumen, consulta y resultados.
- `.integration-query-panel`: consola de trabajo con scroll interno para pruebas, formularios, QR, inbox o resultados.

El submenú marca la sección activa con `aria-current`, y los saltos desplazan el panel interno para no mover toda la página. Cualquier proveedor nuevo debe encajar en este patrón: resumen lateral, panel de detalle, secciones navegables y resultados contenidos.

## Consumo desde DataHub

Los datos externos que pertenezcan a una explotación concreta no deben quedar solo como respuesta temporal de `/api/integrations/query`. Deben materializarse en el módulo `DataHub` cuando exista una clave de explotación:

- cliente interno: `customers.id`;
- explotación interna: `customer_farms.id`;
- REGA: `customer_farms.rega`;
- proveedor: `source_id`;
- tipo de dato: `entity_type`.

LIGAL usa este patrón para sincronizar una vez al día clientes, muestras y medias por REGA. La documentación del almacén está en `docs/ALMACEN_LECHERO.md`.

## CIMAVet

Integración registrada:

```json
{
  "id": "cimavet",
  "provider": "AEMPS",
  "access": "adapter",
  "endpoint": "/api/integrations/query"
}
```

### Capacidades

| Capacidad | Resultado | Filtros normalizados |
|---|---|---|
| `veterinary_medicines.search` | `veterinary_medicine[]` | `query`, `name`, `laboratory`, `activeSubstance`, `activeSubstance2`, `activeSubstanceId`, `activeSubstance2Id`, `nationalCode`, `atc`, `registrationNumber`, `registrationSearch`, `excipient`, `excipientId`, `species`, `speciesId`, `commercialized`, `administrativeStatus`, `routeId`, `route`, `pharmaceuticalFormId`, `pharmaceuticalForm`, `medicineType`, `administration`, `narcotic`, `psychotropic`, `dispensing`, `page` |
| `veterinary_medicines.get` | `veterinary_medicine` | `registrationNumber`, `nationalCode` |
| `veterinary_presentations.search` | `veterinary_presentation[]` | `registrationNumber`, `nationalCode`, `commercialized`, `page` |
| `veterinary_presentations.get` | `veterinary_presentation` | `nationalCode` |
| `veterinary_safety_notes.search` | `veterinary_safety_note[]` | `registrationNumber` |
| `veterinary_master_data.search` | `master_item[]` | `masterType`, `name`, `id`, `code`, `page` |
| `veterinary_change_log.search` | `veterinary_change[]` | `dateFrom`, `registrationNumber`, `registrationNumbers`, `page` |

### Tipos normalizados

#### `veterinary_medicine`

Campos principales:

| Campo | Descripción |
|---|---|
| `id` | Identificador preferente. CN si existe, si no número de registro. |
| `externalId.registrationNumber` | Número de registro CIMAVet. |
| `externalId.nationalCode` | Código nacional si aplica. |
| `name` | Nombre del medicamento. |
| `registrationNumber` | Número de registro. |
| `nationalCode` | Código nacional. |
| `holder` | Laboratorio titular. |
| `status.status` | `authorized`, `suspended`, `revoked` o `unknown`. |
| `commercialized` | Estado de comercialización. |
| `activeSubstances` | Lista normalizada de principios activos. |
| `excipients` | Lista normalizada de excipientes. |
| `documents` | Documentos normalizados con `label`, `url` y `type`. |
| `flags` | Indicadores como `ema`, `narcotic`, `psychotropic`, `antibiotic`, `safetyNotes`. |
| `source` | Siempre `cimavet`. |

#### `veterinary_presentation`

Usa el mismo modelo base que `veterinary_medicine`, añadiendo de forma habitual `nationalCode`.

#### `master_item`

```json
{
  "type": "master_item",
  "id": 166,
  "code": "166A",
  "name": "FENILBUTAZONA",
  "source": "cimavet"
}
```

#### `veterinary_change`

```json
{
  "type": "veterinary_change",
  "registrationNumber": "EU001 IP",
  "changedAt": "2026-05-01",
  "changeType": "modified",
  "changedFields": [
    { "id": "otros", "label": "Otros cambios" }
  ],
  "source": "cimavet"
}
```

#### `veterinary_safety_note`

```json
{
  "type": "veterinary_safety_note",
  "number": "1/2026",
  "reference": "AEMPS",
  "subject": "Asunto publicado por AEMPS",
  "publishedAt": "2026-05-01",
  "url": "https://...",
  "source": "cimavet"
}
```

## Outlook Microsoft 365

Integración registrada:

```json
{
  "id": "outlook",
  "provider": "Microsoft Graph",
  "access": "adapter",
  "endpoint": "/api/integrations/query",
  "docs": "/docs/OUTLOOK_INTEGRATION.md"
}
```

Usa `OUTLOOK_TENANT_ID`, `OUTLOOK_CLIENT_ID`, `OUTLOOK_CLIENT_SECRET` y `OUTLOOK_MAILBOX`, o los ficheros equivalentes en `data/outlook-*.txt`.

### Capacidades

| Capacidad | Resultado | Filtros normalizados |
|---|---|---|
| `outlook.auth.status` | `outlook_mailbox` | `force` |
| `outlook.mail.folders.search` | `outlook_mail_folder[]` | `mailbox`, `includeHidden`, `top` |
| `outlook.mail.messages.search` | `outlook_message[]` | `mailbox`, `folderId`, `folder`, `query`, `from`, `unreadOnly`, `since`, `top`, `skip` |
| `outlook.mail.messages.get` | `outlook_message` | `mailbox`, `messageId`, `bodyContentType` |
| `outlook.mail.messages.send` | `outlook_send_result` | `mailbox`, `to`, `cc`, `bcc`, `subject`, `body`, `bodyType`, `attachments`, `saveToSentItems`, `confirmWrite` |
| `outlook.mail.documents.send` | `outlook_send_result` | `mailbox`, `to`, `subject`, `body`, `fileName`, `mimeType`, `dataBase64`, `path`, `saveToSentItems`, `confirmWrite` |
| `outlook.mail.messages.mark_read` | `outlook_message` | `mailbox`, `messageId`, `isRead`, `confirmWrite` |

### `outlook_message`

```json
{
  "type": "outlook_message",
  "id": "AAMk...",
  "subject": "Factura",
  "from": { "name": "Cliente", "address": "cliente@empresa.com" },
  "receivedAt": "2026-05-19T08:00:00Z",
  "bodyPreview": "Texto inicial",
  "hasAttachments": true,
  "isRead": false,
  "source": "outlook"
}
```

## Telegram

Integración registrada:

```json
{
  "id": "telegram",
  "provider": "Telegram Bot API",
  "access": "adapter",
  "endpoint": "/api/integrations/query",
  "docs": "/docs/TELEGRAM_INTEGRATION.md"
}
```

Usa `TELEGRAM_BOT_TOKEN` o `data/telegram-bot-token.txt`. Los módulos internos no exponen ni consumen el token.

### Capacidades

| Capacidad | Resultado | Filtros normalizados |
|---|---|---|
| `telegram.bot.status` | `telegram_bot` | Ninguno |
| `telegram.webhook.info` | `telegram_webhook` | Ninguno |
| `telegram.webhook.delete` | `telegram_operation` | `dropPendingUpdates`, `confirmWrite` |
| `telegram.updates.receive` | `telegram_message[]` | `offset`, `limit`, `timeout`, `allowedUpdates` |
| `telegram.messages.send` | `telegram_message` | `chatId`, `text`, `parseMode`, `disableNotification`, `replyToMessageId`, `confirmWrite` |
| `telegram.documents.send` | `telegram_message` | `chatId`, `fileName`, `mimeType`, `dataBase64`, `path`, `caption`, `parseMode`, `confirmWrite` |

### `telegram_message`

```json
{
  "type": "telegram_message",
  "id": 42,
  "updateId": 123456,
  "chatId": 123456789,
  "direction": "inbound",
  "text": "Texto recibido",
  "messageType": "text",
  "timestamp": "2026-05-19",
  "source": "telegram"
}
```

## WhatsApp Web

Integración registrada:

```json
{
  "id": "whatsapp",
  "provider": "WhatsApp Web / Baileys",
  "access": "local_bridge",
  "endpoint": "/api/integrations/query",
  "docs": "/docs/WHATSAPP_INTEGRATION.md"
}
```

Usa una pasarela local en `integrations/whatsapp-bridge` basada en `baileys`, siguiendo el patrón de OpenClaw: QR en un proceso sidecar actualizable y contrato interno estable para Animal Charlie.

### Capacidades

| Capacidad | Resultado | Filtros normalizados |
|---|---|---|
| `whatsapp.connection.status` | `whatsapp_connection` | Ninguno |
| `whatsapp.connection.qr` | `whatsapp_qr` | Ninguno |
| `whatsapp.chats.search` | `whatsapp_chat[]` | `query`, `limit` |
| `whatsapp.messages.search` | `whatsapp_message[]` | `chatId`, `direction`, `query`, `limit` |
| `whatsapp.messages.media` | `whatsapp_media` | `messageId` |
| `whatsapp.messages.send` | `whatsapp_send_result` | `to`, `text`, `confirmWrite` |
| `whatsapp.documents.send` | `whatsapp_send_result` | `to`, `fileName`, `mimeType`, `dataBase64`, `path`, `caption`, `confirmWrite` |
| `whatsapp.connection.disconnect` | `whatsapp_connection` | `confirmWrite` |

Las capacidades de escritura requieren `confirmWrite=true`. El adaptador permite escritura a usuarios `admin` y `technician`.

### `whatsapp_message`

```json
{
  "type": "whatsapp_message",
  "id": "ABC123",
  "chatId": "34600111222@s.whatsapp.net",
  "from": "34600111222@s.whatsapp.net",
  "to": "me",
  "direction": "inbound",
  "text": "Texto recibido",
  "messageType": "conversation",
  "hasMedia": false,
  "timestamp": "2026-05-18T22:00:00.000Z",
  "source": "whatsapp"
}
```

## LIGAL

Integración registrada:

```json
{
  "id": "ligal",
  "provider": "LIGAL",
  "access": "adapter",
  "endpoint": "/api/integrations/query",
  "docs": "/docs/LIGAL_API.md"
}
```

La autenticación externa de LIGAL se gestiona dentro del adaptador. La configuración preferida es `data/ligal-accounts.json`, con varias cuentas locales identificadas por id y una cuenta predeterminada. Las variables `LIGAL_USER`/`LIGAL_PASSWORD` y los ficheros `data/ligal-username.txt` y `data/ligal-password.txt` se conservan como compatibilidad de una sola cuenta. Los secretos y tokens externos no se entregan a los módulos consumidores.

Cada explotación selecciona su cuenta LIGAL y el enlace de DataHub conserva `account_id`. La provisión automática recorre todas las cuentas, asigna a las nuevas explotaciones la cuenta que las descubre y no sobrescribe una selección manual existente. El endpoint `GET /api/integrations/ligal/accounts` publica únicamente metadatos enmascarados para construir el selector de la ficha.

El adaptador conserva medias de periodo (`ligal_average`), muestreos (`ligal_sample`) y sanidad animal individual (`ligal_mastitis_sample`). La sincronización consulta `ligal.sample_types.search` (`/api/sampleTypes`) y, salvo configuración explícita, recorre todos los tipos con su `DefaultDateFilterType`: `I0` usa `4` y `04` usa `2` en la cuenta validada. Si `/api/samples` no devuelve filas, también aprovecha las determinaciones anidadas en medias (`MEDIAS_DETALLE`, `MEDIAS_DETALLE_DETERMINACIONES`, `DETERMINACIONES` y `MUESTRAS`).

Las referencias `04` de mamitis se cruzan con nombres CEGCOL únicamente cuando la coincidencia normalizada es única. Se guardan cuarterón, RCS, identificación, N+L, antibiograma y PDF. Los nombres ambiguos o ausentes conservan una identidad `LIGAL-NAME-*` marcada como no resuelta.

### Capacidades principales de lectura

| Capacidad | Resultado | Filtros normalizados |
|---|---|---|
| `ligal.tests.search` | `lab_test[]` | `materialCode`, `sampleTypeCode`, `averageTypeCode`, `graphicable`, `alerts`, `top`, `skip`, `orderBy` |
| `ligal.tests.get` | `lab_test` | `testCode` |
| `ligal.user_profile.get` | `ligal_user_profile` | Sin filtros |
| `ligal.parameters.safe.search` | `ligal_parameter[]` | Sin filtros; allowlist segura |
| `ligal.customer_types.search` | `lab_customer_type[]` | `top`, `skip`, `orderBy` |
| `ligal.materials.search` | `lab_material[]` | `sampleTypeCode`, `top`, `skip`, `orderBy` |
| `ligal.milk_types.search` | `lab_milk_type[]` | `top`, `skip`, `orderBy` |
| `ligal.average_types.search` | `lab_average_type[]` | `top`, `skip`, `orderBy` |
| `ligal.profiles.search` | `lab_profile[]` | `top`, `skip`, `orderBy` |
| `ligal.revisions.search` | `lab_revision[]` | `top`, `skip`, `orderBy` |
| `ligal.methods.search` | `lab_method[]` | `top`, `skip`, `orderBy` |
| `ligal.customer_containers.search` | `lab_customer_container[]` | `customerCode`, `top`, `skip`, `orderBy` |
| `ligal.customers.search` | `lab_customer[]` | `typeCode`, `code`, `relatedCode`, `name`, `vatReg`, `rega`, `tagSerie`, `relationCode`, `vigente`, `route`, `top`, `skip`, `orderBy` |
| `ligal.customers.related.search` | `lab_related_customer[]` | `customerCode`, `top`, `skip`, `orderBy` |
| `ligal.sample_types.search` | `lab_sample_type[]` | `top`, `skip`, `orderBy` |
| `ligal.samples.search` | `lab_sample[]` | `sampleTypeCode`, `sampleDateFilterType`, `fromDate`, `toDate`, `customerCode`, `relatedCustomerCode`, `materialCode`, `reference`, `testCodes`, `testFilters`, `collaborativeId`, `revisionCodes`, `farmerTank`, `tankCode`, `top`, `skip`, `orderBy` |
| `ligal.samples.get` | `lab_sample` | `code` |
| `ligal.samples.download_url.get` | `lab_sample_download_url` | `code` |
| `ligal.samples.report.get` | descriptor de PDF backend | `code` |
| `ligal.samples.excel.get` | `lab_samples_excel` | mismos filtros de muestras |
| `ligal.averages.search` | `lab_average[]` | `fromDate`, `toDate`, `customerCode`, `relatedCustomerCode`, `averageTypeCode`, `route`, `top`, `skip`, `orderBy` |
| `ligal.account.limit_values.search` | `ligal_limit_value[]` | `testCode` |
| `ligal.requests.*.search` | modelos `lab_request_*` | Según `docs/LIGAL_API.md`; algunas rutas requieren permisos no disponibles para todos los usuarios LIGAL. |
| `ligal.pickers.*` | `milk_picker[]` o resultado de escritura | Según `docs/LIGAL_API.md`; algunas rutas requieren permisos específicos. |

### Capacidades de escritura

Las capacidades externas de escritura existen para cubrir el Swagger completo, pero Animal Charlie las bloquea salvo que:

- el usuario interno sea `admin`;
- la petición incluya `confirmWrite: true`;
- el payload llegue dentro de `filters.body` cuando la operación requiera cuerpo.

Capacidades de escritura registradas:

- `ligal.account.users.create`
- `ligal.account.limit_values.save`
- `ligal.account.subscription.renew`
- `ligal.pickers.create`
- `ligal.pickers.create_many`
- `ligal.requests.receptions.create`

### Tipos normalizados

#### `lab_test`

```json
{
  "type": "lab_test",
  "id": 1,
  "code": "000001",
  "name": "Ensayo",
  "description": "Ensayo",
  "abbreviation": "ENS",
  "graphicable": true,
  "alert": false,
  "source": "ligal"
}
```

#### `lab_customer`

```json
{
  "type": "lab_customer",
  "id": 1,
  "code": "1578100008",
  "name": "Cliente",
  "vat": "B00000000",
  "rega": "ES000000000000",
  "relation": { "code": "R1", "route": "Ruta" },
  "source": "ligal"
}
```

#### `lab_sample`

```json
{
  "type": "lab_sample",
  "code": "M000001",
  "sampleDate": "2026-05-18T00:00:00",
  "receptionDate": "2026-05-18T00:00:00",
  "customer": {},
  "material": {},
  "reports": [],
  "source": "ligal"
}
```

#### `lab_average`

```json
{
  "type": "lab_average",
  "id": 1,
  "period": "2026-05",
  "customer": {},
  "relatedCustomer": {},
  "nonConformity": false,
  "source": "ligal"
}
```

## Ejemplos curl

Buscar medicamento:

```bash
curl -sS -X POST http://127.0.0.1:8020/api/integrations/query \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"integration":"cimavet","capability":"veterinary_medicines.search","filters":{"query":"eq-zona"}}'
```

Detalle de presentación:

```bash
curl -sS -X POST http://127.0.0.1:8020/api/integrations/query \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"integration":"cimavet","capability":"veterinary_presentations.get","filters":{"nationalCode":"570828"}}'
```

Registro de cambios:

```bash
curl -sS -X POST http://127.0.0.1:8020/api/integrations/query \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"integration":"cimavet","capability":"veterinary_change_log.search","filters":{"dateFrom":"01/05/2026"}}'
```

Buscar ensayos LIGAL:

```bash
curl -sS -X POST http://127.0.0.1:8020/api/integrations/query \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"integration":"ligal","capability":"ligal.tests.search","filters":{"top":20,"skip":0,"orderBy":"Code"}}'
```

Buscar clientes LIGAL:

```bash
curl -sS -X POST http://127.0.0.1:8020/api/integrations/query \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"integration":"ligal","capability":"ligal.customers.search","filters":{"top":20,"skip":0,"orderBy":"Code"}}'
```

Comprobar Outlook:

```bash
curl -sS -X POST http://127.0.0.1:8020/api/integrations/query \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"integration":"outlook","capability":"outlook.auth.status","filters":{}}'
```

Listar correos Outlook:

```bash
curl -sS -X POST http://127.0.0.1:8020/api/integrations/query \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"integration":"outlook","capability":"outlook.mail.messages.search","filters":{"folder":"Inbox","top":25}}'
```

Enviar correo Outlook:

```bash
curl -sS -X POST http://127.0.0.1:8020/api/integrations/query \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"integration":"outlook","capability":"outlook.mail.messages.send","filters":{"to":"cliente@empresa.com","subject":"Factura","body":"Adjuntamos factura.","confirmWrite":true}}'
```

Estado WhatsApp:

```bash
curl -sS -X POST http://127.0.0.1:8020/api/integrations/query \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"integration":"whatsapp","capability":"whatsapp.connection.status","filters":{}}'
```

Leer mensajes WhatsApp:

```bash
curl -sS -X POST http://127.0.0.1:8020/api/integrations/query \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"integration":"whatsapp","capability":"whatsapp.messages.search","filters":{"limit":30}}'
```

Estado Telegram:

```bash
curl -sS -X POST http://127.0.0.1:8020/api/integrations/query \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"integration":"telegram","capability":"telegram.bot.status","filters":{}}'
```

Leer actualizaciones Telegram:

```bash
curl -sS -X POST http://127.0.0.1:8020/api/integrations/query \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"integration":"telegram","capability":"telegram.updates.receive","filters":{"limit":30,"timeout":0}}'
```

## Añadir una nueva integración

Para añadir otro proveedor:

1. Registrar la integración en `INTEGRATIONS` con `id`, `provider`, `endpoint` y `capabilities`.
2. Crear una función tipo `execute_{integration}_query(capability, filters, include_raw=False)`.
3. Traducir filtros normalizados a parámetros externos dentro del adaptador.
4. Normalizar la respuesta a la envoltura común.
5. No exponer detalles del proveedor al módulo consumidor salvo en `source` y `request.translatedQuery`.
6. Añadir ejemplos de uso en este documento.

## Reglas para módulos consumidores

- No usar `fetch()` contra CIMAVet, LIGAL, Outlook, Telegram, WhatsApp ni contra otra API externa.
- No depender de nombres de campos externos como `nregistro`, `cn`, `resultados` o `totalFilas`.
- Usar campos normalizados como `registrationNumber`, `nationalCode`, `code`, `name`, `result.items`, `result.total`.
- Tratar `source` como trazabilidad, no como modelo de negocio.
- Pedir nuevas capacidades si un módulo necesita otro tipo de consulta.
- Consultar `GET /api/integrations` para descubrir capacidades, credenciales y estado antes de mostrar acciones al usuario.
