# 11 · Integrações

Fonte: https://muriloht.com/idempiere/11-integracoes (Guia iDempiere, Murilo H. Torquato). Versão alvo: iDempiere 14.

Objetivos:
- Expor as visitas para outros sistemas pela API REST do iDempiere
- Criar e completar uma visita por HTTP, com as mesmas regras da tela
- Avisar outro sistema quando uma visita é concluída
- Fazer chamadas externas fora da transação, sem travar o usuário
- Escolher entre entrada (API) e saída (notificação) em cada integração

## Antes de começar

- Módulo 09 concluído: a visita é um documento com o botão **Document Action**.
- `curl` e `nc` (netcat) no terminal. O `nc` faz o papel do "outro sistema" que recebe a notificação.
- Acesso à internet no servidor de desenvolvimento, para o Extension Manager baixar o plugin REST.

## O que você vai construir

A GardenWorld tem um aplicativo de campo que os técnicos usam no celular, e um sistema de atendimento que precisa saber quando uma visita termina. Duas direções de integração:

- **Entrada:** o aplicativo cria e consulta visitas no iDempiere, pela **API REST**.
- **Saída:** quando uma visita é concluída, o iDempiere avisa o sistema de atendimento, com um POST para uma URL configurável.

| Direção | Quem inicia | Ferramenta | Quando usar |
|---|---|---|---|
| Entrada | O outro sistema | API REST (plugin) | O outro sistema precisa ler ou gravar no iDempiere |
| Saída | O iDempiere | Event handler + HTTP | O outro sistema precisa saber de algo assim que acontece |

## Parte 1: a API REST

### 1. Instale o plugin REST

