# Elevor NFe Automation — Documentação Completa v1.0

> Documento de referência técnica para reprodução integral do sistema do zero.
> Versão congelada em 01/06/2026. Inclui arquitetura, banco de dados, workflows, scripts Browserless, bugs resolvidos e procedimentos operacionais.

---

## Sumário

1. [Visão Geral do Sistema](#1-visão-geral-do-sistema)
2. [Infraestrutura e Containers Docker](#2-infraestrutura-e-containers-docker)
3. [Fluxo de Dados End-to-End](#3-fluxo-de-dados-end-to-end)
4. [Schema do Banco de Dados](#4-schema-do-banco-de-dados)
5. [Workflows n8n — Referência Completa](#5-workflows-n8n--referência-completa)
6. [Script Browserless v14 — Pontos Críticos](#6-script-browserless-v14--pontos-críticos)
7. [ERP360 ASP.NET WebForms — Regras de Automação](#7-erp360-aspnet-webforms--regras-de-automação)
8. [Dashboard Web](#8-dashboard-web)
9. [Credenciais e Configurações](#9-credenciais-e-configurações)
10. [Bugs Críticos Resolvidos](#10-bugs-críticos-resolvidos)
11. [Restrições do n8n Task Runner](#11-restrições-do-n8n-task-runner)
12. [Restrições do Browserless Sandbox](#12-restrições-do-browserless-sandbox)
13. [Procedimento de Teste E2E](#13-procedimento-de-teste-e2e)
14. [Operação e Monitoramento](#14-operação-e-monitoramento)
15. [Queries Úteis de Diagnóstico](#15-queries-úteis-de-diagnóstico)
16. [Decisões de Arquitetura — Justificativas](#16-decisões-de-arquitetura--justificativas)

---

## 1. Visão Geral do Sistema

O **Elevor NFe Automation** automatiza a escrituração de Notas Fiscais Eletrônicas (NF-e) recebidas de fornecedores no ERP Elevor360. O processo que normalmente exigiria um operador navegando manualmente no ERP é realizado por um robô Puppeteer (via Browserless) orquestrado por workflows n8n.

### Problema resolvido

O ERP Elevor360 é um sistema ASP.NET WebForms legacy hospedado em servidor do cliente (`elevor01.ddns.net`). Não possui API REST. A única forma de inserir notas é via interface web. O sistema de automação simula um operador humano: abre o browser, faz login, navega até a tela de entrada de fornecedor, preenche os dados da NF-e e clica Gravar.

### Componentes principais

```
XMLs de NF-e (fornecedores)
        │
        ▼
[Dashboard Web] ──upload──► [n8n Ingest] ──► [MinIO] ──► [n8n Parser]
                                                               │
                                              ┌────────────────┘
                                              ▼
                                        [PostgreSQL]
                                        nfe_jobs / nfe_items
                                              │
                              ┌───────────────┘
                              ▼
                    [n8n Dispatcher] (3min)
                              │
                              ▼
                    [n8n Escriturar-Job]
                              │
                              ▼
                    [Browserless Puppeteer]
                              │
                              ▼
                    [ERP Elevor360 WebForms]
                              │
                    ┌─────────┴──────────┐
                    ▼                    ▼
                 success         aguardando_associacao
                                         │
                              [Operador no Dashboard]
                                         │
                              [n8n Associar / IA Match]
                                         │
                              [Reprocessar via Dispatcher]
```

---

## 2. Infraestrutura e Containers Docker

A infraestrutura roda em uma VPS Ubuntu com Docker gerenciado pelo Easypanel. Todos os containers de apoio (banco, cache, object storage, browser) estão na rede interna `easypanel-n0ns`, sem exposição direta à internet (exceto via Traefik).

### Mapeamento de containers

| Container | Imagem/Papel | Porta exposta (host:container) | Rede interna |
|---|---|---|---|
| `n0ns_n8n-postgres.1.*` | PostgreSQL 17 | apenas interno | easypanel-n0ns |
| `minio-elevor` | MinIO (object storage) | `127.0.0.1:9100:9000` | easypanel-n0ns |
| `browserless-elevor` | Browserless/Chrome | `127.0.0.1:3100:3000` | easypanel-n0ns |
| `redis-elevor` | Redis | `127.0.0.1:6390:6379` | easypanel-n0ns |
| `elevor-dashboard` | nginx (static) | exposto via Traefik | easypanel-n0ns |
| n8n (gerenciado Easypanel) | n8n com task-runner | `https://n8n.n0ns.tech` | easypanel-n0ns |

### Traefik

O Traefik é o roteador reverso que termina TLS e roteia:
- `n8n.n0ns.tech` → container n8n
- `elevor.n0ns.tech` → container `elevor-dashboard` (nginx)

### MinIO

- **Bucket:** `elevor-xml-archive`
- **Estrutura de pastas:** `received/YYYY/MM/` (ex: `received/2026/06/`)
- **Credenciais:** usuário `elevor_admin`, senha armazenada em `/opt/elevor-automation/.env` (variável `MINIO_SECRET_KEY` pode estar vazia — usar `docker inspect minio-elevor` para obter as credenciais reais se necessário)
- **CLI dentro do container:** `docker exec minio-elevor mc alias set local http://127.0.0.1:9000 elevor_admin 'SENHA'`
- **Endpoint interno (para n8n):** `http://minio-elevor:9000` (não usar localhost/127.0.0.1 de dentro de outros containers)

### Browserless

- **Endpoint de função:** `POST http://browserless-elevor:3000/function`
- **Body:** `{"code": "<string com código JS>", "context": {<objeto passado para o script>}}`
- **Chrome versão:** 148
- **Timeout configurado no n8n:** 180 segundos (aumentado de 120s na v14)
- **Por que Browserless e não Puppeteer direto:** Browserless gerencia o ciclo de vida do Chrome, pooling de sessões e elimina a necessidade de instalar Chrome na VPS. O script JS é enviado via REST e executado no contexto de uma página Chrome real.

### PostgreSQL

- **Container:** `n0ns_n8n-postgres.1.*` (nome pode mudar com redeployment; descobrir via `docker ps | grep postgres`)
- **Banco:** `elevor_import`
- **Schema:** `app`
- **Usuário principal:** `postgres` (owner das tabelas)
- **Usuário de aplicação:** `elevor_import_user` (usado pelo n8n via credencial `ny8L4S8VHJy0ojgK`)
- **Credencial n8n MinIO:** `FOYYHs3SNl8RfqY6`
- **Credencial n8n Redis:** `Ulzq0Hu0q8Zjx1RG`
- **Credencial n8n Anthropic:** `s3VRLeOygByWgFUQ`

### Arquivo de configuração principal

`/opt/elevor-automation/.env` — contém `N8N_API_KEY` e configurações de MinIO.

---

## 3. Fluxo de Dados End-to-End

### Fase 1 — Ingestão

1. Operador arrasta arquivo(s) XML de NF-e para o dashboard (`elevor.n0ns.tech`)
2. Dashboard faz `POST https://n8n.n0ns.tech/webhook/elevor/xml/upload` com body raw XML e header `Content-Type: application/xml`
3. Workflow **elevor-ingest-trg** recebe o XML em `item.json.body`
4. Extrai `chaveNfe` (44 dígitos) e `cnpjEmit` (14 dígitos) do XML
5. Salva o XML no MinIO em `received/YYYY/MM/<chaveNfe>.xml`
6. Cria registro em `app.nfe_jobs` com `status = 'received'`
7. Responde 200 com `{ok: true, job_id: N, chave_nfe: "..."}`

### Fase 2 — Parsing

1. Workflow **elevor-parser-proc** roda a cada 30 segundos
2. Busca todos os jobs com `status = 'received'`
3. Para cada job: baixa o XML do MinIO, parseia os elementos `<det>` (itens da nota)
4. Insere registros em `app.nfe_items` (um por item da NF-e)
5. Atualiza `nfe_jobs.status = 'parsed'` e `parsed_at = NOW()`

### Fase 3 — Escrituração (principal)

1. Workflow **elevor-escriturar-dispatcher** roda a cada 3 minutos
2. **Mutex serial:** executa `SELECT 1 FROM nfe_jobs WHERE status='processing'` — se existir, aborta (não processa novo job enquanto há um em andamento)
3. Pega 1 job com `status = 'parsed'` OU (`status = 'failed'` AND `last_error ILIKE '%timeout%'` AND `retry_count < 3`)
4. Prioridade: `parsed` > `failed-timeout` (ORDER BY CASE WHEN)
5. Chama via HTTP POST o webhook do **elevor-escriturar-job** com `{job_id: N}`

### Fase 4 — Job de Escrituração

1. **elevor-escriturar-job** valida o job_id e carrega dados do banco
2. Verifica mapeamentos: `SELECT c_prod, codigo_elevor FROM produto_mapeamento WHERE c_prod = ANY(array_de_c_prods) AND ativo = true`
3. Se houver produtos sem mapeamento → `status = 'aguardando_associacao'`, workflow para
4. Se todos mapeados → monta o `context` para o script Browserless:
   ```json
   {
     "chaveNfe": "35260527042473...",
     "vencimento": "06/06/2026",
     "jobId": 1,
     "natureza": "46",
     "mapeamentos": [
       {"c_prod": "LAR110", "codigo_elevor": "LAR110"},
       {"c_prod": "12409110", "codigo_elevor": "12410"}
     ]
   }
   ```
5. Chama `POST http://browserless-elevor:3000/function` com o script v14 + context
6. Script Puppeteer executa no Chrome, escritura no ERP
7. Retorna `{success: true}` ou `{hasError: true, error: "...", screenshot: "<base64>"}`
8. Workflow salva resultado: `status = 'success'` ou `status = 'failed'`
9. 7 nós de log (fan-out paralelo) inserem entradas em `nfe_job_logs`

### Fase 5 — Associação de Produtos (quando necessário)

1. Job fica em `aguardando_associacao`
2. Dashboard exibe o job com botão "Associar manualmente"
3. Operador busca no campo de busca (ILIKE em `erp_produtos_cache`) e seleciona o produto correto
4. Dashboard chama `POST /elevor/associar` com `{c_prod, cnpj_emit, codigo_elevor, nome_elevor}`
5. Workflow **elevor-associar** insere/atualiza `produto_mapeamento`
6. Opcionalmente: `POST /elevor/auto-associar` tenta associar automaticamente todos os itens
7. Dispatcher (próximo ciclo de 3min) pega o job e reprocessa

### Fase 6 — Recovery

Workflow **elevor-recovery** roda a cada 10 minutos. Busca jobs com `status = 'processing'` há mais de 10 minutos (browser travado, exception não capturada) e os volta para `status = 'parsed'` para reprocessamento.

---

## 4. Schema do Banco de Dados

Banco: `elevor_import`, schema: `app`. Restaurar com: `psql -U postgres elevor_import < schema-app.sql`

### Tabela: `nfe_jobs` — fila principal de NF-es

```sql
CREATE TABLE app.nfe_jobs (
    id              bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    chave_nfe       varchar(44) NOT NULL UNIQUE,  -- chave de 44 dígitos (cNF+dígito verificador)
    cnpj_emit       varchar(14) NOT NULL,           -- CNPJ do emitente, somente números
    xml_object_key  text NOT NULL,                  -- path no MinIO: received/YYYY/MM/<chave>.xml
    status          varchar(30) NOT NULL DEFAULT 'received',
    received_at     timestamptz NOT NULL DEFAULT now(),
    parsed_at       timestamptz,
    processed_at    timestamptz,
    last_error      text,                           -- motivo do último erro ou aviso
    n8n_exec_id     text,                           -- ID da execução n8n (para rastreamento)
    retry_count     int NOT NULL DEFAULT 0,
    metadata        jsonb DEFAULT '{}',
    updated_at      timestamptz DEFAULT now(),
    CONSTRAINT nfe_jobs_status_check CHECK (status = ANY (ARRAY[
        'received', 'parsed', 'ready', 'processing', 'waiting_hitl',
        'success', 'failed', 'timeout', 'cancelled', 'aguardando_associacao'
    ]))
);
```

**Fluxo de status:**
```
received → parsed → processing → success
                              ↘ failed          (erro irrecuperável ou retry_count >= 3)
                              ↘ aguardando_associacao  (produto sem mapeamento)
```

**Índices:**
- `idx_nfe_jobs_status ON status` — usado pelo dispatcher para filtrar jobs
- `idx_nfe_jobs_received_at ON received_at DESC` — ordenação no dashboard

### Tabela: `nfe_items` — itens de cada NF-e

```sql
CREATE TABLE app.nfe_items (
    id          bigint PRIMARY KEY,
    job_id      bigint NOT NULL REFERENCES nfe_jobs(id) ON DELETE CASCADE,
    item_seq    int NOT NULL,
    c_prod      varchar(60),    -- código do produto no XML do fornecedor
    c_ean       varchar(20),    -- código EAN/GTIN (pode ser SEM GTIN)
    x_prod      text,           -- descrição do produto no XML
    ncm         varchar(10),    -- NCM (8 dígitos)
    cfop        varchar(5),     -- CFOP (ex: 6102)
    u_com       varchar(10),    -- unidade comercial (ex: UN, KG, CX)
    q_com       numeric(15,4),  -- quantidade comercial
    v_un_com    numeric(15,4),  -- valor unitário
    v_prod      numeric(15,2),  -- valor total do item
    match_status varchar(20) NOT NULL DEFAULT 'pending',
    matched_sku  varchar(60),   -- código no Elevor após match
    match_score  numeric(5,4),
    matched_at   timestamptz,
    UNIQUE (job_id, item_seq),
    CONSTRAINT nfe_items_match_status_check CHECK (match_status = ANY (ARRAY[
        'pending', 'exact', 'semantic', 'manual', 'unmapped', 'skipped'
    ]))
);
```

**Por que separar itens:** Permite rastrear quais produtos de uma NF-e têm mapeamento e quais não têm, facilitando o fluxo de associação parcial.

### Tabela: `nfe_job_logs` — log de execução passo a passo

```sql
CREATE TABLE app.nfe_job_logs (
    id       int PRIMARY KEY,
    job_id   int REFERENCES nfe_jobs(id) ON DELETE CASCADE,
    step     text NOT NULL,    -- identificador do passo (ver valores abaixo)
    status   text NOT NULL,    -- info | success | warning | error
    message  text,
    payload  jsonb,            -- dados adicionais (ex: screenshot em base64 se erro)
    created_at timestamptz DEFAULT now(),
    CONSTRAINT nfe_job_logs_status_check CHECK (status = ANY (ARRAY[
        'info', 'success', 'warning', 'error'
    ]))
);
CREATE INDEX idx_nfe_job_logs_job_id ON nfe_job_logs(job_id);
```

**Valores de `step`:**
- `job_recebido` — dispatcher pegou o job
- `validacao` — job_id válido, dados carregados
- `mapeamento_produto` — resultado do check de mapeamentos
- `browserless_inicio` — chamada ao Browserless iniciada
- `browserless_sucesso` — script retornou sem erro
- `browserless_erro` — script retornou com erro (payload contém screenshot)
- `status_salvo` — status final gravado no nfe_jobs

**IMPORTANTE:** O usuário `elevor_import_user` precisa de `GRANT ALL ON nfe_job_logs TO elevor_import_user`. Sem esse grant, o n8n retorna "Failed query" sem detalhe do erro PostgreSQL.

### Tabela: `produto_mapeamento` — de-para c_prod → codigo_elevor

```sql
CREATE TABLE app.produto_mapeamento (
    id             int PRIMARY KEY,
    c_prod         varchar(60) NOT NULL,   -- código do produto no XML do fornecedor
    cnpj_emit      varchar(14),            -- NULL = vale para qualquer emitente
    codigo_elevor  varchar(60) NOT NULL,   -- código EXATO como cadastrado no Elevor ERP
    nome_elevor    varchar(255),
    origem         varchar(20) NOT NULL DEFAULT 'manual',  -- manual | auto | ia_auto
    ativo          bool NOT NULL DEFAULT true,
    criado_em      timestamptz NOT NULL DEFAULT now(),
    atualizado_em  timestamptz NOT NULL DEFAULT now(),
    UNIQUE (c_prod, cnpj_emit)  -- UNIQUE composto, não só c_prod!
);
COMMENT ON COLUMN produto_mapeamento.cnpj_emit IS 'NULL = vale para qualquer emitente';
COMMENT ON COLUMN produto_mapeamento.codigo_elevor IS 'Código exato como cadastrado no Elevor';
```

**CRÍTICO:** A constraint UNIQUE é `(c_prod, cnpj_emit)`, não apenas `(c_prod)`. O ON CONFLICT deve usar `ON CONFLICT (c_prod, cnpj_emit)` — usar `ON CONFLICT (c_prod)` causará erro.

**Por que cnpj_emit nullable:** Um mesmo código `c_prod` pode ter significados diferentes para fornecedores distintos. O NULL permite criar um mapeamento "coringa" válido para qualquer emitente.

### Tabela: `erp_produtos_cache` — catálogo scrapeado do ERP

```sql
CREATE TABLE app.erp_produtos_cache (
    id              int PRIMARY KEY,
    codigo_erp      varchar(60) NOT NULL UNIQUE,  -- código do produto no Elevor
    nome_erp        text NOT NULL,
    referencia_erp  varchar(60),
    marca_erp       varchar(100),
    ncm_erp         varchar(20),
    cnpj_emit       varchar(20),
    cached_at       timestamptz DEFAULT now()
);
```

**Estado v1.0:** 1.630 produtos populados via scraper Browserless (`browserless-scraper-produtos.js`). Atualizado automaticamente a cada 15 minutos pelo workflow `elevor-sync-produtos`. A sincronização só faz UPSERT se a contagem de produtos no ERP mudou (evita escrita desnecessária).

### Tabela: `ia_sugestoes` — sugestões geradas pela IA

```sql
CREATE TABLE app.ia_sugestoes (
    id           int PRIMARY KEY,
    job_id       bigint,
    c_prod_xml   varchar(60) NOT NULL,  -- código no XML
    x_prod_xml   text,                  -- descrição no XML
    sugestoes    jsonb,   -- array de [{codigo, nome, confianca, razao}]
    status       varchar(20) DEFAULT 'pendente',  -- pendente | aceito | rejeitado
    codigo_aceito varchar(60),
    criado_em    timestamptz DEFAULT now(),
    atualizado_em timestamptz DEFAULT now()
);
```

### Tabela: `sync_log` — histórico de sincronizações do catálogo

```sql
CREATE TABLE app.sync_log (
    id              int PRIMARY KEY,
    sync_type       varchar(50) DEFAULT 'produtos',
    produtos_antes  int,
    produtos_depois int,
    sincronizado    bool DEFAULT false,
    detalhes        text,
    executado_em    timestamptz DEFAULT now()
);
```

### Tabelas auxiliares (não usadas ativamente na v1.0)

- `produto_catalogo` — catálogo com embeddings vetoriais (pgvector), reservado para busca semântica futura
- `hitl_requests` — Human-in-the-Loop requests, infraestrutura para aprovação manual interativa
- `error_log` — log de erros de workflow (alternativo ao nfe_job_logs)

---

## 5. Workflows n8n — Referência Completa

Base URL dos webhooks: `https://n8n.n0ns.tech/webhook/`

### 5.1 elevor-ingest-trg (`MYMEYkCD9fgP8vNw`)

**Trigger:** `POST /elevor/xml/upload`
**Content-Type:** `application/xml` (body raw)
**Função:** Receber XML de NF-e, persistir no MinIO, criar job

**Fluxo interno:**
1. Webhook recebe XML em `$json.body`
2. Code Node: parseia XML string para extrair `chNFe` (44 chars) e `CNPJ` do emitente
3. HTTP Request (MinIO S3 API): PUT do XML em `received/YYYY/MM/<chaveNfe>.xml`
4. Postgres Insert: insere em `nfe_jobs` com `status='received'`
5. Respond to Webhook: `{ok: true, job_id, chave_nfe}`

**Como testar:**
```bash
curl -X POST https://n8n.n0ns.tech/webhook/elevor/xml/upload \
  -H "Content-Type: application/xml" \
  --data-binary @nota.xml
```

### 5.2 elevor-parser-proc (`uBpKdZ6NsiewicIR`)

**Trigger:** Schedule, a cada 30 segundos
**Função:** Parsear XMLs dos jobs `received`, criar itens

**Fluxo interno:**
1. Postgres: `SELECT * FROM nfe_jobs WHERE status = 'received'`
2. Para cada job (SplitInBatches):
   a. Baixa XML do MinIO usando `xml_object_key`
   b. Code Node: parseia XML, extrai todos os `<det>` (itens)
   c. Postgres: INSERT em `nfe_items` para cada item
   d. Postgres: UPDATE `nfe_jobs SET status='parsed', parsed_at=NOW()`

### 5.3 elevor-escriturar-dispatcher (`gzW0D89LvPy4y8u4`)

**Trigger:** Schedule, a cada 3 minutos
**Função:** Mutex serial — garante que somente 1 job é processado por vez

**Por que existe:** Sem o dispatcher, o botão "Escriturar todos" (removido na v1.1) disparava N webhooks simultâneos → N sessões Puppeteer abertas ao mesmo tempo → ERP travava sob carga concorrente. O dispatcher serializa via banco de dados.

**Fluxo interno:**
```sql
-- Passo 1: Verificar mutex
SELECT 1 FROM app.nfe_jobs WHERE status = 'processing';
-- Se retornar linha: abortar (job em andamento)

-- Passo 2: Reivindicar 1 job (atomic UPDATE ... RETURNING)
UPDATE app.nfe_jobs
SET status = 'processing', updated_at = NOW()
WHERE id = (
  SELECT id FROM app.nfe_jobs
  WHERE status = 'parsed'
     OR (status = 'failed' AND last_error ILIKE '%timeout%' AND retry_count < 3)
  ORDER BY
    CASE WHEN status = 'parsed' THEN 0 ELSE 1 END,
    received_at ASC
  LIMIT 1
)
RETURNING *;
```
3. Se nenhum job elegível: abortar
4. HTTP POST para `escriturar-job` com `{job_id: N}`

### 5.4 elevor-escriturar-job (`PXVlrv85uFwzr3VI`)

**Trigger:** `POST /elevor/escriturar-job` body `{"job_id": N}`
**Função:** Orquestrar a escrituração de 1 NF-e no ERP

**Fluxo interno detalhado:**

```
Webhook → Validar Job (SELECT nfe_jobs WHERE id=$job_id) 
       → Verificar Mapeamentos (SQL abaixo)
       → IF c_prod_faltando > 0: UPDATE status=aguardando_associacao → FIM
       → Preparar Body Job (monta context com mapeamentos)
       → Chamar Browserless (POST /function, timeout 180s)
       → IF hasError: UPDATE status=failed, salvar screenshot
       → IF success: UPDATE status=success, processed_at=NOW()
       → 7 nós de log em paralelo (fan-out, não bloqueia fluxo)
```

**SQL "Verificar Mapeamentos":**
```sql
WITH itens AS (
  SELECT DISTINCT c_prod FROM app.nfe_items WHERE job_id = $job_id
),
mapeados AS (
  SELECT i.c_prod, pm.codigo_elevor
  FROM itens i
  LEFT JOIN app.produto_mapeamento pm
    ON pm.c_prod = i.c_prod AND pm.ativo = true
    AND (pm.cnpj_emit IS NULL OR pm.cnpj_emit = (
      SELECT cnpj_emit FROM app.nfe_jobs WHERE id = $job_id
    ))
)
SELECT
  COUNT(*) FILTER (WHERE codigo_elevor IS NULL) AS c_prod_faltando,
  json_agg(json_build_object(
    'c_prod', c_prod, 'codigo_elevor', codigo_elevor
  )) FILTER (WHERE codigo_elevor IS NOT NULL) AS mapeamentos
FROM mapeados;
```

**IMPORTANTE — comportamento do executeQuery UPDATE sem RETURNING:**
Após um nó Postgres com `operation: executeQuery` e UPDATE sem RETURNING, o resultado é `{success: true}`. O nó NÃO faz passthrough dos dados de entrada. Para referenciar dados do job após um UPDATE, usar `$('Validar Job').first().json` (nome do nó anterior), NUNCA `$input.first().json`.

**Nós de log (7 inserções paralelas):**
```sql
INSERT INTO app.nfe_job_logs (job_id, step, status, message, payload)
VALUES ($job_id, 'browserless_sucesso', 'success', 'NF-e escriturada com sucesso', '{}');
```

### 5.5 elevor-recovery (`UAGJeL1KiCdN1ojI`)

**Trigger:** Schedule, a cada 10 minutos
**Função:** Desbloquear jobs presos em `processing`

```sql
UPDATE app.nfe_jobs
SET status = 'parsed', last_error = 'Timeout: processamento > 10min, resetado pelo recovery'
WHERE status = 'processing'
  AND updated_at < NOW() - INTERVAL '10 minutes'
RETURNING id, chave_nfe;
```

**Por que é necessário:** Quando o Browserless lança uma exception não capturada (ex: tab não carrega por nota duplicada no ERP), o workflow n8n morre sem salvar o status. O job fica preso em `processing` indefinidamente. O recovery resolve isso automaticamente.

### 5.6 elevor-ia-match (`FyprplJ8gG6Up5iI`)

**Trigger:** `POST /elevor/ia-match`
**Body:** `{"job_id": N, "c_prod": "PR310E110V", "x_prod": "Processador 3L 110V"}`
**Função:** Usar Claude (Anthropic) para sugerir o produto correto no catálogo ERP

**Fluxo interno:**
1. Recebe `c_prod` e `x_prod` do XML
2. Extrai voltagem do `c_prod` e `x_prod` com regex: `/(110|127|220|bivolt)/i`
3. Busca candidatos no catálogo: `SELECT * FROM erp_produtos_cache WHERE nome_erp ILIKE '%termo%' LIMIT 30`
4. Monta prompt com:
   - Produto buscado (c_prod + x_prod + voltagem detectada)
   - Lista de candidatos do catálogo
   - Instrução: voltagem é DECISIVA, nunca sugerir produto de voltagem errada
   - Função `norm()` para normalizar acentos antes de comparar
5. HTTP POST para API Anthropic (claude-3-5-sonnet)
   - Header `x-api-key: <chave>` manual (não usar `predefinedCredentialType`)
   - `specifyBody: "json"`, `sendBody: true` (obrigatórios!)
6. Parseia resposta JSON: `[{codigo, nome, confianca, razao}]`
7. Insere em `ia_sugestoes` com `ON CONFLICT (c_prod, cnpj_emit) DO UPDATE`
8. Se `confianca >= 0.85`: insere automaticamente em `produto_mapeamento` com `origem='ia_auto'`

**Bug crítico resolvido — specifyBody:**
`specifyBody="string"` trata o body como URL-encoded (URLSearchParams). Para enviar JSON real, usar `specifyBody="json"` com campo `jsonBody` como expressão objeto JS. Sem `sendBody: true`, o corpo nunca é enviado.

### 5.7 elevor-ia-sugestoes (`ETjj8lK9F131CWvt`)

**Trigger:** `GET /elevor/ia-sugestoes?job_id=N&c_prod=PR310E110V`
**Função:** Retornar sugestões da IA para exibição no dashboard

```sql
SELECT * FROM app.ia_sugestoes
WHERE job_id = $job_id AND c_prod_xml = $c_prod
ORDER BY criado_em DESC LIMIT 1;
```

### 5.8 elevor-associar (`caFJPAPrNEQnLD8F`)

**Trigger:** `POST /elevor/associar`
**Body:** `{"c_prod": "PR310E110V", "cnpj_emit": "27042473000612", "codigo_elevor": "12014", "nome_elevor": "Processador 3L 110V"}`
**Função:** Salvar mapeamento manual no banco

```sql
INSERT INTO app.produto_mapeamento
  (c_prod, cnpj_emit, codigo_elevor, nome_elevor, origem)
VALUES ($c_prod, $cnpj_emit, $codigo_elevor, $nome_elevor, 'manual')
ON CONFLICT (c_prod, cnpj_emit) DO UPDATE
SET codigo_elevor = EXCLUDED.codigo_elevor,
    nome_elevor = EXCLUDED.nome_elevor,
    ativo = true,
    atualizado_em = NOW();
```

### 5.9 elevor-auto-associar (`dpjsYBFYG03mQq33`)

**Trigger:** `POST /elevor/auto-associar`
**Body:** `{"job_id": N}`
**Função:** Tentar associar automaticamente todos os itens sem mapeamento via `produto_mapeamento` existente

**Retorno:** `{ok: true, associados: 2, nao_encontrados: ["PR310E110V"], escalado: false}`

**Lógica:** Para cada `c_prod` sem mapeamento, tenta match exato em `produto_mapeamento`. Se todos forem encontrados, atualiza job para `parsed` (pronto para reprocessamento).

### 5.10 elevor-sync-produtos (`FJFtTnnNmiTRZC8S`)

**Trigger:** Schedule, a cada 15 minutos
**Função:** Sincronizar catálogo de produtos do ERP com `erp_produtos_cache`

**Lógica inteligente (evita scraping desnecessário):**
1. Conta produtos atuais: `SELECT COUNT(*) FROM erp_produtos_cache`
2. Executa scraper Browserless em `CL_Produtos.aspx` (lista "Todos") — obtém count de produtos no ERP
3. Se count mudou: faz UPSERT completo em `erp_produtos_cache`
4. Registra em `sync_log`

**Script:** `/opt/elevor-automation/browserless-scraper-produtos.js`

### 5.11 elevor-buscar-produtos (`07P6w4y4BUbfRDqm`)

**Trigger:** `GET /elevor/buscar-produtos?q=liquidificador`
**Função:** Busca ILIKE no catálogo cacheado, retorna top 20

```sql
SELECT codigo_erp, nome_erp, referencia_erp, marca_erp, ncm_erp
FROM app.erp_produtos_cache
WHERE nome_erp ILIKE '%' || $q || '%'
   OR codigo_erp ILIKE '%' || $q || '%'
ORDER BY nome_erp
LIMIT 20;
```

Usado pelo campo de busca no modal de associação do dashboard (debounce 350ms).

### 5.12 elevor-status (`EHyCmpD1CmBg6H23`)

**Trigger:** `GET /elevor/status`
**Função:** Retornar contagens e listas de jobs por status para o dashboard

```sql
SELECT
  status,
  COUNT(*) as total,
  json_agg(json_build_object(
    'id', id, 'chave_nfe', chave_nfe, 'cnpj_emit', cnpj_emit,
    'received_at', received_at, 'last_error', last_error, 'retry_count', retry_count
  ) ORDER BY received_at DESC) as jobs
FROM app.nfe_jobs
GROUP BY status;
```

### 5.13 elevor-job-logs (`42c7pESSzW6m5WEy`)

**Trigger:** `GET /elevor/job-logs?job_id=N`
**Função:** Retornar logs de execução de um job (para painel de log ao vivo no dashboard)

```sql
SELECT step, status, message, payload, created_at
FROM app.nfe_job_logs
WHERE job_id = $job_id
ORDER BY created_at ASC;
```

O dashboard faz polling a cada 3 segundos neste endpoint enquanto um job está em `processing`.

### 5.14 elevor-delete-job (`e0tdc9ifGlBENYZZ`)

**Trigger:** `POST /elevor/delete-job`
**Body:** `{"job_id": N}`
**Função:** Deletar job e todos os seus itens/logs (CASCADE)

```sql
DELETE FROM app.nfe_jobs WHERE id = $job_id;
-- nfe_items e nfe_job_logs são deletados via ON DELETE CASCADE
```

---

## 6. Script Browserless v14 — Pontos Críticos

**Arquivo:** `/opt/elevor-automation/browserless-escriturar-v14.js`
**Arquivo congelado:** `/opt/elevor-automation/versoes/v1.0-importacao/scripts/browserless-escriturar-v14.js`

O script é enviado como string para `POST http://browserless-elevor:3000/function`. O Browserless executa a função exportada com `({page, context})`.

### Context recebido

```javascript
const {
  chaveNfe,      // "35260527042473000612550020000178321606170377"
  vencimento,    // "06/06/2026" (DD/MM/YYYY) = entrada + 7 dias
  jobId,         // 1
  natureza,      // "46" (natureza de operação)
  mapeamentos    // [{c_prod: "LAR110", codigo_elevor: "LAR110"}, ...]
} = context;
```

### Sequência de automação

```
1. Navegar para ERP: https://elevor01.ddns.net/HOMOLOGACAO/ERP360
2. Login: usuário "teste.elevor", senha "TesteIA@123"
3. Selecionar empresa: select[name="...drlEmpresa"] → value "3"
4. Navegar para CD_EntradaFornecedor.aspx
5. Digitar chave NF-e em txtChaveAcesso
6. Clicar btnBuscarNFe (PRIMEIRO botão)
7. Tratar modal ICMS Desonerado (se aparecer): clicar ModalMensagem_btnSim
8. Verificar se há produtos não cadastrados em dgdProdutosAssociar
9. Para cada produto não cadastrado: executar fluxo ModalProdutosAssociacao
10. Se houve associações: clicar btnBuscarNFe de NOVO (obrigatório)
11. Preencher campos da nota:
    a. txtSerie (deve estar na aba Nota)
    b. Natureza: digitar código + Tab (dispara UpdatePanel postback)
    c. Aguardar 2000ms após Tab
    d. LIMPAR txtNFeID (ANTES de navegar para aba FP)
12. Navegar para aba Forma Pagamento: clicar btnFormaPagamento
13. Preencher parcela: tipo=5 (A Prazo), parcelas=0 (Vencimento), data=vencimento
14. Clicar WUCFormaPagamento_btnAdicionar
15. Clicar btnGravar
16. Verificar sucesso: URL ainda em CD_EntradaFornecedor.aspx, form vazio
```

### Fluxo ModalProdutosAssociacao (passo 9)

Este é o passo mais complexo e onde ocorreram os bugs mais críticos.

```javascript
// CORRETO: usar __doPostBack diretamente (NÃO usar element.click())
// O href é "javascript:__doPostBack(...)" — anchors com javascript: href
// não disparam via el.click() no contexto Puppeteer
await page.evaluate((target) => {
  __doPostBack(target, '');
}, 'ctl00$ctl00$ContentPlaceHolder1$AppContentPlaceHolder$dgdProdutosAssociar$ctl03$LinkButton1');
// Para linha N: substituir ctl03 por ctl0${N+3}

// Aguardar modal abrir
await page.waitForSelector('#ModalProdutosAssociacao_pnlProdutosAssociacao', {visible: true});

// Digitar codigo_elevor (NÃO o c_prod do XML!)
const mapaElevor = {};
mapeamentos.forEach(m => { mapaElevor[m.c_prod] = m.codigo_elevor; });
const codigoParaDigitar = mapaElevor[cProdAtual]; // ex: "12014" não "12409110"

const inputSelector = '#ctl00_ctl00_ContentPlaceHolder1_AppContentPlaceHolder_ModalProdutosAssociacao_drlCodProdutos_Input';
await page.click(inputSelector);
await page.type(inputSelector, codigoParaDigitar, {delay: 80});

// Aguardar AJAX do RadComboBox (Telerik) — MÍNIMO 3 segundos
await page.waitForTimeout(3000);

// Clicar no primeiro item da lista
const itemSelector = 'li.rcbItem, .RadComboBoxDropDown li, [class*="rcbItem"]';
await page.waitForSelector(itemSelector, {visible: true, timeout: 5000});
await page.click(itemSelector);

// Confirmar associação
await page.click('#ModalProdutosAssociacao_btnGravar');
await page.waitForTimeout(1500); // aguardar modal fechar
```

**ATENÇÃO:** Após associar TODOS os produtos não cadastrados, é OBRIGATÓRIO clicar em `btnBuscarNFe` novamente. Sem esse segundo clique, o ERP não carrega os dados da nota para o formulário.

### Natureza de operação (UpdatePanel)

```javascript
// Tab dispara o postback do UpdatePanel que preenche a descrição da natureza
await page.click('#txtCodNaturezaOperacao');
await page.type('#txtCodNaturezaOperacao', context.natureza || '46');
await page.keyboard.press('Tab');
await page.waitForTimeout(2000); // aguardar postback
```

### Limpeza de txtNFeID (CRÍTICO)

```javascript
// ANTES de navegar para aba Forma Pagamento:
await page.evaluate(() => {
  const el = document.querySelector('#txtNFeID');
  if (el) el.value = '';
});
// SOMENTE DEPOIS navegar para FP
await page.click('#btnFormaPagamento');
```

Se `txtNFeID` não for limpo ANTES da navegação para a aba FP, o server recebe o ID junto com o postback de navegação e lança: `"Para o Modelo 55 - NF-e não pode ser informado o ID NF-e"`.

### Detecção de sucesso

```javascript
// Após btnGravar:
const url = page.url();
const isSuccess = url.includes('CD_EntradaFornecedor.aspx') && !url.includes('Erro');
// Form vazio = Gravar foi bem-sucedido (ASP.NET limpa o form após gravar)
```

### Screenshot condicional

```javascript
// Apenas em caso de erro (economiza bandwidth e tempo)
const screenshot = result.hasError
  ? await page.screenshot({encoding: 'base64', fullPage: false})
  : null;
return {success: !result.hasError, error: result.error, screenshot};
```

---

## 7. ERP360 ASP.NET WebForms — Regras de Automação

### Regra 1 — EventValidation: nunca injetar inputs novos

**NUNCA** usar `document.createElement('input')` e appendar ao form com nome de controle (`ctl00$...$txtSerie`). O ASP.NET com `EnableEventValidation="true"` valida que todos os controles POSTados foram registrados no render. Inputs criados dinamicamente falham essa validação com erro `"Argumento de postback inválido" (Erro.aspx?Cod=225)`.

**Correto:** modificar o `.value` de elementos já existentes no DOM.

### Regra 2 — UpdatePanel: cada aba substitui o DOM

`txtSerie` só existe no DOM quando a aba "Nota" está ativa. `WUCFormaPagamento_drlTipo` só existe na aba "Forma Pagamento". Tentar `page.click('#txtSerie')` da aba FP falha com "no element found".

### Regra 3 — btnNota reseta o formulário INTEIRO

`#ContentPlaceHolder1_AppContentPlaceHolder_btnNota` não é navegação para aba — inicializa uma nova nota em branco. NUNCA usar para trocar de aba.

### Regra 4 — Modal ICMS Desonerado

Após o primeiro `btnBuscarNFe` para NF-e Modelo 55 com ICMS desonerado, um modal de confirmação aparece. Clicar `#ModalMensagem_btnSim` com `.catch(() => {})` para tratamento gracioso:

```javascript
await page.click('#ModalMensagem_btnSim').catch(() => {});
```

### Regra 5 — Campos de Forma Pagamento A Prazo

```
#WUCFormaPagamento_drlTipo → value "5" (A Prazo)
#WUCFormaPagamento_drlParcelasAPrazo → value "0" (Vencimento único)
#WUCFormaPagamento_txtVencimentoAPrazo → "DD/MM/YYYY"
#WUCFormaPagamento_btnAdicionar → adiciona a parcela
```

### Regra 6 — Fórmula de vencimento

O vencimento é calculado pelo workflow `escriturar-job` ANTES de chamar o Browserless:

```sql
SELECT TO_CHAR(NOW() + INTERVAL '7 days', 'DD/MM/YYYY') AS vencimento;
```

### Regra 7 — Credenciais do robô

- **Login:** `teste.elevor`
- **Senha:** `TesteIA@123`
- **Empresa (MELI):** select value `3` = "TEMPERARE SP - MELI"
- **Funcionário padrão:** select value `68`
- **Natureza de operação:** `46`

---

## 8. Dashboard Web

**URL:** `https://elevor.n0ns.tech`
**Arquivo:** `/opt/elevor-automation/dashboard/index.html` (montado no nginx)
**Container:** `elevor-dashboard` (nginx), volume mount: `/opt/elevor-automation/dashboard → /usr/share/nginx/html`

### Funcionalidades

| Funcionalidade | Implementação |
|---|---|
| Upload de XMLs | Drag-and-drop + input file, POST para `/elevor/xml/upload` |
| Painel de status | GET `/elevor/status` a cada 8s, atualização automática |
| Botão "Escriturar" (individual) | POST `/elevor/escriturar-job` com `{job_id}` |
| Log ao vivo | GET `/elevor/job-logs?job_id=N` polling 3s, colapsável |
| Modal de associação | Busca ILIKE via `/elevor/buscar-produtos?q=` (debounce 350ms) |
| Deletar job | POST `/elevor/delete-job` |
| Sugestões de IA | GET `/elevor/ia-sugestoes?job_id=N&c_prod=X` |

**IMPORTANTE:** O botão "Escriturar todos" foi **removido** na versão correta do dashboard. Ele apenas informava ao operador; o processamento real é feito pelo dispatcher. A remoção elimina confusão e tentativas de disparo em lote que causavam concorrência.

### Painel de log ao vivo

Cada card de job em processamento tem um painel de log abaixo que:
- Faz polling GET `/elevor/job-logs?job_id=N` a cada 3 segundos
- Exibe step, status (color-coded), message e timestamp
- É colapsável (não bloqueia a interface)
- Fecha automaticamente quando o job sai de `processing`
- O painel "Escriturando agora" abre com log ao vivo por padrão

---

## 9. Credenciais e Configurações

### ERP

| Campo | Valor |
|---|---|
| URL | `https://elevor01.ddns.net/HOMOLOGACAO/ERP360` |
| Usuário robô | `teste.elevor` |
| Senha | `TesteIA@123` |
| Empresa MELI (select value) | `3` |
| Descrição empresa | TEMPERARE SP - MELI |
| Funcionário padrão (select value) | `68` |
| Natureza de operação | `46` |

### n8n Credential IDs

| Serviço | ID da credencial |
|---|---|
| PostgreSQL | `ny8L4S8VHJy0ojgK` |
| MinIO (S3) | `FOYYHs3SNl8RfqY6` |
| Redis | `Ulzq0Hu0q8Zjx1RG` |
| Anthropic (Claude) | `s3VRLeOygByWgFUQ` |

### Anthropic API

A credencial Anthropic no n8n usa `predefinedCredentialType: anthropicApi`, MAS isso não funciona corretamente com `specifyBody="json"` — o body fica vazio. A solução funcional é usar header `x-api-key` manual + `specifyBody="json"` + `sendBody: true`.

### MinIO

- Bucket: `elevor-xml-archive`
- Usuário: `elevor_admin`
- Senha: ver `/opt/elevor-automation/.env` ou `docker inspect minio-elevor`
- Endpoint interno (containers): `http://minio-elevor:9000`
- Endpoint externo (host): `http://127.0.0.1:9100`

---

## 10. Bugs Críticos Resolvidos

### Bug 1 — Concorrência: timeout por múltiplas sessões simultâneas

**Sintoma:** Ao usar "Escriturar todos", vários jobs ficavam presos em `processing` e o ERP ficava lento/travado.

**Causa raiz:** O workflow `elevor-escriturar-lote` (agora INATIVO, ID `tiaam7MsUfJDRD15`) disparava N chamadas simultâneas ao script Browserless. Cada chamada abria uma sessão Chrome independente. O ERP360 (ASP.NET com state de sessão) não suporta múltiplas sessões simultâneas do mesmo usuário.

**Solução implementada:**
- Workflow `escriturar-lote` desativado permanentemente
- Criado `elevor-escriturar-dispatcher` com mutex via banco: `NOT EXISTS (SELECT 1 FROM nfe_jobs WHERE status='processing')`
- Processamento serial: 1 job por vez, intervalo de 3 minutos
- `elevor-recovery` desobstrui jobs presos após 10 minutos

**Lição:** Em integrações com sistemas legados que não suportam concorrência, o controle de serialização deve ser feito via banco de dados (mutex idempotente), não via semáforos de memória.

### Bug 2 — Dropdown RadComboBox sempre vazio (maior bug)

**Sintoma:** Todos os jobs com produtos requerendo associação ficavam presos em `aguardando_associacao`, mesmo após associar manualmente. O modal de associação abria mas o dropdown de produtos no ERP nunca mostrava resultados.

**Causa raiz (script v13):** O script digitava `prod.c_prod` (o código do produto no XML do fornecedor, ex: `"12409110"`) no campo RadComboBox do ERP. O ERP busca por código interno Elevor, não por código do fornecedor. O código `"12409110"` não existe no catálogo Elevor — o código correto era `"12410"`. Por isso o dropdown retornava 0 resultados.

**Solução implementada (script v14):**
1. SQL "Verificar Mapeamentos" expandido para retornar também o array `mapeamentos` em JSON (além de `c_prod_faltando`)
2. Workflow "Preparar Body Job" passa `context.mapeamentos = [{c_prod, codigo_elevor}, ...]`
3. Script v14 constrói `mapaElevor = {c_prod → codigo_elevor}` a partir do context
4. No loop de associação, usa `mapaElevor[cProdAtual]` para digitar o `codigo_elevor` correto no dropdown
5. Aguarda 3s (era 2s) para AJAX do RadComboBox responder completamente

**Lição:** O campo de busca de produtos no modal de associação do ERP busca por **código interno do ERP**, não pelo código do fornecedor. O mapeamento existe exatamente para fazer essa tradução.

### Bug 3 — IA sem catálogo de referência

**Sintoma:** O workflow `elevor-ia-match` retornava sugestões genéricas e imprecisas.

**Causa raiz:** Claude era chamado apenas com o `c_prod` e `x_prod` do XML, sem contexto de quais produtos existem no ERP. Sem catálogo, o modelo inventava códigos.

**Solução implementada:**
1. Workflow `elevor-sync-produtos` criado para manter `erp_produtos_cache` atualizado (1.630 produtos)
2. Workflow `elevor-buscar-produtos` criado para busca ILIKE
3. `elevor-ia-match` agora busca candidatos relevantes em `erp_produtos_cache` e os inclui no prompt
4. Prompt inclui função `norm()` para normalização de acentos
5. Detecta voltagem em `c_prod` e `x_prod` com regex `/(110|127|220|bivolt)/i`
6. Alerta explícito no prompt: voltagem é DECISIVA, nunca sugerir produto de voltagem diferente

### Bug 4 — splitInBatches quebra com respondToWebhook

**Sintoma:** Workflow de lote (`escriturar-lote`) executava em ~91ms sem processar nenhum job.

**Causa raiz:** Comportamento conhecido do n8n onde `splitInBatches` vai imediatamente para a saída "done" (main[1]) quando a execução usa `respondToWebhook` antes do loop.

**Solução:** Uso do dispatcher serial com HTTP Request para o webhook do `escriturar-job`, processando 1 job por vez.

### Bug 5 — executeQuery UPDATE sem RETURNING não faz passthrough

**Sintoma:** `$input.first().json` após um nó Postgres UPDATE retornava `{success: true}` em vez dos dados do job.

**Causa raiz:** `operation: executeQuery` com UPDATE sem RETURNING retorna apenas confirmação, não os dados originais.

**Solução:** Após qualquer UPDATE, referenciar o nó ANTERIOR (ex: `$('Validar Job').first().json`), nunca `$input.first().json`.

### Bug 6 — fetch() e $helpers bloqueados no task-runner

**Sintoma:** Código em Code Nodes que usava `fetch()` ou `$helpers.httpRequest()` falhava silenciosamente.

**Causa raiz:** O n8n roda com `@n8n/task-runner` em sandbox isolado. `fetch`, `$helpers` e `require('https')` são bloqueados.

**Solução:** Usar nó **HTTP Request nativo** (typeVersion 4, `specifyBody: "json"`) para qualquer chamada HTTP dentro de workflows.

---

## 11. Restrições do n8n Task Runner

O n8n desta instalação usa sandbox `@n8n/task-runner`. As seguintes APIs são **BLOQUEADAS** em Code Nodes:

| API | Status | Alternativa |
|---|---|---|
| `fetch()` | BLOQUEADO | Nó HTTP Request nativo |
| `$helpers.httpRequest()` | BLOQUEADO | Nó HTTP Request nativo |
| `require('https')` | BLOQUEADO | Nó HTTP Request nativo |
| `require('fs')` | BLOQUEADO | — |
| `require('path')` | BLOQUEADO | — |

**Configuração correta do nó HTTP Request:**
```
Resource: HTTP Request
Type Version: 4
Method: POST
URL: https://api.anthropic.com/v1/messages
Send Body: true (OBRIGATÓRIO)
Body Content Type: JSON
Specify Body: Using JSON (não "string"!)
JSON Body: {{ { model: "claude-3-5-sonnet-20241022", ... } }}
```

**Referenciando nós anteriores após executeQuery UPDATE:**
```javascript
// ERRADO:
const job = $input.first().json;

// CORRETO:
const job = $('Validar Job').first().json;
```

---

## 12. Restrições do Browserless Sandbox

O script executado pelo Browserless tem acesso ao Node.js no contexto outer function, mas o contexto de página tem restrições adicionais:

| API | Disponibilidade | Observação |
|---|---|---|
| `Buffer` | BLOQUEADO no context browser | — |
| `import('node:fs/promises')` | BLOQUEADO | — |
| `page.setInputFiles()` | NÃO EXISTE | método não implementado |
| `fetch()` no context browser | Não alcança IPs Docker internos | usar Node.js fetch no outer context |
| `TextEncoder` | DISPONÍVEL | — |
| Node.js `fetch` | DISPONÍVEL no outer function | para chamadas HTTP externas |
| `page.evaluate()` | DISPONÍVEL | para manipular DOM |
| `page.click()`, `page.type()` | DISPONÍVEL | automação normal |

**Sobre `element.click()` via `page.evaluate()`:**
Anchors com `href="javascript:__doPostBack(...)"` NÃO disparam via `el.click()` em `page.evaluate()`. O Puppeteer não segue `javascript:` hrefs em anchors via click de JavaScript. Solução: chamar `__doPostBack()` diretamente via `page.evaluate()`.

---

## 13. Procedimento de Teste E2E

### Pré-requisitos

- Arquivo XML de NF-e válido (chave 44 dígitos, CNPJ emitente: `27042473000612`)
- `produto_mapeamento` populado com os c_prods dos produtos da nota
- `erp_produtos_cache` populado (executar sync-produtos manualmente se necessário)

### Passo 1 — Limpar ambiente de teste

```sql
-- ATENÇÃO: preserva produto_mapeamento e erp_produtos_cache
TRUNCATE app.nfe_items, app.nfe_job_logs, app.ia_sugestoes RESTART IDENTITY;
DELETE FROM app.nfe_jobs;
-- Resetar sequence
ALTER SEQUENCE app.nfe_jobs_id_seq RESTART WITH 1;
ALTER SEQUENCE app.nfe_items_id_seq RESTART WITH 1;
```

```bash
# Limpar MinIO
docker exec minio-elevor sh -c \
  "mc alias set local http://127.0.0.1:9000 elevor_admin 'SENHA' && \
   mc rm --recursive --force --versions local/elevor-xml-archive"
```

### Passo 2 — Verificar produto_mapeamento

```sql
SELECT c_prod, cnpj_emit, codigo_elevor, nome_elevor FROM app.produto_mapeamento WHERE ativo = true;
```

Os c_prods do XML que será testado devem ter entradas aqui.

### Passo 3 — Upload do XML

Via dashboard: arrastar arquivo XML para a drop zone em `https://elevor.n0ns.tech`

Ou via curl:
```bash
curl -X POST https://n8n.n0ns.tech/webhook/elevor/xml/upload \
  -H "Content-Type: application/xml" \
  --data-binary @/caminho/para/nota.xml
```

Verificar resposta: `{"ok":true,"job_id":1,"chave_nfe":"35260..."}`

### Passo 4 — Aguardar parser

O parser roda a cada 30s. Verificar:
```sql
SELECT id, status, chave_nfe, parsed_at FROM app.nfe_jobs;
SELECT job_id, item_seq, c_prod, x_prod FROM app.nfe_items ORDER BY job_id, item_seq;
```

Status esperado: `parsed`

### Passo 5 — Escriturar

**Opção A (automática):** Aguardar dispatcher (até 3min)

**Opção B (forçada):**
```bash
curl -X POST https://n8n.n0ns.tech/webhook/elevor/escriturar-job \
  -H "Content-Type: application/json" \
  -d '{"job_id": 1}'
```

### Passo 6 — Monitorar

Dashboard exibe o painel "Escriturando agora" com log ao vivo.

Via banco:
```sql
SELECT step, status, message, created_at
FROM app.nfe_job_logs
WHERE job_id = 1
ORDER BY created_at;
```

### Passo 7 — Tratar aguardando_associacao

Se o job ficar em `aguardando_associacao`:
1. No dashboard: clicar "Associar manualmente" no card do job
2. Buscar produto no campo de busca (ILIKE)
3. Selecionar produto correto da lista
4. Clicar Salvar
5. Dispatcher reprocessará automaticamente no próximo ciclo

### Passo 8 — Verificar sucesso

```sql
SELECT id, status, processed_at, last_error
FROM app.nfe_jobs WHERE id = 1;
-- Esperado: status='success', processed_at IS NOT NULL, last_error IS NULL
```

No ERP: verificar se a nota foi criada em `CD_EntradaFornecedor.aspx`.

---

## 14. Operação e Monitoramento

### Comandos de diagnóstico rápido

```bash
# Verificar containers rodando
docker ps | grep -E 'browserless|minio|redis|n8n|elevor'

# Ver logs do container browserless
docker logs browserless-elevor --tail 50

# Conectar ao PostgreSQL
docker exec -it <container-postgres> psql -U postgres -d elevor_import

# Verificar MinIO (dentro do container)
docker exec minio-elevor mc ls local/elevor-xml-archive/received/
```

### Status dos workflows n8n

Acessar `https://n8n.n0ns.tech` → Workflows. Os seguintes devem estar ACTIVE:

| Workflow | Esperado |
|---|---|
| elevor-escriturar-dispatcher | ACTIVE |
| elevor-parser-proc | ACTIVE |
| elevor-recovery | ACTIVE |
| elevor-sync-produtos | ACTIVE |
| Todos os webhooks | ACTIVE |

**INATIVO (propositalmente):**
- `elevor-escriturar-lote` (ID `tiaam7MsUfJDRD15`) — substituído pelo dispatcher

### Alertas operacionais

| Sintoma | Causa provável | Ação |
|---|---|---|
| Jobs presos em `processing` > 10min | Exception Browserless não capturada | Recovery resolve em até 10min |
| Dropdown RadComboBox vazio | `codigo_elevor` incorreto no mapeamento | Verificar `produto_mapeamento` |
| `aguardando_associacao` em loop | c_prod sem mapeamento | Associar manualmente no dashboard |
| Dispatcher não processa | Job em processing (mutex ativo) | Aguardar recovery ou reset manual |
| Erro "Argumento de postback inválido" | Injeção de inputs no DOM | Verificar script Browserless (regra 1) |

### Reset manual de job preso

```sql
-- Desbloquear job específico preso em processing
UPDATE app.nfe_jobs
SET status = 'parsed',
    last_error = 'Reset manual: job travado em processing'
WHERE id = <JOB_ID> AND status = 'processing';
```

---

## 15. Queries Úteis de Diagnóstico

### Visão geral por status
```sql
SELECT status, COUNT(*) as total
FROM app.nfe_jobs
GROUP BY status ORDER BY total DESC;
```

### Jobs com erros recentes
```sql
SELECT id, chave_nfe, status, last_error, retry_count, updated_at
FROM app.nfe_jobs
WHERE status IN ('failed', 'aguardando_associacao')
ORDER BY updated_at DESC;
```

### Produtos sem mapeamento
```sql
SELECT DISTINCT ni.c_prod, ni.x_prod, nj.cnpj_emit
FROM app.nfe_items ni
JOIN app.nfe_jobs nj ON nj.id = ni.job_id
WHERE nj.status = 'aguardando_associacao'
AND NOT EXISTS (
  SELECT 1 FROM app.produto_mapeamento pm
  WHERE pm.c_prod = ni.c_prod AND pm.ativo = true
  AND (pm.cnpj_emit IS NULL OR pm.cnpj_emit = nj.cnpj_emit)
);
```

### Log de execução de um job
```sql
SELECT step, status, message, payload, created_at
FROM app.nfe_job_logs
WHERE job_id = <JOB_ID>
ORDER BY created_at;
```

### Histórico de sincronização do catálogo
```sql
SELECT sync_type, produtos_antes, produtos_depois, sincronizado, detalhes, executado_em
FROM app.sync_log
ORDER BY executado_em DESC LIMIT 10;
```

### Relatório de escrituração
```sql
SELECT
  COUNT(*) FILTER (WHERE status = 'success') as sucesso,
  COUNT(*) FILTER (WHERE status = 'failed') as falha,
  COUNT(*) FILTER (WHERE status = 'aguardando_associacao') as aguardando,
  COUNT(*) FILTER (WHERE status IN ('received', 'parsed')) as fila,
  COUNT(*) TOTAL
FROM app.nfe_jobs;
```

---

## 16. Decisões de Arquitetura — Justificativas

### Por que n8n e não código direto?

O n8n fornece: orquestração visual de fluxos, gestão de credenciais com criptografia, retry automático de execuções, histórico de execuções auditável, e webhooks prontos. Implementar o mesmo em código puro exigiria uma aplicação completa. O trade-off é a limitação do task-runner sandbox (sem fetch nos Code Nodes).

### Por que Browserless e não Selenium/Playwright?

Browserless elimina o gerenciamento de ChromeDriver, instalação de dependências e ciclo de vida do browser. O endpoint REST `/function` permite enviar o script como string (gerado dinamicamente pelo n8n) sem precisar implantar código. O modelo `{code, context}` é ideal para passar parâmetros do workflow para o script.

### Por que MinIO e não salvar XML no banco?

XMLs de NF-e podem ter centenas de KB. Armazenar binários no PostgreSQL degrada performance de queries e backup. O MinIO fornece object storage compatível com S3, com bucket versionado e path organizado por data. O banco armazena apenas o `object_key` (referência).

### Por que dispatcher serial em vez de fila paralela?

O ERP Elevor360 é um sistema legado com estado de sessão por usuário. Múltiplas sessões simultâneas do mesmo usuário `teste.elevor` causam conflitos de estado. A serialização é necessária para garantir que cada NF-e seja escriturada corretamente sem interferência.

### Por que mutex via banco e não Redis?

O banco PostgreSQL já é parte obrigatória da arquitetura. Usar o banco para mutex (via `SELECT COUNT(*) WHERE status='processing'`) elimina uma dependência adicional. O Redis existe mas é usado para outras finalidades (cache de sessão do n8n).

### Por que `produto_mapeamento` com cnpj_emit nullable?

Fornecedores diferentes podem usar o mesmo código de produto para itens completamente diferentes. O `cnpj_emit` permite criar mapeamentos específicos por fornecedor. O NULL serve como "coringa" quando o mesmo código tem o mesmo significado em todos os fornecedores. A constraint `UNIQUE (c_prod, cnpj_emit)` garante unicidade sem conflito entre specific e wildcard.

### Por que scraper de catálogo ao invés de API?

O ERP Elevor360 não possui API REST para consulta de produtos. O único modo de obter o catálogo é via scraping da tela `CL_Produtos.aspx`. O cache em `erp_produtos_cache` (atualizado a cada 15min) evita um round-trip ao ERP em tempo real durante a escrituração.

### Por que voltagem é tratada como caso especial na IA?

Produtos elétricos brasileiros podem ser 110V, 220V ou bivolt. Uma sugestão incorreta de voltagem causa retorno da mercadoria, prejuízo e retrabalho. O modelo Claude sem instruções específicas pode sugerir "o produto mais parecido" ignorando voltagem. As regras explícitas no prompt (voltagem é DECISIVA, nunca sugerir produto de voltagem diferente) previnem esse erro de alto custo.

---

*Elevor Importação Versão 1.0 — congelada em 01/06/2026*