O iDempiere não traz API REST no core. Ela vem de um plugin mantido pela comunidade, [bxservice/idempiere-rest](https://github.com/bxservice/idempiere-rest), com documentação em [bxservice.github.io/idempiere-rest-docs](https://bxservice.github.io/idempiere-rest-docs/). Ele segue o padrão OData para filtros e consultas.

O iDempiere 14 instala esse tipo de plugin sem compilar nada, pelo **Extension Manager**: uma loja de extensões revisadas pela comunidade, publicada no repositório [idempiere-extension-repository](https://github.com/idempiere/idempiere-extension-repository). A configuração `server.product` do Eclipse já aponta para ele.

1. Entre como **System** e abra o formulário **Extension Management**.
2. Localize **iDempiere REST API** e clique para instalar. O iDempiere baixa o jar, instala e inicia o plugin.
3. Confira no console do Eclipse: `ss com.trekglobal.idempiere.rest.api` deve mostrar `ACTIVE`. A janela **Extension Registry** lista o que está instalado.

> O Extension Manager baixa o plugin da internet. Na sua máquina isso é conveniente; num servidor de produção, trate como qualquer instalação de software: confira a versão, teste antes em homologação e mantenha a lista do que foi instalado.

### 2. Autentique

Toda chamada leva um token. Pegue um para o GardenAdmin, já escolhendo empresa, perfil e organização:

```bash
curl -s -X POST http://localhost:8080/api/v1/auth/tokens \
  -H "Content-Type: application/json" \
  -d '{
    "userName": "GardenAdmin",
    "password": "GardenAdmin",
    "parameters": { "clientId": 11, "roleId": 102, "organizationId": 11, "warehouseId": 103 }
  }'
```

`11` é a GardenWorld, `102` o perfil GardenWorld Admin, `11` a organização HQ e `103` o armazém HQ Warehouse, os IDs do banco de demonstração. A resposta traz um `token`, válido por uma hora. Guarde:

```bash
TOKEN="cole o token aqui"
```

> Só perfis com **Role Type** em branco ou `WebService` podem entrar pela API. Em produção, crie um usuário e um perfil só para cada integração, com acesso apenas ao necessário. Nunca use um usuário de pessoa numa integração.

### 3. Consulte visitas

O recurso `models` dá acesso a qualquer tabela pelo nome, respeitando o acesso do perfil:

```bash
curl -s -G http://localhost:8080/api/v1/models/EDU_Visita \
  --data-urlencode '$filter=C_BPartner_ID eq 118' \
  --data-urlencode '$select=DocumentNo,EDU_DataVisita,DocStatus' \
  -H "Authorization: Bearer $TOKEN"
```

`118` é o Joe Block. O `-G` com `--data-urlencode` monta a URL com os espaços codificados. A resposta é um JSON com as visitas dele. Os parâmetros com `$` seguem o OData:

| Parâmetro | Exemplo | Faz |
|---|---|---|
| `$filter` | `Processed eq false` | Filtra (`eq`, `ne`, `gt`, `lt`, `and`, `or`, `in`) |
| `$select` | `DocumentNo,DocStatus` | Escolhe as colunas |
| `$orderby` | `EDU_DataVisita desc` | Ordena |
| `$top` / `$skip` | `10` / `20` | Pagina |
| `$expand` | `EDU_VisitaLinha` | Traz os registros filhos junto |

### 4. Crie e complete uma visita

```bash
curl -s -X POST http://localhost:8080/api/v1/models/EDU_Visita \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "C_BPartner_ID": 118,
    "SalesRep_ID": 101,
    "EDU_DataVisita": "2026-10-15",
    "EDU_TipoVisita": "MA",
    "EDU_IsUrgente": false,
    "Description": "Criada pelo aplicativo de campo",
    "EDU_VisitaLinha": [
      { "Line": 10, "M_Product_ID": 123, "Qty": 2 }
    ],
    "doc-action": "CO"
  }'
```

Três coisas acontecem numa chamada só: o cabeçalho é gravado, a linha vai junto (o nome da tabela filha vira uma lista dentro do JSON) e `doc-action` roda o **Complete**, como o botão da janela. Abra a visita no iDempiere: está lá, completa, com endereço preenchido pelo `beforeSave`.

Agora prove que as regras valem. Repita a chamada **sem** `EDU_VisitaLinha`. A visita é criada, mas o Complete falha com a mensagem `EDU_VisitaSemLinhas`, a mesma da tela.

Esse é o resultado dos módulos 04 a 09: a regra mora na `MVisita`, então a API não precisa reimplementar nada. Se a regra estivesse num callout, a API passaria por cima dela.

> O `Line` foi informado à mão. A Default Logic com `@SQL=` do módulo 01 é da tela; pela API, só valem os padrões do banco e o que o seu Java preenche. Para não depender do cliente da API, o certo seria calcular o `Line` num `beforeSave` de uma `MVisitaLinha`. Fica como desafio.

## Parte 2: avisar outro sistema

### 5. A configuração

Na janela **System Configurator**, como **System**:

| Campo | Valor |
|---|---|
| Name | `EDU_VISITA_WEBHOOK_URL` |
| Description | `URL que recebe um POST quando uma visita é concluída. Vazio desliga.` |
| Configured Value | `http://localhost:9999/visitas` |
| Configuration Level | `Client` |
| Entity Type | `EDU` |

Com o nível `Client`, cada empresa aponta para o próprio sistema, ou deixa vazio para não integrar.

### 6. O event handler de documento

No `MANIFEST.MF`, acrescente `com.google.gson` ao `Require-Bundle`: é a biblioteca de JSON que o próprio core usa.

Crie `com.gardenworld.visitas.event.NotificarVisitaConcluida`, no mesmo pacote do event handler do módulo 05 (o `VisitasEventManager` já registra tudo o que estiver nele):

```java
package com.gardenworld.visitas.event;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.logging.Level;

import org.adempiere.base.annotation.EventTopicDelegate;
import org.adempiere.base.annotation.ModelEventTopic;
import org.adempiere.base.event.annotations.ModelEventDelegate;
import org.adempiere.base.event.annotations.doc.AfterComplete;
import org.compiere.model.MSysConfig;
import org.compiere.util.CLogger;
import org.compiere.util.Trx;
import org.compiere.util.TrxEventListener;
import org.compiere.util.Util;
import org.osgi.service.event.Event;

import com.gardenworld.visitas.model.MVisita;
import com.google.gson.JsonObject;

@EventTopicDelegate
@ModelEventTopic(modelClass = MVisita.class)
public class NotificarVisitaConcluida extends ModelEventDelegate<MVisita> {

    public static final String SYSCONFIG_URL = "EDU_VISITA_WEBHOOK_URL";

    private static final CLogger log = CLogger.getCLogger(NotificarVisitaConcluida.class);
    private static final HttpClient http = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5))
            .build();

    public NotificarVisitaConcluida(MVisita po, Event event) {
        super(po, event);
    }

    @AfterComplete
    public void aoCompletar() {
        MVisita visita = getModel();
        String url = MSysConfig.getValue(SYSCONFIG_URL, "", visita.getAD_Client_ID());
        if (Util.isEmpty(url, true))
            return; // integração desligada nesta empresa

        // Monta o conteúdo agora, enquanto o registro está em memória
        JsonObject json = new JsonObject();
        json.addProperty("evento", "visita.concluida");
        json.addProperty("visitaId", visita.getEDU_Visita_ID());
        json.addProperty("numero", visita.getDocumentNo());
        json.addProperty("cliente", visita.get_DisplayValue(MVisita.COLUMNNAME_C_BPartner_ID, true));
        json.addProperty("data", String.valueOf(visita.getEDU_DataVisita()));
        String corpo = json.toString();

        // Envia só depois do commit: se a transação falhar, ninguém é avisado
        Trx trx = Trx.get(visita.get_TrxName(), false);
        if (trx == null) {
            enviar(url, corpo);
            return;
        }
        trx.addTrxEventListener(new TrxEventListener() {
            @Override
            public void afterCommit(Trx t, boolean success) {
                if (success)
                    enviar(url, corpo);
            }

            @Override
            public void afterRollback(Trx t, boolean success) {
            }

            @Override
            public void afterClose(Trx t) {
            }
        });
    }

    private static void enviar(String url, String corpo) {
        HttpRequest request = HttpRequest.newBuilder(URI.create(url))
                .timeout(Duration.ofSeconds(10))
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(corpo))
                .build();
        // Assíncrono: a tela do usuário não espera o outro sistema responder
        http.sendAsync(request, HttpResponse.BodyHandlers.discarding())
            .whenComplete((resposta, erro) -> {
                if (erro != null)
                    log.log(Level.WARNING, "Falha ao notificar " + url, erro);
                else if (resposta.statusCode() >= 300)
                    log.warning("Notificação recusada por " + url + ": HTTP " + resposta.statusCode());
            });
    }
}
```

Este é o código mais importante do módulo, e não pelo HTTP. Pelas três decisões:

1. **`@AfterComplete`, e não `@AfterChange`.** O evento de documento dispara exatamente quando a visita completa, venha da tela, da API ou de um processo. Isso só funciona porque a `MVisita` chama `fireDocValidate` (módulo 09).
2. **O envio espera o commit.** O `@AfterComplete` roda **dentro** da transação. Se você chamasse o outro sistema ali e, logo depois, algo desse rollback, o sistema de atendimento saberia de uma visita que nunca foi concluída. O `TrxEventListener` adia o envio para depois do commit, e só se ele deu certo.
3. **O envio é assíncrono e tolerante a falha.** `sendAsync` não prende a tela do usuário esperando a resposta; uma falha vira aviso no log, não erro na tela. A visita foi concluída de qualquer jeito: a notificação é consequência, não condição.

### 7. Teste com o netcat

Num terminal, deixe o "sistema de atendimento" escutando:

```bash
nc -l 9999
```

Reinicie o servidor. Complete uma visita pela janela (ou pela API, passo 4). O terminal do `nc` mostra a requisição chegando:

```
POST /visitas HTTP/1.1
Content-Type: application/json
...

{"evento":"visita.concluida","visitaId":1000012,"numero":"VT-1000012","cliente":"Joe Block","data":"2026-10-15 00:00:00.0"}
```

O `nc` não responde, então o log do servidor vai mostrar um aviso de falha depois do tempo limite: é o esperado. Um receptor de verdade responde `200`.

Agora teste o desligamento: apague o valor da configuração, faça **Cache Reset** e complete outra visita. Nada chega.

### 8. Commit

Inclua no Pack Out a configuração nova (**Data**, `AD_SysConfig`, filtrando pelo nome). Gere o `2Pack_1.0.7.zip`.

```bash
git add .
git commit -m "Visitas 1.0.7: notificação de visita concluída"
```

## Checkpoint

- [ ] O plugin REST está ACTIVE e devolve um token para o GardenAdmin
- [ ] Listo as visitas de um cliente com GET /api/v1/models/EDU_Visita
- [ ] Crio uma visita com linha por POST e ela aparece na janela
- [ ] Completar pela API sem linhas devolve o erro da regra do módulo 09
- [ ] Ao completar uma visita, o endereço configurado recebe um JSON
- [ ] Com a URL vazia no System Configurator, nada é enviado

## Erros comuns

| Sintoma | Causa provável | Correção |
|---|---|---|
| **Extension Management** diz que o repositório não está configurado | O servidor subiu sem `-DIDEMPIERE_EXTENSION_REPOSITORY` | Use a configuração `server.product` do repositório, que já traz o parâmetro |
| `401` em toda chamada | Token ausente, vencido ou sem o prefixo `Bearer` | Gere outro token; confira o cabeçalho |
| Login pela API recusado | Perfil com **Role Type** diferente de vazio ou `WebService` | Use outro perfil, ou um perfil próprio para a integração |
| `POST` falha com coluna obrigatória | Campo que a tela preenche sozinha (Default Logic) ficou fora do JSON | Informe no JSON, ou calcule no `beforeSave` |
| Visita criada, mas não completa | `doc-action` recusado por uma regra | Leia a mensagem na resposta: é a mesma da tela |
| Nada chega no `nc` | URL vazia, cache antigo, ou o handler não foi registrado | Confira a configuração, **Cache Reset**, e `OSGI-INF/` |
| O `nc` recebe, mas a visita não completou | Envio feito antes do commit | Confira o `TrxEventListener`: nunca chame fora dele |

## Desafio

1. Crie a `MVisitaLinha` com um `beforeSave` que calcula o `Line` quando ele vem vazio, e repita o `POST` do passo 4 sem `Line`.
2. Acrescente um cabeçalho `X-Assinatura` com um HMAC-SHA256 do corpo, usando um segredo guardado numa segunda configuração. É assim que o receptor confere que a notificação veio mesmo do iDempiere.

## Referência

### Outros pontos de integração

| Recurso | O que é | Quando usar |
|---|---|---|
| `api/v1/processes` (plugin REST) | Roda processos e relatórios pela API | Gerar o relatório do módulo 07 de fora |
| `api/v1/windows` (plugin REST) | Grava pela janela, com callouts e Default Logic | O cliente da API quer o comportamento exato da tela |
| Serviço OSGi próprio | Uma interface Java publicada pelo seu plugin e consumida por outros | Dois plugins seus precisam conversar sem depender um do outro |

### Garantias da notificação deste módulo

É **no máximo uma vez**: se o outro sistema estiver fora do ar, a notificação se perde (fica o aviso no log). Quando perder não é aceitável, o padrão é gravar a notificação numa tabela própria, na mesma transação da visita, e ter um processo agendado (módulo 06) que envia e reenvia até receber `200`. É mais trabalho, e é o que se faz para integrações que movimentam dinheiro ou estoque.

## Próximo módulo

[12 · Rumo à produção](https://muriloht.com/idempiere/12-rumo-a-producao): compilar o plugin como jar, instalar num servidor, boas práticas de desempenho e como contribuir com o projeto.
