Table of Contents
Maxsul PIED¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-10
A Maxsul faz a emissão fiscal no X-Adm e opera a venda de painéis solares na plataforma
PIED. Hoje, cada venda fechada na PIED é redigitada à mão no X-Adm — lento, repetitivo e
sujeito a erro. Este servidor é o caminho robusto da integração PIED → X-Adm (projeto
MAXSUL2025/841): um serviço always-on em pied.maxsul.xadm.biz que captura tudo o que a PIED
produz — por webhook (tempo real) e por poll na API REST — e persiste os payloads crus em
Postgres.
O MVP faz só isso de propósito. A prioridade é entrar em produção o quanto antes capturando dados reais, para analisar a API da PIED com material de verdade antes de escrever qualquer transformação. A integração com o X-Adm (fase 2) já tem o caminho desenhado — e nasce desligada por toggle de configuração (decisão 0001).
Por que importa¶
- Dados reais primeiro — cada pedido, cliente e produto da PIED fica registrado cru no banco; a modelagem da fase 2 sai da observação, não de suposição.
- Nada se perde — o webhook aceita qualquer payload (evento inesperado é dado capturado, não erro) e o poll varre as três entidades com cursor incremental persistido.
- Risco mínimo — o serviço não escreve no ERP; ligar/desligar cada mecanismo é uma variável de ambiente (Configuração).
Para o desenho da solução completa (os 4 componentes e o plano em fases), veja a Documentação Completa; os termos do domínio estão no Glossário.
Pré-projeto
Pré-projeto — Integração PIED → X-Adm¶
| Documento | Pré-projeto (avaliação de viabilidade e caminhos) |
| Versão | 1.0 — decidido |
| Data | 2026-06-15 (decisão registrada em 2026-07-09) |
| Autor | Gustavo Madruga |
| Status | Aprovado — Caminho 2 (Robusto) |
| Aprovador | Marcelo Garabeli |
Para que serve este documento: avaliar a viabilidade de integrar a plataforma PIED ao ERP X-Adm, apresentar os caminhos possíveis (com prós, contras e custos) e pedir uma decisão. É propositalmente não-técnico. Aprovado este pré-projeto, segue-se o Projeto (documento técnico): endpoints, estrutura de dados, eventos, contratos e plano de testes.
::: {custom-style="Decisao"} ► DECISÃO DA DIRETORIA (2026-07-09 — Marcelo Garabeli): aprovado o Caminho 2 (Robusto) — servidor always-on na nuvem, com webhook (tempo real) + poll REST + entrega desacoplada via PowerSync. A implantação é em fases (§10): a Fase 1 (captura raw) entra em produção primeiro; a Fase 2 (transform + entrega ao X-Adm) segue com os dados reais já acumulados. O detalhamento técnico está no Projeto e na decisão 0002. :::
1. Sumário Executivo¶
A Maxsul (cliente da X-Adm) adquiriu uma empresa de painéis solares que opera na plataforma PIED. Hoje, cada venda feita na PIED é redigitada manualmente no X-Adm — produto, cliente e pedido — para só então emitir a nota fiscal. É lento, repetitivo e sujeito a erro. A Maxsul pediu à X-Adm uma integração que elimine essa redigitação.
É viável. A PIED disponibiliza todos os dados necessários por uma API, e confirmamos que conseguimos trazê-los para o X-Adm de forma automática e auditável.
Há dois caminhos: - Caminho 1 (Simples): um programa que puxa os dados da PIED periodicamente ou sob demanda. Baixo custo e risco, cobre 100% dos dados — mas sem tempo real. - Caminho 2 (Robusto): um servidor na nuvem que recebe os pedidos em tempo real e mantém tudo sincronizado automaticamente com o X-Adm. Mais robusto, com custo/esforço um pouco maiores.
Decisão solicitada: aprovar o início do projeto, definindo qual caminho seguir.
2. Contexto e problema¶
Quem é quem. A X-Adm fornece à Maxsul (nosso cliente) o ERP com toda a parte fiscal — notas, cadastros, impostos. A Maxsul adquiriu uma empresa de painéis solares, e essa empresa opera na plataforma PIED, onde cadastra produtos, clientes e registra as vendas (pedidos).
A dor. Hoje, cada venda fechada na PIED é redigitada manualmente no X-Adm — todos os cadastros (produto, cliente, pedido) — para só então emitir a nota fiscal. É lento, repetitivo, sujeito a erro e consome tempo da equipe da Maxsul.
A demanda. A própria Maxsul solicitou à X-Adm que automatize essa ponte, eliminando a redigitação. O objetivo é um fluxo automático, incremental (só o que muda) e auditável da PIED para o X-Adm.
📌 Nota de atenção — benefício ainda qualitativo. Hoje o ganho está descrito apenas de forma qualitativa ("elimina a redigitação, ganha tempo"). Seria interessante quantificar o tempo economizado, pela fórmula:
Economia (h/mês) = pedidos fechados/dia × tempo de redigitação por pedido (min) × dias úteis/mês ÷ 60
Pressuposto inicial: funil de ~5 orçamentos/dia → ~1 pedido fechado/dia; ~22 dias úteis/mês. Falta medir com a Maxsul o tempo de redigitação por pedido (cliente + produtos + pedido). Exemplo ilustrativo (a confirmar): 1 pedido/dia × 20 min × 22 dias ÷ 60 ≈ ~7 h/mês. Quanto maior o volume de pedidos, maior o retorno — vale levantar o número real antes da decisão.
3. Objetivo e escopo¶
Objetivo: trazer da PIED para o X-Adm, de forma automática e confiável, os dados de produtos, clientes e pedidos, prontos para emissão de NF e contabilidade.
3.1 Dentro do escopo¶
- Importação automática, incremental e auditável de produtos, clientes e pedidos da PIED → X-Adm.
- Implementar uma das duas modalidades de integração:
- Integração simples — o X-Adm consome a API REST da PIED de tempos em tempos ou sob demanda (Caminho 1).
- Integração robusta — servidor em nuvem com comunicação em tempo real (Caminho 2).
3.2 Fora do escopo (nesta fase)¶
- Orçamentos no X-Adm (podem ser coletados para uso interno, mas não entram no ERP).
- Devolver dados do X-Adm para a PIED (status/preço/estoque).
- Definição técnica detalhada (endpoints, estrutura de dados, eventos) — fica para o Projeto.
- Regras fiscais finais da baixa no núcleo do ERP.
3.3 Premissas¶
- A PIED disponibiliza os dados via API com token de acesso.
- A infraestrutura de nuvem já existe (Nuvem Wiechert).
- Há pontos a confirmar com PIED/X-Adm que não impedem iniciar (§9).
4. Partes interessadas¶
| Papel | Quem |
|---|---|
| Cliente / solicitante | Maxsul |
| Operação de origem (vendas na PIED) | Empresa de painéis solares (adquirida pela Maxsul) |
| Fornecedor da solução / execução | X-Adm — Equipe de Integração |
| Patrocínio / decisão (interno X-Adm) | Diretoria X-Adm |
| Fonte dos dados | PIED (plataforma / suporte) |
| Destino dos dados | ERP X-Adm (usado pela Maxsul) |
| Infraestrutura | Nuvem Wiechert |
5. Viabilidade — o que já confirmamos¶
- A PIED disponibiliza todos os dados necessários por API. Produtos, clientes e pedidos: viável.
- Existem dois jeitos de receber os dados:
- Puxar (perguntar "o que mudou?" de tempos em tempos) — cobre tudo.
- Receber aviso em tempo real (a PIED avisa quando um pedido muda) — cobre só pedidos, com algumas lacunas; não cobre produtos.
- Puxar já é suficiente para ter todos os dados. O tempo real é ganho de agilidade, não requisito de completude.
- Pontos a confirmar com PIED/X-Adm existem, mas nenhum impede começar (§9).
As APIs da PIED foram validadas (a nível de documentação/contrato) e a viabilidade do projeto está OK. Para sacramentar restam duas verificações, que serão feitas na fase de Projeto: (1) acesso direto à API da PIED com token real (confirmar payloads e comportamento ao vivo); e (2) resolver as lacunas de mapeamento de dados (campos com regra de negócio em aberto —
../../PENDENCIAS.md). Nenhuma delas muda a conclusão de viabilidade.Os contratos da PIED e a modelagem do X-Adm que sustentam esta viabilidade estão nos Anexos A–C (ao final do documento).
6. Caminho 1 — Simples (puxar via REST)¶
Como funciona: um programa Java enxuto (PiedRestClient) é acionado de tempos em tempos
(agendado) ou sob demanda (o próprio X-Adm dispara). Conecta na PIED, baixa
produtos/clientes/pedidos e grava no banco do X-Adm. Não exige nada rodando o tempo todo.
flowchart TB
XADM["X-Adm<br/>(agendado ou sob demanda)"] -->|dispara| PRC["PiedRestClient<br/>(Java)"]
PRC -->|puxa produtos / clientes / pedidos| PIED["PIED API"]
PRC -->|grava| DB[("Banco do X-Adm")]
Prós - Mais simples e mais barato: não precisa de servidor ligado 24 horas. - Menos peças, menos pontos de falha; manutenção fácil. - Roda agendado ou sob demanda — só consome recurso quando executa. - Já traz 100% dos dados necessários. - Fácil de reexecutar se falhar; controle total do ritmo (respeita o limite de uso da API).
Contras - Sem tempo real: dados só atualizam quando o programa roda. - Disparo sob demanda gera uma espera (aciona e aguarda). - A frequência precisa ser calibrada (rara demais atrasa; frequente demais bate no limite da API).
Quando faz sentido: quando atualizar "de tempos em tempos" atende o negócio. Ideal para começar e validar com baixo custo e risco.
7. Caminho 2 — Robusto (servidor na nuvem + sincronização)¶
Como funciona: um servidor na nuvem ligado o tempo todo que (a) recebe avisos instantâneos da PIED quando um pedido muda (tempo real) e (b) também sabe puxar o resto via REST (reaproveitando o programa do Caminho 1). Mantém um banco na nuvem sempre atualizado. Um segundo programa Java, com sincronização automática (PowerSync), repassa as mudanças ao X-Adm — sem o X-Adm precisar perguntar.
flowchart TB
PIED["PIED"] -->|avisa em tempo real| SRV["Servidor na nuvem<br/>(webhook + REST)"]
SRV -->|puxa o resto| PIED
SRV -->|grava| DBC[("Banco na nuvem")]
DBC -->|sincronização automática| JAVA["Programa Java<br/>(PowerSync)"]
JAVA -->|envia| XADM["X-Adm"]
Prós - Tempo real para pedidos. - O X-Adm recebe as atualizações automaticamente — não precisa puxar nem esperar. - Mais resiliente: se a conexão cair, recupera sozinho e não perde atualização. - Desacoplado: coleta (nuvem) e entrega (X-Adm) evoluem separadamente. - Cobre tudo (tempo real + puxar o resto).
Contras - Mais peças (servidor sempre no ar, banco na nuvem, sincronização) → mais a manter/monitorar. - Maior esforço inicial. - A tecnologia de sincronização exige uma pequena parte em Kotlin (detalhe técnico isolado). - Pontos em aberto com a PIED pesam mais aqui (segurança e reenvio dos avisos em tempo real).
Quando faz sentido: quando o negócio precisa dos pedidos quase em tempo real no X-Adm e/ou não se quer depender de alguém acionar a sincronização.
8. Comparativo¶
| Critério | Caminho 1 — Simples (REST) | Caminho 2 — Robusto (Servidor) |
|---|---|---|
| Tempo real (pedidos) | ❌ Periódico / sob demanda | ✅ Sim |
| Cobertura de dados | ✅ Completa | ✅ Completa |
| Custo de infraestrutura | 🟢 Baixo | 🟢 Baixo (infra já existe na Nuvem Wiechert) |
| Esforço de desenvolvimento | 🟢 Baixo | 🟡 Médio |
| Manutenção / nº de peças | 🟢 Simples | 🟡 Médio (simples, porém com mais peças) |
| Resiliência | 🟢 Boa (reexecuta) | 🟢 Alta (auto-recuperação) |
| X-Adm precisa "puxar"? | Sim (agendado/sob demanda) | Não (recebe automático) |
| Dependências externas em aberto | 🟢 Poucas | 🟡 Mais (segurança do webhook) |
9. Riscos e pontos a confirmar¶
- Com a PIED: segurança e política de reenvio dos avisos em tempo real; limites de uso da API; existência de ambiente de testes.
- Com a X-Adm: algumas regras fiscais/de negócio (ex.: classificação fiscal do pedido, condição de pagamento).
- Mitigação: o Caminho 1 depende de poucos desses pontos; nenhum impede iniciar a Fase 1. O
detalhamento técnico e as prioridades estão em
../../PENDENCIAS.md.
10. Recomendação — caminho em fases¶
Os dois caminhos compartilham o mesmo núcleo (o cliente REST do Caminho 1 é reaproveitado dentro do servidor do Caminho 2). Começar simples não é trabalho jogado fora.
| Fase | O quê | Esforço | Estimativa¹ |
|---|---|---|---|
| 1 — Integração simples (Caminho 1) | Puxar tudo da PIED e popular o X-Adm. | 🟢 Baixo | ~1 semana |
| 2 — Tempo real (Caminho 2) | Servidor na nuvem + sincronização automática; reaproveita a Fase 1. | 🟡 Médio | ~2 semanas |
¹ Estimativas preliminares (chute de ordem de grandeza), a confirmar no Projeto (fase técnica).
Assim a diretoria decide o investimento em tempo real depois de ver a viabilidade comprovada.
Valor estratégico do Caminho 2 (além desta integração)¶
Hoje o X-Adm tem bem resolvido o fluxo de SAÍDA de dados: extração em batch para BI e em tempo real para a Nota Sul Plata. O que ainda é frágil é o X-Adm RECEBER dados de APIs externas — não há um caminho consolidado.
O Caminho 2 não serve só para a PIED: ele pavimenta e solidifica esse processo de entrada de dados externos (servidor na nuvem + sincronização confiável + idempotência). Construído uma vez, vira base reutilizável para futuras integrações de entrada (outros fornecedores, marketplaces, parceiros), reduzindo custo e risco de cada próxima integração. Parte do investimento é, portanto, infraestrutura estratégica, não custo exclusivo desta integração.
11. Critérios de sucesso¶
- Os dados da PIED (produto, cliente, pedido) chegam ao X-Adm de forma automática e correta.
- A sincronização não gera retrabalho nem duplicidade (atualiza só o que muda).
- Elimina a redigitação manual hoje feita pela Maxsul para emitir a nota fiscal.
12. Decisão solicitada e próximos passos¶
::: {custom-style="Decisao"} ► DECISÃO SOLICITADA À DIRETORIA: aprovar uma das opções — Fase 1 (mais rápida e barata, ~1 semana) ou Fase 2 (mais robusta, pavimentando o caminho para integrações futuras, ~2 semanas). :::
Após aprovação: elaboração do Projeto (documento técnico) — define endpoints, estrutura de
dados, eventos, contratos, plano de testes e de implantação. O Projeto também sacramenta a
viabilidade com acesso direto à API da PIED (token real) e a resolução das lacunas de
mapeamento (../../PENDENCIAS.md). Em seguida, execução da fase aprovada.
13. Glossário¶
| Termo | Significado |
|---|---|
| Maxsul | Cliente da X-Adm que solicitou a integração; adquiriu a empresa de painéis solares que opera na PIED. |
| PIED | Plataforma B2B onde acontecem os negócios (orçamentos/pedidos de energia solar). Fonte dos dados. |
| API / REST | Forma de um sistema oferecer dados a outro de forma programática. |
| Webhook | Aviso automático que a PIED envia quando algo muda (base do "tempo real"). |
| PowerSync | Tecnologia que mantém dados sincronizados automaticamente entre um banco na nuvem e um programa. |
| Idempotência | Garantia de que reprocessar o mesmo dado não cria duplicidade. |
| Nuvem Wiechert | Infraestrutura de nuvem já existente onde os serviços rodam. |
Anexos¶
Documentos-base que fundamentam o escopo (anexados na sequência):
- Anexo A — Contrato da API REST da PIED
(
001-pied-rest-api.md) — endpoints, autenticação e JSON de request/reply das principais chamadas. - Anexo B — Contrato de Webhook da PIED
(
002-pied-webhook.md) — eventos, JSON do POST recebido, lacunas e segurança. - Anexo C — Modelagem do envio ao X-Adm
(
003-xadm-modelagem.md) — JSON que o X-Adm espera (cliente, produto, pedido).
Anexo A — Contrato da API REST da PIED¶
Informação extraída em 16/06/2026 da documentação da API da PIED (OpenAPI v2/v1). Resumo simplificado, só com o necessário para definir o escopo.
Acesso¶
- Base URL (v2):
https://backend-pied-prod.piedadmin.com.br/api/v2(v1 atende empresas). - Autenticação: header
Authorization: Bearer <token>. - Paginação:
/{rota}/{página}/{limite}— limite máx. 50. - Limite de uso: dois tetos por hora (qtd. de chamadas e tempo de processamento) → ritmo controlado.
A.1 Produtos — GET /equipments/{página}/{limite}¶
Request
GET /api/v2/equipments/1/50
Authorization: Bearer <token>
{
"data": [
{
"productCode": "MOD-550",
"name": "Painel Solar 550W Monocristalino",
"manufacturer": "Canadian Solar",
"model": "CS6W-550MS",
"type": "module",
"baseprice": 780.00,
"inventory": { "current": 320, "reserved": 12, "minimum": 50 }
}
],
"totalItems": 1
}
A.2 Pedidos — GET /requests/order/{página}/{limite}¶
Cliente (company) e itens (products) vêm embutidos no pedido.
Request
GET /api/v2/requests/order/1/50?lastUpdateAfter=2026-06-10
Authorization: Bearer <token>
{
"data": [
{
"id": "6f3a9c12", "code": "200000123", "type": "order",
"kind": "Kit Personalizado", "totalPower": 5.5,
"dealStatus": "Novo", "originalValue": 25000, "discount": 5, "finalValue": 23750,
"company": {
"cnpj": "12.345.678/0001-09", "companyName": "Solar Sul Ltda",
"mainContact": { "name": "João", "surname": "Silva", "email": "joao@solarsul.com", "cellphone": "5642999990000" },
"address": { "CEP": "84010-000", "state": "PR", "city": "Ponta Grossa",
"neighborhood": "Centro", "patio": "Rua das Acácias", "number": "100", "complement": "" }
},
"products": [
{ "productCode": "MOD-550", "type": "module", "quantity": 10,
"singlePrice": 780, "totalPrice": 7800, "center": { "code": "CD001" } }
],
"payment": { "type": "credito", "status": "received",
"condition": { "name": "30/60 dias", "quantityOfInstallments": 2 } },
"freight": { "type": "CIF", "price": 350 },
"lastUpdate": "2026-06-15T19:12:24.391Z"
}
],
"totalItems": 1
}
204= nenhum registro. Orçamentos usamGET /requests/budget/...com o mesmo formato (coletados só para uso interno — não vão ao X-Adm).
A.3 Clientes — GET /companies/{página}/{limite} (v1)¶
Mesmo objeto company do pedido (cnpj/cpf, companyName, mainContact, address), mais
stateInscription. Use quando precisar de clientes que ainda não geraram pedido.
A.4 Incremental (delta)¶
Chamando requests sem parâmetros, a API retorna só o que mudou desde a última chamada
(marcadores apiSent/apiSentUpdated). Cuidado: persistir o bruto antes de processar + manter
reconciliação por data como rede de segurança.
Campos não cobertos pela documentação da PIED (pendências) em
../../PENDENCIAS.md.
Anexo B — Contrato de Webhook da PIED¶
Informação extraída em 16/06/2026 da documentação de webhooks da PIED. Resumo simplificado, só com o necessário para definir o escopo.
Eventos disponíveis¶
| Evento | Quando dispara |
|---|---|
budget.created / budget.updated |
Orçamento criado / atualizado |
order.created / order.updated |
Pedido criado / atualizado |
Empresas (clientes) também disparam evento segundo a doc, mas sem nome publicado — a confirmar. Não há webhook de produto.
Request (PIED → nós)¶
A PIED faz um POST HTTPS para a URL configurada. O data é completo (mesmo formato do
pedido no Anexo A — não exige uma segunda consulta na maioria dos casos).
POST /webhook/pied
Content-Type: application/json
{
"event": "order.created",
"data": {
"id": "6f3a9c12", "code": "200000123", "type": "order", "kind": "Kit Personalizado",
"totalPower": 5.5, "dealStatus": "Novo", "finalValue": 23750,
"company": { "cnpj": "12.345.678/0001-09", "companyName": "Solar Sul Ltda",
"mainContact": { "email": "joao@solarsul.com", "cellphone": "5642999990000" },
"address": { "CEP": "84010-000", "state": "PR", "city": "Ponta Grossa" } },
"products": [ { "productCode": "MOD-550", "quantity": 10, "singlePrice": 780, "center": { "code": "CD001" } } ],
"payment": { "type": "credito", "status": "received" },
"lastUpdate": "2026-06-15T19:12:24.391Z"
}
}
Reply (nós → PIED)¶
Responder 200 OK rápido (apenas confirma o recebimento; o processamento é assíncrono).
HTTP/1.1 200 OK
⚠️ Lacunas (NÃO disparam webhook)¶
Alteração de responsável, edição de frete, campos customizados, anotações e upload de arquivos. Consequência: o webhook não é um espelho fiel — o que falta é coberto pela reconciliação via REST (Anexo A).
Segurança (a confirmar com a PIED)¶
- Sem assinatura/HMAC documentada → estratégia: segredo compartilhado + lista de IPs + HTTPS.
- Política de reenvio (retry) não documentada → assumir "pode perder evento" e cobrir com a reconciliação REST.
Pontos em aberto detalhados em
../../PENDENCIAS.md.
Anexo C — Modelagem do envio ao X-Adm (JSON)¶
Informação extraída em 16/06/2026 do documento interno X-Adm (
anexos/001 - MAXSUL2025_610110_US.docx), adaptada para o modelo simplificado: como a integração é interna (nós produzimos os dados), o JSON traz apenas a entidade — sem envelope detoken/data_hora/operacao/empresa. São três objetos: cliente, produto e pedido.
C.1 Cliente¶
{
"cnpjCpf": "12345678000109",
"nomeProp": "Solar Sul Ltda",
"inscEst": "",
"endereco": "Rua das Acácias", "numero": "100", "complemento": "",
"bairro": "Centro", "cidade": "Ponta Grossa", "estado": "PR",
"cep": "84010000", "cmb": "",
"telefone": "5642999990000", "email": "joao@solarsul.com"
}
cnpjCpf | company.cnpj ou company.cpf |
| nomeProp | company.companyName |
| inscEst | companies.stateInscription (v1) |
| endereco/numero/complemento/bairro/cidade/estado/cep | company.address.* |
| telefone / email | company.mainContact.cellphone / .email |
| cmb | regra X-Adm (de-para de município; não vem da PIED) |
C.2 Produto¶
{ "codigo_produto": "MOD-550", "nomeProd": "Painel Solar 550W Monocristalino", "ean13": "", "temLote": "N" }
codigo_produto | equipments.productCode |
| nomeProd | equipments.name |
| ean13 | não consta na PIED (confirmar) |
| temLote | regra X-Adm (I/N) |
C.3 Pedido¶
Cabeçalho + itens + parcelas, tudo no mesmo objeto.
{
"codigo_pedido": "200000123",
"cnpjCpf": "12345678000109",
"data": "20260615",
"tipo_frete": "CIF",
"antecipado": "S",
"itens": [ { "codigo_produto": "MOD-550", "quantidade": 10, "valor_unitario": 780.00 } ],
"pagamento": [ { "prazo": 30 }, { "prazo": 60 } ]
}
codigo_pedido | request.code |
| cnpjCpf | request.company.cnpj |
| data | request.orderCreated → AAAAMMDD |
| tipo_frete | request.freight.type (CIF/FOB) |
| itens[].codigo_produto | products[].productCode |
| itens[].quantidade | products[].quantity |
| itens[].valor_unitario | products[].singlePrice |
| pagamento[].prazo | derivado de request.payment.condition (regra X-Adm) |
| antecipado | derivado de request.payment (regra X-Adm) |
| cfop (quando aplicável) | regra fiscal X-Adm |
Observações¶
- Documentos (
cnpjCpf,cep, telefone) em dígitos (sem máscara); datas emAAAAMMDD. - O item referencia o produto pelo
codigo_produto(origem) — o X-Adm resolve internamente; não enviamos códigos internos do ERP. - Campos marcados regra X-Adm (
cmb,cfop,temLote,antecipado,prazo) ainda estão em definição — ver../../PENDENCIAS.md.
Projeto
Projeto — Integração PIED → X-Adm (Caminho Robusto)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-11
Documento técnico da integração PIED → X-Adm da Maxsul (projeto MAXSUL2025/841). Sucede o Pré-projeto, cujo Caminho 2 (Robusto) foi aprovado pela diretoria em 2026-07-09 (Marcelo Garabeli) — ver decisão 0002.
Esta é a documentação-mãe do projeto como um todo. A solução se destila em vários repositórios (Integrador, este
maxsul-pied, PowerSync, cliente Java). Aqui está a visão completa e canônica; cada repositório carrega a sua fatia destilada e aponta de volta para cá.
1. Estrutura inicial e suas partes¶
A integração é um pipeline entre dois sistemas que não se conhecem: a PIED (onde a venda de painéis solares acontece) e o X-Adm (o ERP que emite a nota fiscal). Entre eles, quatro peças, cada uma no seu repositório, ligadas por um único banco Postgres compartilhado (o banco de integração da Maxsul) e pelo PowerSync.
flowchart TB
PIED["<b>PIED</b><br/>API REST + webhooks"]
subgraph nuvem["Nuvem X-Adm · Coolify — Postgres Maxsul compartilhado"]
direction TB
PIEDAPP["<b>maxsul-pied</b> — pied.maxsul.xadm.biz<br/>webhook + poll + transform"]
INT["<b>Integrador</b> — int.maxsul.xadm.biz<br/>tabelas espelho do X-Adm · conector PowerSync"]
PS["<b>PowerSync</b> — ps.maxsul.xadm.biz<br/>Postgres ↔ SQLite"]
end
ERP["<b>ps-java</b> — cliente Java on-premises no ERP<br/>grava no X-Adm (ZIM)"]
PIED -->|"webhook (tempo real)"| PIEDAPP
PIEDAPP -->|"poll REST"| PIED
PIEDAPP -->|"PUT / DELETE /api/v1/xadm"| INT
INT -->|"replicação lógica (WAL)"| PS
PS -->|"sync offline-first"| ERP
ERP -->|"write-back de status"| INT
Quem é dono do quê (a regra que rege todo o resto)¶
| Peça | Subdomínio | É dono de… | Papel |
|---|---|---|---|
Integrador (integracao/integrador) |
int.maxsul.xadm.biz |
tabelas espelho do X-Adm: contratos, propriedades, fones, itensped, estoque |
Plataforma central de integração. Recebe o estado desejado pelo mesmo contrato REST que o ERP já usa, persiste, e alimenta o PowerSync. |
| maxsul-pied (este repo) | pied.maxsul.xadm.biz |
tabelas PIED: pied_webhook, pied_rest (raw), pied_produto, pied_cliente, pied_pedido (normalizadas) |
Ponte com a PIED: recebe webhook e faz poll REST, valida, transforma para o modelo X-Adm e empurra ao Integrador via REST. |
maxsul-powersync (maxsul/powersync) |
ps.maxsul.xadm.biz |
— (infra) | Replica as tabelas do Integrador (Postgres) para o SQLite do cliente Java, offline-first. |
ps-java (maxsul/ps-java) |
on-premises (junto ao ERP) | — (consumidor) | Cliente Java de exemplo: observa as mudanças sincronizadas e (fase futura, pela equipe X-Adm) grava no ERP; devolve o status. |
Dois princípios invioláveis que valem em todo o pipeline:
- Persistir o raw ANTES de processar — nada é transformado sem antes existir cru no banco; auditoria e reprocessamento ficam garantidos desde o dia 1.
- Nunca escrever síncrono no ERP — a entrega é desacoplada (Integrador → PowerSync → cliente). A nuvem nunca chama o ZIM direto.
Banco compartilhado, donos separados. Integrador e
maxsul-piedconectam no mesmo Postgres, mas cada um só fazCREATE/ALTER(Flyway) no seu conjunto de tabelas. Omaxsul-piednão escreve SQL direto nas tabelas do Integrador — ele usa a API REST existente do Integrador (PUT/DELETE /api/v1/xadm), o mesmo caminho que o agente on-premises do X-Adm já usa. Assim preserva-se a serialização porReentrantLock, a auditoria emrequeste a semântica de soft-delete/revive do Integrador.
O plano é em fases (decisão 0001):
- Fase 1 — captura raw (MVP, em produção primeiro): só
pied_webhook+pied_rest. Sem transform, sem escrita no Integrador. Objetivo: acumular payloads reais da PIED para modelar a fase 2 com evidência, não com suposição. - Fase 2 — transform + entrega (implementada, 2026-07-13): normaliza
(
pied_produto/pied_cliente/pied_pedido), aplica o de-para X-Adm, faz o gate pordealStatus(transição de pagamento →received), empurra o estado desejado ao Integrador (PUT /api/v1/xadm) e reconcilia o retorno do X-Adm pela remessa do manifesto (GET /api/v1/integracao/remessas) — um pedido só ficaCONFIRMADOquando a remessa inteira fechou, não quando o contrato voltou006(decisão 0021). MODOPRODUCAO(auto) ×STAGING(fila manual). Operação e dry-run pelo console/console(§3.4). Nasce desligada por toggle (pied.integracao.habilitada, defaultfalse) — ligar é decisão de operação após confirmar de-para/gate com a Maxsul (configuração).
2. Integrador — o espelho do banco X-Adm¶
O Integrador já é um produto da casa: um servidor Micronaut/Java, uma instância por cliente,
Postgres dedicado, publicado em int.<cliente>.xadm.biz. Ele nasceu para o fluxo de saída do
X-Adm (o ERP faz push e o Integrador replica para os apps móveis via PowerSync). Para a Maxsul, ele
ganha o papel de plataforma central desta integração: é onde vive o modelo X-Adm que a PIED
precisa alimentar.
2.1 As tabelas espelho (o alvo da integração)¶
O contrato autoritativo é o documento interno MAXSUL2025_610110 (entidades, campos, tamanhos e regras de transformação). Cinco entidades:
| Tabela | Chave de negócio | O que é |
|---|---|---|
contratos |
xPed / ChaveCP |
Cabeçalho do pedido de venda (cliente, valor, natureza de operação, frete). |
itensped |
xPed + item |
Itens do pedido (produto, quantidade, valor, frete). |
propriedades |
CgcCpf + CodProp |
Cliente / endereço (razão social, endereço, cidade, estado). |
fones |
CgcCpf |
Contato do cliente (nome, DDD, telefone, e-mail). |
estoque |
CodigoAlt / ChaveEst |
Produto (código PIED, nome, valores de venda, EAN13). |
Cada tabela carrega a convenção do Integrador (chave de negócio + id UUID v7 + soft-delete); as
colunas Chave* e CodRetorno/MsgRetorno são preenchidas pelo lado X-Adm e voltam pelo write-back
(§5). Schema completo (colunas, tipos, tamanhos) e relacionamentos:
Modelagem §1. De-para e regras de transformação: Mapeamento.
O que muda no Integrador para a Maxsul — aditivo. O X-Adm tem um modelo único:
contratos/propriedades/estoque/itensped(efones) são as mesmas tabelas para todos os clientes — o Integrador as espelha. Uma mesmacontratosserve compra e venda; cada fluxo preenche o subconjunto de colunas que usa. Para a Maxsul o trabalho é aditivo ao molde compartilhado: garantir as colunas do fluxo de venda (xPed,CgcCpfCliente,NatOp…) e a tabelafones(nova). Delta de colunas a confirmar com o dono do Integrador.
2.2 Como o Integrador recebe o estado desejado¶
O maxsul-pied entrega os dados pelo mesmo contrato REST que o ERP já usa para popular o
Integrador: PUT /api/v1/xadm (upsert) e DELETE /api/v1/xadm (soft-delete), com
Authorization: Bearer <token>. O Integrador faz o parse, serializa o apply
(ReentrantLock(fair=true)), persiste e audita em request. Para JSON válido responde sempre
HTTP 200, com o resultado no corpo. É o maxsul-pied no papel que o agente on-premises ocupa
para os outros clientes — só que a fonte, aqui, é a PIED em vez do próprio ERP.
Comunicação tolerante a falhas. A entrega ao Integrador nunca se perde por indisponibilidade.
Se o Integrador estiver fora do ar, responder erro ou a chamada expirar, o maxsul-pied retenta no
futuro com espera progressiva — o estado desejado continua no banco (raw + tabelas normalizadas),
então retentar é sempre seguro (idempotente por chave natural, §6.4). Falha que persista além das
retentativas alerta a equipe de desenvolvimento via GlitchTip — decidido ter uma instância
para este projeto (habilitar features.glitchtip no app.json ao configurar). Nenhum pedido é
descartado por falha de rede.
3. maxsul-pied (este projeto) — a ponte com a PIED¶
Este repositório é o microserviço slim que conversa com a PIED. Ele capta por dois canais
complementares (nenhum cobre tudo sozinho) e é dono das tabelas pied_*.
flowchart TB
subgraph pied_app["maxsul-pied"]
WH["Webhook<br/>POST /webhook/pied"] --> RAWW[("pied_webhook")]
POLL["Poll REST<br/>@Scheduled"] --> RAWR[("pied_rest")]
RAWW --> TF["Transform (fase 2)"]
RAWR --> TF
TF --> NORM[("pied_produto<br/>pied_cliente<br/>pied_pedido")]
NORM --> PUSH["push → Integrador<br/>PUT/DELETE /api/v1/xadm"]
end
UI["UI simples<br/>(o que chegou × o que foi processado)"] -.lê.- RAWW
UI -.lê.- RAWR
UI -.lê.- NORM
3.1 Webhook (tempo real) — caminho quente¶
A PIED envia POST com corpo { "event": "order.created", "data": { ... } } (eventos budget.* e
order.*; o data traz company e products embutidos — ver o payload de exemplo no
MAXSUL2025_610110). Regras:
- Toggle desligado → 503; segredo compartilhado configurado e divergente → 401 (RFC 7807).
- Insert do corpo cru + headers (credenciais mascaradas) em
pied_webhook. - Responde 200 imediatamente — nenhum processamento pesado no request.
- Evento desconhecido não é rejeitado: é dado capturado (evita reentrega da PIED por erro nosso).
O cadastro do endpoint e do segredo é manual, no painel da PIED (Configuração).
Modo de falha do canal: a PIED desliga o webhook e não avisa. Quando uma entrega falha — 404 (app fora do ar) ou timeout (rede oscilando, app de pé) — a PIED desativa o webhook, para de enviar, não religa sozinha e não reenvia o que se perdeu. Só volta com recadastro manual no dashboard (runbook). Em 18/09/2026, cinco minutos de rede ruim custaram 2h44 de apagão no pico de uma sexta. Vigiado pelo watchdog em duas réguas — 45 min comerciais e 24h úteis (0016, 0023).
3.2 Poll REST — reconciliação e o que o webhook não traz¶
O webhook sozinho não cobre tudo: não há evento de produto, e alterações de frete/responsável não disparam evento. Por isso o poll REST é obrigatório, não rede de segurança. Contrato da API validado contra o ambiente real em 2026-06-17:
- Produtos:
GET /api/v2/equipments/{pagina}/{limite}— varredura completa (sem delta). - Clientes:
GET /api/v1/companies/{pagina}/{limite}(v1) — varredura completa. - Pedidos:
GET /api/v2/requests/order/{pagina}/{limite}[?lastUpdateAfter=AAAA-MM-DD]— incremental por cursor (?lastUpdateAftersó funciona em pedidos).
Envelope comum: { "error": null, "data": { "items": [...], "totalItems": N } }. Paginação 1-based,
limite 50; página além do fim retorna HTTP 200 com items vazio — a condição de parada. O cursor
de pedidos é persistido (pied_rest / tabela de cursor) e só avança ao fim de uma rodada
bem-sucedida; a janela do último dia é reprocessada de propósito (o filtro tem granularidade de dia —
dedup é problema do transform).
O poll também é o backstop do apagão de webhook — e por um tempo foi um backstop pela metade:
recuperava o dado do pedido perdido, mas se o evento de pagamento caiu no apagão, o pedido chega
aqui já pago, sem transição para o gate testemunhar, e encalhava em CAPTURADO. Fechado pelo
resgate da 1ª captura já paga (0023),
que usa o orderCreated do REST para separar pedido novo de backlog. Nasce desligado
(PIED_GATE_PRIMEIRA_CAPTURA_DESDE vazio).
3.3 Transform (fase 2) — do modelo PIED para o modelo X-Adm¶
Com dados reais em mãos, o transform lê o raw, normaliza para pied_produto/pied_cliente/
pied_pedido e produz o estado desejado no formato do MAXSUL2025_610110, empurrando ao Integrador
via REST (§2.2). É idempotente por content-hash: só reescreve quando o conteúdo de negócio muda de
fato — evita sync inútil no PowerSync. As regras de de-para (incluindo a lógica Kit × não-Kit, a
NatOp por estado, os valores fixos ChaveUnid/CodMat, a derivação de CodProp, a quebra
DDD/telefone) estão em Mapeamento e regras.
Regra de negócio — só pedido finalizado vai ao X-Adm. O X-Adm faz apenas INSERT de pedido — nunca UPDATE. Logo, um pedido só é empurrado ao Integrador quando está finalizado na PIED; enquanto estiver em aberto/rascunho, fica retido no landing e não é enviado. Assim que finaliza, é inserido uma única vez. (Clientes e produtos não têm essa trava: podem ser atualizados normalmente.)
Como foi definido: o gate de envio é a transição de pagamento — o
payment.statuspassa areceivednum update de um pedido que já tínhamos não-pago (Maxsul, 2026-08-07). Detectada por evento no upsert (payment_status/payment_status_anterior); é go-forward por natureza (backlog já-pago não re-transiciona) e o envio é uma vez (INSERT-único; a máquina de status não reenfileira). Pedido que aparece já-pago na 1ª captura não dispara sozinho (operador enfileira no console). OdealStatusdeixou de ser o gate (pendências P1).
3.4 Console operador (/console)¶
Superfície única de operação (aberta, como o /captura) que dirige cada pedido pela máquina de
estados real (pied_pedido.status) e absorveu o antigo /inspetor+/fila. Por pedido: o de-para
PIED × X-Adm campo a campo (dry-run, sem tocar o Integrador), o stepper de estágios e as ações
liberadas pelo estado — puxar da PIED, enfileirar (gate manual), enviar, atualizar o retorno, reenviar,
re-normalizar — mais um sync global de clientes (InscEst). Em STAGING é interativo; em PRODUCAO,
monitor. Rotas e envs em configuração.
3.5 UI de diagnóstico — home + browser do banco (/dados/*)¶
As telas são server-rendered (JTE, decisão 0025 — layout/menu no padrão do integrador) e abertas (sem auth,
atrás da rede). Além do /console e do /captura, a home (/) é um painel de cards com a contagem
por tabela, e o browser do banco (/dados/{pedidos,clientes,produtos,webhooks,rest,cursor,estado})
expõe read-only as 7 tabelas pied_* pelo browser — colunas + payload JSON copiável. Expõe PII
(clientes). Detalhe das rotas em configuração.
4. PowerSync — do Postgres ao cliente¶
O PowerSync (ps.maxsul.xadm.biz, repositório maxsul/powersync) replica, por replicação
lógica (WAL do Postgres), as tabelas do Integrador para um SQLite local ao lado do cliente
Java. É self-hosted (serviço PowerSync + MongoDB de bucket), publicação Postgres nomeada powersync,
auth por JWKS (auth.xadm.biz, audience maxsul).
As sync rules já estão escritas (bucket global):
data:
- SELECT * FROM contratos WHERE deleted = false
- SELECT * FROM propriedades WHERE deleted = false
- SELECT * FROM fones WHERE deleted = false
- SELECT * FROM itensped WHERE deleted = false
- SELECT * FROM estoque WHERE deleted = false
Com a fase 2 em produção, essas tabelas são alimentadas: o EstadoDesejado deste app mapeia
cliente→propriedades, pedido→contratos, item→itensped e produto/kit→estoque, e chega lá pelo
PUT /api/v1/xadm do Integrador (§3). O log "tabela ausente" do PowerSync era o estado da fase
1 — se voltar a aparecer, é sintoma, não o esperado. Runbook e regra de "nunca down -v em
produção" (apaga o bucket) estão no repositório do PowerSync.
5. Quais dados chegam no cliente Java (ps-java)¶
O ps-java (maxsul/ps-java) é um programa Java puro (Java 21+, sem Gradle/Maven para
rodar — só java -jar), entregue à equipe do ERP como exemplo funcional. Ele não puxa API nem
faz polling: consulta o SQLite local que o PowerSync mantém atualizado em tempo real.
Chegam ao cliente exatamente as cinco tabelas espelho: contratos, propriedades, fones,
itensped, estoque (linhas com deleted = false) — ou seja, o pedido completo pronto para o
X-Adm: cabeçalho + itens + cliente + contato + produtos.
Fluxo no cliente:
- Watch reativo no SQLite: linhas novas/alteradas (estado desejado).
- Para cada uma: marca "em processamento", grava no X-Adm — hoje um
TODO X-Adm(simulaXadm); a gravação real no ZIM é desenvolvida depois, pela equipe X-Adm. - Devolve o resultado: write-back por upload do PowerSync (o SDK envia a alteração local de volta
ao endpoint de upload, que persiste
CodRetorno/MsgRetornoe asChave*nas tabelas do Integrador). Offline-first: se a rede cair, o upload fica enfileirado e reenvia.
Máquina de status (códigos de retorno X-Adm — MAXSUL2025_610110)¶
O status de cada registro segue os códigos de retorno do X-Adm (000 novo → 001/002/003
em processamento → 006 gravado; 1XX erro terminal; 9XX erro de processamento). Tabela completa
em Mapeamento §6.
Nota — exemplo atual × modelo final. O
ps-javaatual sincroniza uma tabela únicarequisicoescom statusRECEBIDO/EM_PROCESSAMENTO/CONCLUIDO_*, para provar o mecanismo de sync/write-back. O modelo final são as cinco tabelas acima com os códigos de retorno — alinhar ops-javaa esse modelo é trabalho da fase 2.
6. Parte técnica (referência)¶
6.1 Modelo de dados¶
O modelo completo — as duas famílias (espelho do X-Adm, dono Integrador; pied_*, dono maxsul-pied),
com schema, tipos, chaves e relacionamentos — está em Modelagem. A transformação
de uma família na outra (de-para, regras de negócio, códigos de retorno) está em
Mapeamento e regras. Postgres 18 (uuidv7() nativo).
6.2 Contrato de entrada (PIED) — validado¶
Endpoints REST, envelope, paginação, incremental e webhook (payloads, eventos, lacunas, segurança) em API da PIED.
6.3 Contrato de saída (Integrador) — reaproveitado¶
PUT/DELETE /api/v1/xadm, Bearer, sempre HTTP 200 para JSON válido, resultado no corpo — o mesmo
contrato do agente on-premises do X-Adm. Endpoints, semântica DELETE→PUT, código Origem e corpo
por tabela em API do Integrador.
6.3.1 Gravação no ZIM (X-Adm)¶
O X-Adm roda sobre ZIM, cujo modelo de dados difere de um Postgres comum (Character de largura
variável, VastInt escalado, máscara ≠ valor armazenado). Os campos viajam tipados no pipeline e a
formatação para o ZIM é feita no cliente, na fronteira com o zimrtmu. Tipos, regras de
formatação por campo e armadilhas em Tratamento de dados no ZIM — é o
ponto de maior risco da integração.
6.4 Idempotência e ordem¶
- Raw persistido antes de processar; reprocessar é seguro.
- Transform idempotente por content-hash (só reescreve em mudança real → sem sync inútil).
- Apply no Integrador serializado por
ReentrantLock(ordemDELETE→PUT, sem deadlock40P01). - Overlap webhook × poll é seguro por chave natural (código do pedido/produto, documento do cliente).
6.5 Segurança¶
- Webhook: segredo compartilhado opcional (header
X-Pied-Secret, a confirmar com a PIED) + possível IP allowlist no Traefik; HTTPS obrigatório; payload com PII mascarado em log. - REST ao Integrador: Bearer estático (env do Coolify).
- PowerSync: JWKS
auth.xadm.biz, audiencemaxsul; credencial do Postgres só porPS_PG_URI.
6.6 Deploy e operação¶
Coolify por subdomínio (pied. / int. / ps. maxsul.xadm.biz), imagens multi-stage,
GET /health. Toggles e passo a passo em operacao/configuracao.md.
6.7 Qualidade¶
- Cliente REST/paginação com WireMock (mock da API PIED).
- Webhook ponta a ponta (
@MicronautTest+ Postgres via PostgresTestResource): segredo, toggles, corpo arbitrário. - Checkstyle X-Adm no gate (
./gradlew check, CI Forgejo).
6.8 Riscos e pendências¶
| Risco / lacuna | Situação |
|---|---|
| A lista decisão-a-decisão (cada item com alternativas e recomendação) está em | |
| Pendências. Resumo dos principais riscos: |
| Risco / lacuna | Situação |
|---|---|
Colunas de venda + fones no modelo X-Adm do Integrador |
Aditivo ao molde compartilhado; delta de colunas a confirmar com o Integrador (Modelagem §1). |
| Campo que marca "pedido finalizado" na PIED | Bloqueia o quando enviar (INSERT-only); definido a partir do raw da Fase 1 (§3.3). |
De-para restante (ClassForma, Prazo, EAN13, rateio de frete) |
Regra/confirmação — Pendências. |
| Header do segredo do webhook / retry da PIED | A confirmar com a PIED (API PIED §3.2). Token de produção é variável do Coolify. |
ps-java ainda no modelo de exemplo (requisicoes) |
Alinhar às 5 tabelas + códigos de retorno na fase 2. |
Crescimento do landing pied_* |
Política de retenção decidida na fase 2, com volumes medidos. |
7. Como esta doc se destila nos outros repositórios¶
A documentação completa vive aqui. Cada repositório carrega a fatia dele e aponta para cá:
| Repositório | O que documentar lá | Aponta para |
|---|---|---|
| Integrador | As colunas de venda + a tabela fones no modelo X-Adm compartilhado (P1, de Modelagem §1), a fonte via /api/v1/xadm, e o registro do origem INT-PIED. |
esta doc (§2), API Integrador, Pendências |
| maxsul-pied (aqui) | Webhook, poll, transform, tabelas pied_*, UI; modelo em Modelagem, transform em Mapeamento. |
— (é a doc-mãe) |
| maxsul-powersync | Sync rules, replicação, runbook (já existe). | esta doc (§4) |
| ps-java | Cliente de exemplo; simulaXadm; alvo = 5 tabelas + códigos de retorno; formatação para o ZIM (integracao-zim). |
esta doc (§5) |
8. Apêndice — histórico¶
Decisões:
- 0001 — MVP entra em produção só capturando raw
- 0002 — Caminho robusto (servidor always-on)
- 0003 — Valores tipados no pipeline, formatação ZIM no cliente
- 0004 — MODO staging: gate manual da entrega ao Integrador
- 0005 — Alerta por e-mail no erro terminal do pedido
- 0006 — Painel colaborador: dashboard e detalhe abertos para o usuário Maxsul
- 0007 — Saneamento de texto para o X-Adm: sem diacríticos e caixa alta
- 0008 — Adota xadm-comum-web (base transversal da casa) e desliga o security transitivo
- 0009 — Cliente do transform vem da aba Faturamento (invoice), não do Integrador (company)
- 0010 — Visibilidade de pagamento: parcial travado e cancelamento pós-import
- 0011 — int-pied native-ready (GraalVM); imagem/deploy na fábrica
- 0012 — Convergência da entrega ao outbox da casa e adoção do micronaut-data
- 0013 — Testes de integração sem SQL cru, sobre a fixture da casa
- 0014 — Guarda de cluster (advisory lock) para os @Scheduled
- 0015 — CI/CD 100% GitHub Actions (adoção local do ADR central 0027)
- 0016 — Watchdog de silêncio de webhook (alerta por inatividade)
- 0017 — Enriquecimento sob demanda do pedido preso na fila
- 0018 — Recência do evento e normalização incremental
- 0019 — Adota o kit de UI X-Adm (JTE) — ponteiro do ADR central 0025
- 0020 — Adota o smoke de produção pós-deploy — ponteiro do ADR central 0031
- 0021 — Reconciliação por remessa e re-arme na ordem certa — ponteiro do manifesto
- 0022 — Persistência por Micronaut Data: o critério lote × CRUD
- 0023 — Apagão de webhook: watchdog comercial e resgate da 1ª captura já paga
Modelagem de dados — PIED e X-Adm¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-11
O modelo de dados consolidado da integração. São duas famílias de tabelas, no mesmo Postgres compartilhado, com donos distintos:
| Família | Dono (Flyway) | Papel | Sincroniza p/ o cliente? |
|---|---|---|---|
Espelho do X-Adm (contratos, propriedades, fones, itensped, estoque) |
Integrador | Estado desejado no formato do ERP — o alvo da integração | ✅ sim (PowerSync) |
PIED (pied_*) |
maxsul-pied | Captura crua da PIED + normalização interna | ❌ não (interno) |
O maxsul-pied grava na família espelho pela API REST do Integrador (/api/v1/xadm), nunca por
SQL direto (ver Projeto §2.2). A transformação de uma família na outra — de-para, regras
de negócio e códigos de retorno — vive em Mapeamento e regras.
1. Família espelho do X-Adm (dono: Integrador)¶
Contrato autoritativo: documento interno MAXSUL2025_610110. Cinco entidades que reproduzem o modelo do ERP. Este schema é consolidado aqui e replicado no repositório do Integrador (que é o dono do Flyway).
Modelo único do X-Adm. Estas tabelas são as mesmas para todos os clientes do Integrador — não são específicas da Maxsul. Uma
contratos(chaveChaveCP) serve compra e venda; cada fluxo preenche o subconjunto de colunas que usa. A integração da Maxsul usa as colunas de venda abaixo — algumas podem precisar entrar no molde compartilhado do Integrador, sem colidir com outros clientes.
Convenções (valem para as cinco tabelas)¶
- Chave natural da fonte PIED (ex.:
xPed,CodigoAlt,CgcCpf) +idUUID v7 surrogate (Postgres 18uuidv7(), ordenável por tempo) + soft-delete (deleted BOOLEAN,deleted_at).Nota (identidade no X-Adm).
xPed/CodigoAltsão códigos da PIED (transporte): servem de chave natural da entrada enquanto o X-Adm ainda não gerou a sua identidade (Chave*—ChaveCP/ChaveEst). No espelho do integrador eles são armazenados prefixadospied_*(rastreabilidade), e a identidade do produto éChaveEst/CodProd— nunca o código do parceiro. O fio (JSON que este projeto posta) segue comCodigoAlt/xPedinalterado. Ver decisão 0013 do integrador. - Tipos ZIM → Postgres:
VastInt(a-b dec)→NUMERIC;Date(8)(AAAAMMDD) →DATE;Char(n)/VarAlpha(n)→TEXT/VARCHAR. - Colunas preenchidas pelo lado X-Adm (chegam vazias, voltam pelo write-back, §5 do livro):
Chave*(ChaveCP,ChaveProp,ChaveEst,ChaveFone,ChaveItem) eCodRetorno/MsgRetorno. - Colunas só do PowerSync (sequenciais gerados na gravação):
idempropriedadeseitensped(marcadas ⟳ abaixo).
1.1 contratos — cabeçalho do pedido¶
| Campo | Tipo | Tam | Conteúdo |
|---|---|---|---|
xPed 🔑 |
Char | 15 | Código do pedido de venda no PIED. |
CgcCpfCliente |
Char | 14 | CNPJ ou CPF do cliente. |
ChaveUnid |
Char | 5 | Centro de custos do pedido. |
CodMat |
Char | 10 | Chave do funcionário que cadastrou o pedido. |
DtEm |
Date | 8 | Data de emissão. |
DataInc |
Date | 8 | Data de inclusão. |
HoraInc |
Char | 4 | Hora de inclusão. |
VlTot |
VastInt | 0-2 dec | Valor do pedido. |
NatOp |
Char | 5 | Filtro de contabilização (natureza de operação). |
Compl |
Char | 2 | Complemento do filtro de contabilização. |
TpVda |
Char | 1 | F = FOB, C = CIF. |
ClassForma |
Char | 2 | Forma de pagamento. |
Prazo |
Char | 8 | Data de entrega. |
ChaveCP ⬅X-Adm |
Char | 20 | Chave unique do pedido no X-Adm (gerada na inclusão). |
ChaveProp ⬅X-Adm |
Char | 11 | Chave unique do cliente no X-Adm. |
CodRetorno ⬅X-Adm |
Char | 3 | Código de retorno do cadastro. |
MsgRetorno ⬅X-Adm |
VarAlpha | 128 | Mensagem de retorno. |
1.2 propriedades — cliente / endereço¶
| Campo | Tipo | Tam | Conteúdo |
|---|---|---|---|
id ⟳🔑 |
VastInt | — | Sequencial unique (gerado no PowerSync). |
CgcCpf |
Char | 14 | CNPJ ou CPF do cliente. |
CodProp |
Char | 2 | Código do endereço do cliente. |
NomeProp |
VarAlpha | 50 | Nome/razão social do cliente. |
Fantasia |
VarAlpha | 30 | Nome fantasia. |
Endereco |
VarAlpha | 60 | Endereço. |
CEP |
Char | 8 | CEP. |
Bairro |
VarAlpha | 20 | Bairro. |
Cidade |
VarAlpha | 30 | Cidade (o X-Adm tem tabela de municípios). |
Estado |
Char | 3 | Estado (gravar com um espaço antes, totalizando 3 chars). |
Numero |
Char | 6 | Número do endereço. |
InscEst |
Char | 15 | Inscrição Estadual ou RG. |
ChaveProp ⬅X-Adm |
Char | 11 | Chave unique do cliente no X-Adm. |
CodRetorno ⬅X-Adm |
Char | 3 | Código de retorno. |
MsgRetorno ⬅X-Adm |
VarAlpha | 128 | Mensagem de retorno. |
1.3 fones — contato do cliente¶
| Campo | Tipo | Tam | Conteúdo |
|---|---|---|---|
CgcCpf 🔑 |
Char | 14 | CNPJ/CPF do cliente (no X-Adm relaciona por ChaveProp). |
DDD |
Char | 5 | DDD do contato. |
Telefone |
Char | 10 | Telefone do contato. |
Nome |
VarAlpha | 50 | Nome do contato. |
Email |
VarAlpha | 100 | E-mail do contato. |
ChaveFone ⬅X-Adm |
Char | 14 | Chave unique do contato no X-Adm. |
ChaveProp ⬅X-Adm |
Char | 11 | Chave unique do cliente no X-Adm. |
CodRetorno ⬅X-Adm |
Char | 3 | Código de retorno. |
MsgRetorno ⬅X-Adm |
VarAlpha | 128 | Mensagem de retorno. |
Divergência decidida da doc-mãe (MAXSUL2025_610110): o
fonesagora carregaCodRetornocomo as demais espelho — princípio universal (toda tabela espelho carrega seu código de retorno, para oGET /retorno/{tabela}responder uniforme, api-integrador §7). O livro original omitiaCodRetornoemfones.
1.4 itensped — itens do pedido¶
| Campo | Tipo | Tam | Conteúdo |
|---|---|---|---|
id ⟳🔑 |
VastInt | — | Sequencial unique (gerado no PowerSync). |
xPed |
Char | 15 | Código do pedido no PIED. |
CodigoAlt |
Char | 15 | Código do produto no PIED. |
Qtde |
VastInt | 0-3 dec | Quantidade. |
Valor |
VastInt | 0-5 dec | Preço unitário. |
Total |
VastInt | 0-2 dec | Valor total do item. |
TaxaFrete |
VastInt | 0-5 dec | Valor unitário do frete. |
ChaveCp ⬅X-Adm |
Char | 20 | Chave unique do pedido no X-Adm. |
ChaveEst ⬅X-Adm |
Char | 11 | Chave unique do produto no X-Adm. |
ChaveItem ⬅X-Adm |
Char | 27 | Chave unique do item no X-Adm. |
CodRetorno ⬅X-Adm |
Char | 3 | Código de retorno. |
MsgRetorno ⬅X-Adm |
VarAlpha | 128 | Mensagem de retorno. |
No caso Kit (§ Mapeamento), o item usa também
DescCompl(Char) para classificar o gerador por potência — coluna auxiliar do transform, ver Mapeamento.
1.5 estoque — produto¶
| Campo | Tipo | Tam | Conteúdo |
|---|---|---|---|
CodigoAlt 🔑 |
Char | 15 | Código do produto no PIED. |
EAN13 |
Char | 13 | Código de barras. |
CodProdAlt |
Char | 8 | Código do produto base a partir do qual o novo estoque é cadastrado. |
NomeProd |
VarAlpha | 128 | Nome do produto. |
Venda |
VastInt | 0-5 dec | Valor unitário de venda. |
VendaPz |
VastInt | 0-5 dec | Valor unitário de venda a prazo. |
Saldo |
VastInt | 0-3 dec | Saldo no X-Adm. |
ChaveEst ⬅X-Adm |
Char | 11 | Chave unique do produto no X-Adm. |
CodRetorno ⬅X-Adm |
Char | 3 | Código de retorno. |
MsgRetorno ⬅X-Adm |
VarAlpha | 128 | Mensagem de retorno. |
🔑 chave de negócio · ⟳ sequencial só do PowerSync · ⬅X-Adm preenchido pelo ERP (write-back).
1.6 Relacionamentos¶
| Chave primária | Chave estrangeira |
|---|---|
propriedades.CgcCpf |
contratos.CgcCpfCliente |
propriedades.CgcCpf |
fones.CgcCpf |
propriedades.ChaveProp |
contratos.ChaveProp |
propriedades.ChaveProp |
fones.ChaveProp |
contratos.ChaveCP |
itensped.ChaveCP |
contratos.xPed |
itensped.xPed |
estoque.ChaveEst |
itensped.ChaveEst |
estoque.CodigoAlt |
itensped.CodigoAlt |
erDiagram
PROPRIEDADES ||--o{ CONTRATOS : "CgcCpf / ChaveProp"
PROPRIEDADES ||--o{ FONES : "CgcCpf / ChaveProp"
CONTRATOS ||--o{ ITENSPED : "xPed / ChaveCP"
ESTOQUE ||--o{ ITENSPED : "CodigoAlt / ChaveEst"
2. Família PIED (dono: maxsul-pied)¶
Tabelas internas do maxsul-pied, não sincronizadas. Dividem-se em landing (captura crua) e
normalizadas (fase 2).
2.1 Landing — captura raw (fase 1, em produção)¶
Criadas pela migração Flyway V1__landing_pied.sql. Guardam o payload como chegou — o transform
da fase 2 lê daqui. A granularidade é fiel e barata (a página/o POST inteiro, não o item explodido).
erDiagram
PIED_WEBHOOK {
uuid id PK "default uuidv7()"
text evento "campo event do payload (nullable)"
jsonb payload "corpo cru (NOT NULL)"
jsonb headers "headers HTTP, credenciais mascaradas"
timestamptz recebido_em "default now()"
}
PIED_REST {
uuid id PK "default uuidv7()"
text entidade "produtos | clientes | pedidos"
int pagina "1-based"
jsonb payload "envelope completo da página (NOT NULL)"
int total_items "data.totalItems"
timestamptz buscado_em "default now()"
}
PIED_CURSOR {
text entidade PK "produtos | clientes | pedidos"
date last_update_after "cursor do filtro incremental (só pedidos)"
int ultima_pagina "checkpoint do backfill (0 = recomeça da pág 1)"
timestamptz atualizado_em
}
PIED_CURSOR ||--o{ PIED_REST : "governa o delta de pedidos"
pied_webhook— uma linha por POST recebido.eventoextraído do campoeventquando presente (order.created,budget.updated…); corpo não-JSON é envelopado como string JSON (payloadéNOT NULL, nada é rejeitado).headersguarda a requisição para diagnóstico, comX-Pied-Secret/Authorizationmascarados.pied_rest— uma linha por página buscada no poll; opayloadé o envelope{error, data:{items, totalItems}}completo.pied_cursor— estado do poll por entidade.last_update_afteré a data usada como?lastUpdateAfterna próxima rodada; só pedidos usam (produtos/clientes são sempre varridos por inteiro — a PIED ignora o filtro neles) e só passa a valer após o backfill completar.ultima_paginaé o checkpoint do backfill: como a cota horária da PIED (429) interrompe um scan grande no meio, guarda-se a última página capturada para retomar dali na próxima rodada em vez de re-escanear do início (V2; migração da premissa "cursor avança só ao completar", que gerava re-scan infinito — pendências P1, decisão do 429). Ao completar (página vazia), zera-seultima_paginae, em pedidos, liga-se olast_update_afterincremental.- Índices:
pied_webhook (recebido_em),pied_webhook (evento),pied_rest (entidade, buscado_em).
Nomenclatura (definida): as landing ficam como estão —
pied_webhook,pied_rest,pied_cursor; a fase 2 apenas acrescenta as normalizadas (pied_produto/pied_cliente/pied_pedido), sem renomear.Retenção (definida): o landing
pied_*é mantido indefinidamente enquanto barato — é a trilha de auditoria e a fonte de reprocessamento da fase 2. Expurgo só se o volume medido no MVP exigir; nesse caso, arquivar/expurgar páginas antigas depied_restjá processadas, preservandopied_webhook.
2.2 Normalizadas — negócio interno (materializadas na V3__normalizadas.sql)¶
O transform explode o raw em tabelas de negócio próprias, fonte estável e deduplicada do de-para.
Criadas pela migração V3__normalizadas.sql (Postgres 18, uuidv7() surrogate + chave natural +
soft-delete); o NormalizadorService faz upsert idempotente por chave natural a partir de
pied_rest/pied_webhook. Schema exato: a migração é a fonte (não duplicado aqui).
| Tabela | Chave natural | Origem | Colunas típicas |
|---|---|---|---|
pied_produto |
product_code |
poll equipments |
name, manufacturer, model, baseprice, payload |
pied_cliente |
documento (CNPJ/CPF só dígitos) |
poll companies + company embutido no pedido |
company_name, fantasy_name, state_inscription (InscEst), payload |
pied_pedido |
code |
poll requests/order + webhook order.* |
pied_id (→ xPed), documento_cliente, deal_status (+ _anterior), máquina de status, payment_status (+ _anterior), pago_em, parcial_desde, cancelado_pied_em, importacao_manual_obs/_em (V12, "Importado manualmente"), alerta_cod_retorno (V13, dedup do alerta de erro terminal), last_update, payload |
pied_pedidoé a estrutura central: carrega a máquina de status da entrega (CAPTURADO → NA_FILA → ENVIANDO → ENVIADO → CONFIRMADO | ERRO_XADM;ERRO= falha de push) e as colunas de retorno (cod_retorno/msg_retorno/chave_xadm,content_hash). Elas nascem na V3, mas só são dirigidas nas fatias de push/reconciliação (adiante na Fase 2). Oalerta_cod_retornoguarda o último código 1XX já alertado por e-mail: a reconciliação só envia quando consegue gravar um código diferente, e o campo zera quando o pedido confirma ou é reenfileirado.- Colunas de pagamento (carimbos por transição no upsert):
pago_em(→received, gate de envio),parcial_desde(→partial, aviso de parcial travado >5 dias) ecancelado_pied_em(set-once,CONFIRMADO→cancelled, dispara o e-mail de cancelamento pós-import). Ver decisão 0010. pied_produtovem só do pollequipments; osproducts[]embutidos no pedido são consumidos inline pelo de-para, não materializados — evita sobrescrever os campos ricos do produto com o item esparso do pedido (o pedido cru fica empied_pedido.payload).- Orçamento (
budget) não vira tabela normalizada — não vai ao X-Adm. - Recência do evento (V11): o upsert de
pied_pedidorecusa payload cujolastUpdateseja anterior ao já persistido — snapshot atrasado não reaplica umpayment.statusvencido nem dispara os carimbos de transição. Empate (>=) passa, porque é o enriquecimento rebuscando o mesmo snapshot peloinvoice.upsertdevolvebooleane o alerta de cancelamento pós-import só sai quando a linha foi mesmo alterada. A comparação é no segundo cheio (date_trunc('second', …)dos dois lados): webhook e REST carimbam o mesmo instante com precisões diferentes (.512Zvs. segundo cheio), e comparar cru prendia o pedido emNA_FILApara sempre — ver 0018 A.1. - Cursor da normalização (
pied_normalizacao_estado, V11): singletonid = 1com omax(recebido_em/buscado_em)do raw já normalizado — a rodada lê só o raw novo (mais uma margem de 10 min, contra commit fora de ordem) em vez de reprocessar a história inteira a cada 5 min. Reprocesso completo =UPDATE pied_normalizacao_estado SET ate_ts = NULL. Motivação e trade-offs na decisão 0018.
Mapeamento e regras de negócio — PIED → X-Adm¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-21
Como o dado da PIED vira o estado desejado nas tabelas espelho do X-Adm
(Modelagem §1). Fonte autoritativa: documento interno MAXSUL2025_610110. O
transform lê o raw/normalizado (Modelagem §2) e produz os registros que o
maxsul-pied empurra ao Integrador via /api/v1/xadm (Projeto §2.2).
Entrada de referência: o payload de webhook { "event": "order.created", "data": { … } }, com
company e products[] embutidos (exemplo completo no MAXSUL2025_610110).
1. Regras de negócio transversais¶
- Só pedido finalizado vai ao X-Adm. O X-Adm faz apenas INSERT de pedido — nunca UPDATE. Um pedido só é enviado quando finalizado na PIED; antes disso fica retido no landing. Depois de enviado, alteração na PIED não reflete no X-Adm por este caminho (caminho só de ida — pedido enviado nunca volta à fila). O campo que marca "finalizado" será determinado a partir dos dados raw da Fase 1 (decisão adiada de propósito — evidência antes de suposição, decisão 0001).
- Corolário — cadastros compartilhados (
propriedades/fones/estoque). A chave deles é o documento/produto, não o pedido: umPUTposterior (de outro pedido do mesmo cliente) atualiza o dado no espelho. Mas o espelho preserva ocod_retorno(mão única) — sem re-arme, o ERP nunca é reavisado e o console exibe o dado novo como se entregue fosse (caso 260081441, endereço corrigido no espelho e parado no006). Desde 2026-09-17 o Integrador rearma sozinho a linha006cujo dado mudou (volta a000para recoleta);1XXsegue com o operador. - Campo não informado grava em branco, não em nulo.
- Documentos e datas normalizados: CNPJ/CPF/CEP/telefone em dígitos (sem máscara); datas
derivadas de
data.lastUpdate. - Saneamento de texto (convenção X-Adm, decisão 0007):
campos texto-livre viajam sem diacríticos e em caixa alta (
NFD+ strip\p{Mn}+toUpperCase(ROOT)). Allowlist:NomeProp,Fantasia,Endereco,Bairro,Cidade,Numero,fones.Nome,NomeProd.Emailfica de fora (local-part case-sensitive por RFC); códigos, dígitos e UF também não são saneados. - Kit × não-Kit decide a explosão de itens/estoque (§4). Detecção por
data.kind like "%Kit%".⚠️ O MAXSUL2025_610110 usa
data.type like "%Kit%"na regra deNatOp, masdata.type="order"e o rótulo "Kit" aparece emdata.kind("Kit Express"). Adotamoskind; confirmar com dado real. - Cliente, frete e pagamento vêm no próprio pedido, mas só no REST:
data.invoice(cliente faturado) edata.freightnão vêm no webhookorder.*(subconjunto). Para não perdê-los quando um webhook chega depois de um poll REST, o upsert dopied_pedido.payloadfaz merge shallow de topo (chave que a fonte nova traz não-nula vence; ausente é preservada) — ver decisão 0009. - A inscrição estadual do cliente vem inline em
data.invoice.ie(dropa o lookup nopied_cliente). O pollv1/companies/ webhookcompany.*seguem alimentandopied_cliente(catálogo do Integrador), keado pelo doc docompany.
2. Cliente → propriedades + fones (1 registro cada)¶
O cliente é a aba Faturamento (
data.invoice), NÃO o Integrador (data.company) (decisão 0009).companyé a revenda/instaladora (CNPJ);invoiceé o comprador faturado (tipicamente CPF), que é quem o X-Adm precisa. Ocompanysegue alimentando só o catálogopied_clienteno normalizado — não apropriedades/fones.
propriedades¶
| Campo X-Adm | Origem / regra |
|---|---|
CgcCpf |
data.invoice.cnpj se nulo, senão data.invoice.cpf (dígitos). |
NomeProp |
data.invoice.razaoSocial. |
Fantasia |
data.invoice.nomeFantasia. |
Endereco/CEP/Bairro/Cidade/Estado/Numero |
do endereço escolhido (ver abaixo): patio/CEP/neighborhood/city/state/number. Estado com um espaço antes (3 chars). |
InscEst |
data.invoice.ie inline (vem no próprio pedido; dropa o lookup no pied_cliente), só-dígitos (a PIED manda formato inconsistente — ex. MT 13.821.564-2; IE é sempre numérica). Ausente → branco. |
CodProp |
fixo " 0" (um registro por documento; endereço único — ver decisão 0009). |
Endereço da propriedade — entrega × faturamento (decisão 0009):
fonte = data.freight.address SE (freight.type == "CIF" E freight.address completo)
senão data.invoice.address
completo := CEP != null E patio != null E number != null
- CIF com endereço completo → endereço de entrega (
freight.address). - FOB (traz só city/state) ou CIF incompleto → faturamento (
invoice.address). - A UF fiscal do
NatOp(§3) usainvoice.address.state(nota), que pode divergir depropriedades.Estado(entrega) num CIF interestadual — divergência intencional.
fones¶
| Campo X-Adm | Origem / regra |
|---|---|
CgcCpf |
igual à propriedades (doc do invoice). |
Nome |
data.invoice.razaoSocial (invoice não tem mainContact). |
DDD |
data.invoice.telephone — parte dentro dos parênteses. |
Telefone |
data.invoice.telephone — parte fora dos parênteses. |
Email |
data.invoice.email. |
3. Pedido → contratos (1 registro)¶
| Campo X-Adm | Origem / regra |
|---|---|
xPed |
data.code (número do pedido; cabe no CHAR(15) do X-Adm — data.id é ObjectId de 24). |
CgcCpfCliente |
data.invoice.cnpj se nulo, senão data.invoice.cpf (dígitos) — cliente = Faturamento. |
ChaveUnid |
fixo " 1 29". |
CodMat |
fixo " 1 0 134". |
DtEm / DataInc / HoraInc |
data.lastUpdate. |
VlTot |
data.finalValue + data.invoice.serviceToAddInvoiceValue (JSON não traz o total da NF; serviço ausente = 0). |
NatOp |
regra por estado × Kit (§4.1). |
Compl |
regra por estado × Kit (§4.1). |
TpVda |
data.freight.type: FOB → F; qualquer outro (inclui CIF) → C (default) — via poll REST. Frete ausente → branco (em prod 100% dos pedidos trazem CIF/FOB). |
ClassForma |
de-para de data.payment.type/condition → código X-Adm — de-para a definir (§5). |
Prazo |
data de entrega — confirmar se há campo na PIED (§5). |
4. Produto e item → estoque + itensped¶
A explosão depende de o pedido ser um Kit (data.kind like "%Kit%").
4.1 NatOp / Compl (cabeçalho contratos)¶
| Estado | Kit? | Doc | NatOp |
Compl |
|---|---|---|---|---|
| SC | sim | — | 5.101 |
0 |
| ≠ SC | sim | PJ (invoice.cpf nulo) |
6.101 |
0 |
| ≠ SC | sim | PF (invoice.cpf ≠ nulo) |
6.107 |
0 |
| SC | não | — | 5.102 |
1 |
| ≠ SC | não | PJ (invoice.cpf nulo) |
6.102 |
0 |
| ≠ SC | não | PF (invoice.cpf ≠ nulo) |
6.108 |
0 |
PF × PJ só distingue fora de SC.
PF= cliente faturado (data.invoice) comcpfpresente;PJ=cpfnulo (invoice só comcnpj, ou sem doc). Segue a mesma fonte fiscal da UF —invoice, nãocompany(revenda, sempre CNPJ) — por decisão 0009.Compl=1só em SC não-Kit;0nos demais (independe de PF/PJ).
4.2 Se Kit (data.kind like "%Kit%") — 1 item sintético¶
O Kit vira um gerador fotovoltaico, classificado pela potência (data.totalPower).
itensped (1 registro):
| Campo | Regra |
|---|---|
xPed |
data.code (número do pedido; cabe no CHAR(15) do X-Adm — data.id é ObjectId de 24). |
CodigoAlt |
data.code (mesmo do estoque[0] — o item referencia o produto do kit; o Integrador resolve itensped por (xPed, CodigoAlt), então não pode ser vazio). |
DescCompl |
" 1 0E 20000" se totalPower ≤ 75; " 1 0E 20001" se > 75. |
Qtde |
fixo 1. |
Valor / Total |
data.finalValue + data.invoice.serviceToAddInvoiceValue (JSON não traz o total da NF; serviço ausente = 0). |
TaxaFrete |
data.freight.price (nível pedido) — via poll; ratear por item se necessário. |
estoque (1 registro):
| Campo | Regra |
|---|---|
CodProdAlt |
" 20000" se totalPower ≤ 75; " 20001" se > 75. |
CodigoAlt |
data.code (número do pedido; cabe no CHAR(15) do X-Adm — data.id é ObjectId de 24). |
NomeProd |
"GERADOR FOTOVOLTAICO DE CORRENTE CONTINUA COM POTENCIA " + totalPower + " kW". |
Venda |
data.finalValue. |
VendaPz |
data.originalValue. |
4.3 Se não-Kit — 1 item por produto¶
Para cada data.products[]:
itensped:
| Campo | Regra |
|---|---|
xPed |
data.code (número do pedido; cabe no CHAR(15) do X-Adm — data.id é ObjectId de 24). |
CodigoAlt |
products[].productCode. |
Qtde |
products[].quantity. |
Valor |
products[].singlePrice. |
Total |
products[].totalPrice. |
TaxaFrete |
data.freight.price (nível pedido) — via poll; ratear por item se necessário. |
estoque:
| Campo | Regra |
|---|---|
CodProdAlt |
fixo "". |
CodigoAlt |
products[].productCode. |
NomeProd |
products[].name. |
Venda / VendaPz |
products[].singlePrice. |
4.4 Se Kit — FORMULAS (BOM), 1 linha por componente¶
Além do item sintético (§4.2), um pedido kit emite a fórmula (BOM): um registro por
data.products[] (os componentes reais do kit). Não-kit não gera fórmula. Ver
decisão 0009 para o cliente e a spec .ia/006.
| Campo (wire) | Regra |
|---|---|
xPed |
data.code. |
CodProd |
products[].productCode (código do componente no X-Adm — CodProd, não CodigoAlt; numérico 5 díg, ex. 22162). |
Qtde |
products[].quantity. |
- Só esses 3 campos.
pied_formula_id/Chave*/CodRetornonão são do int-pied. - Guard: componente sem
productCodeou semquantitynão vira linha. - Premissa: os
productCodede componente já existem no catálogo do X-Adm — o int-pied não emiteestoquedeles (só referencia). Pedido pago é imutável → emite uma vez, sem remoção/DELETE. - O transform sempre emite o array
formulasnoPUT(o integrador-server/013 trata o array). Ocontent_hashabraçaformulasautomaticamente.
5. Lacunas — campos que a API PIED não fornece¶
Campos que ainda dependem de regra de negócio ou confirmação (ver Pendências):
| Campo X-Adm | Entidade | Situação |
|---|---|---|
ClassForma (forma de pagamento) |
contratos |
a PIED expõe payment.type/condition; falta o de-para para o catálogo X-Adm (01 Dinheiro … 17 PIX … 99 Outros). |
Prazo (data de entrega) |
contratos |
confirmar se a PIED tem o campo de data de entrega. |
EAN13 |
estoque |
código de barras não vem da API — deixar em branco (enriquecer depois, se necessário). |
Reclassificados como resolvíveis (o MAXSUL2025_610110 os dava como "não tem na API", mas a API validada os fornece):
TpVda←freight.type,TaxaFrete←freight.price,InscEst←data.invoice.ie(inline no pedido — §2).
6. Códigos de retorno (máquina de status)¶
O resultado da gravação no X-Adm volta pelo write-back nas colunas CodRetorno/MsgRetorno
(Modelagem §1), percorrendo os códigos definidos no MAXSUL2025_610110:
| Faixa | Código | Significado |
|---|---|---|
Processamento (0XX) |
000 |
Novo no PowerSync, não cadastrado no X-Adm. |
001 |
Em processamento no PowerSync. | |
002 |
Em processamento no X-Adm. | |
003 |
Gravando retorno do X-Adm. | |
006 |
Gravado no X-Adm. | |
Erro terminal (1XX) |
110 |
Inconsistência de dígito verificador. |
111 |
Ausência de dado obrigatório. | |
120 |
Ausência de cadastro auxiliar (ex.: município não cadastrado). | |
121 |
Inconsistência no cadastro. | |
Erro de processamento (9XX) |
980 |
Falha na execução do X-Adm. |
999 |
Conflito de transação. |
7. Divergências conscientes da implementação (Fase 2, 2026-07-13)¶
Onde o TransformService diverge deste documento — de propósito, porque o contrato real do
Integrador e o modelo de dados normalizado mandam no que é possível/aceito:
CodPropé sempre" 0". A regra "endereço diferente →1 + maior CodProp" (§2) pressupõe múltiplos endereços por documento; opied_clienteguarda uma linha por documento (chave natural), então a regra não tem como disparar. Se a Maxsul precisar de múltiplos endereços, o modelo normalizado muda primeiro.DescCompldo Kit (§4.2) não é enviado. A tabelaitenspeddo Integrador não tem essa coluna; a classificação de potência do Kit vive emestoque.CodProdAlt(20000/20001), então nada se perde.- DDD/Telefone por dígitos, não por parênteses. O §2 supôs
(42) 99999-0000; o dado real é corrido (5542999990000, DDI55opcional). O de-para descarta o DDI e fatia DDD (2) + número. PrazoeEAN13em branco (a PIED não os expõe) — regra "branco não nulo".- Rateio do frete: default = proporcional ao valor (§4, opção A), configurável para "total no 1º
item" (opção B) via
pied.integracao.transform.ratear-frete=false.
Pendências e decisões em aberto¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-12
O que ainda depende de terceiros (PIED / X-Adm) para fechar. Itens já decididos saíram daqui — o conhecimento ficou destilado na doc do assunto (Modelagem, Mapeamento, API do Integrador, Tratamento de dados no ZIM, decisões).
Dono: 🔌 PIED · 🧮 X-Adm/ERP.
P1 — Quais dealStatus marcam "enviar ao X-Adm" 🔌 · bloqueia o quando enviar (Fase 2)¶
O X-Adm só faz INSERT de pedido (nunca UPDATE), então só se envia pedido em estado que já conta
como venda. Campo identificado nos dados reais (14.300 pedidos capturados, jul/2026): é o
dealStatus — e o requestStatus veio com distribuição idêntica (espelho; usar dealStatus).
stockStatus (removed/none/reserved) não serve de gate. Valores observados, agrupados:
| Grupo | Valores (dealStatus) |
qtd | Enviar? |
|---|---|---|---|
| Finalizado | Finalizado (8269), Finalizado Garantia/Avaria (312) |
8581 | ✅ provável |
| Despachado | Despachado (2727), Despachado Garantia/Avaria (63) |
2790 | ✅ provável |
| Pago / em produção | Pedido Pago - Liberado para Produção (40), OP Gerada (34), NF Faturamento Banco - Financiamento (13), Nota Futura Emitida (7), Pedido Pago - Venda Futura (2) |
96 | 🟡 decidir |
| Aguardando | Aguardando Transportadora Coletar (55), Aguardando Controladoria (22), Aguardando Financiamento (2) |
79 | 🟡 provavelmente não |
| Novo | Novo (39) |
39 | ❌ não |
| Recusado | Recusado Falta de Pagamento (2230), Recusado Alterar Informações (433), Recusado Desistência (45), Recusado Devolução/Estorno (7) |
2715 | ❌ não |
Decisão (2026-07-13): âncora do gate = Pedido Pago - Liberado para Produção — o pedido é lançado
ao X-Adm ao atingir esse marco.
✅ RESOLVIDA (2026-08-07) — Maxsul confirmou, e o gate MUDOU de eixo: não é mais por
dealStatus. Análise dos pedidos reais mostrou quedealStatusnão distingue pago de não-pago (ex.: "OP Gerada" aparece pago e não-pago) e que a PIED tem um campopayment.statusdedicado (received,notRequested,requested,partial,cancelled). O gate passou a ser a transição de pagamento: envia quando um pedido que já tínhamos não-pago recebe um update compayment.status='received'. Detectada por evento no upsert (payment_status/payment_status_anterior).estados-vendanão governa mais o envio (sobrou como filtro do "Puxar da PIED"). Verdocs/glossario.md(Gate) eIntegracaoConfig.
Ponto de atenção — o furo da 1ª captura (trade-off da transição): o gate é a transição para
received, então precisa de um estado anterior não-pago que a gente tenha testemunhado. Um pedido que
aparece já pago na 1ª captura (sem anterior) não dispara automaticamente — é intencional (pode ser
pedido antigo da PIED chegando agora; evita despejar backlog). O operador manda esses pelo /console
(Enfileirar exige payment_status='received'). Como o X-Adm só faz INSERT, envia uma vez. Ver
decisão 0001 (design
detalhado na spec 002 da linhagem .ia/).
⚠️ COBRADO NA PRÁTICA (2026-09-21) — o furo custou quatro pedidos. No apagão de webhook de 18/09/2026 (a PIED desativou o webhook após timeout; 2h44 de silêncio no pico da sexta), quatro pedidos nasceram e foram pagos na janela cega. O poll REST de backstop os trouxe já pagos — sem transição a testemunhar, encalharam em
CAPTURADOe ninguém viu até a segunda. A válvula manual não basta: ninguém olha o console sem saber que há o que olhar. A decisão 0023 fecha os dois lados — watchdog com régua comercial (45 min, seg–sex 8h–18h) para o apagão virar visível, e resgate automático da 1ª captura já paga, cercado pororderCreated(piso de ativação + janela de 7 dias). Nasce desligado: ligar é pôrPIED_GATE_PRIMEIRA_CAPTURA_DESDEno deploy.
P2 — De-para restante 🧮 · não bloqueia o esqueleto da Fase 2¶
Já resolvidos (movidos ao Mapeamento): TpVda←freight.type,
TaxaFrete←freight.price, InscEst←companies.stateInscription. Faltam quatro:
ClassForma (forma de pagamento)¶
Catálogo X-Adm: 01 Dinheiro · 02 Cheque · 03 Cartão de Crédito · 04 Cartão de Débito · 05
Crédito da Loja · 15 Boleto · 16 Depósito · 17 PIX · 18 Transferência · 90 Sem Pagamento ·
99 Outros. Valores reais de payment.type (14.300 pedidos, jul/2026) e o de-para proposto:
payment.type |
qtd | → ClassForma |
obs |
|---|---|---|---|
boleto |
6950 | 15 Boleto |
✅ |
pix |
6201 | 17 PIX |
✅ |
credito |
379 | 03 Cartão de Crédito |
payment.condition.name = "Cartão 3x/6x/10x" (parcelado) |
financiamento |
711 | ? a decidir | sub-tipos em condition.name: BV, Santander, "outros". Sem código X-Adm direto → 99? código novo? |
other |
41 | 99 Outros |
fallback |
- A (recomendada): de-para configurável (
boleto→15,pix→17,credito→03,other→99), default99p/ tipo não mapeado — ajustável sem recompilar. - A fechar com a equipe: o código de
financiamento(não há equivalente óbvio no catálogo).
Implementado (Fase 2): de-para configurável em
pied.integracao.transform.class-formacom esses defaults;financiamento→99é o default até a equipe decidir. Falta só a confirmação do código.
Prazo (data de entrega)¶
payment.condition ("30/60 dias") é condição de pagamento, não data de entrega. A PIED
aparentemente não tem data de entrega. A confirmar com a PIED; enquanto isso, branco
(regra "branco não nulo").
TaxaFrete (rateio)¶
freight.price é do pedido (valor único). Para gravar por item:
- A (recomendada): ratear proporcional ao valor de cada item.
- B: lançar o total no primeiro item.
EAN13¶
Não vem da API de produtos → branco; enriquecer de outra fonte depois, se necessário.
P3 — Contrato de entrada do zimrtmu 🧮 · bloqueia a gravação real (ps-java)¶
Como o cliente Java grava no ZIM. Ver Tratamento de dados no ZIM e decisão 0003.
| A confirmar com a equipe X-Adm |
|---|
Recebe o valor-armazenado ou o mascarado? Formato de ingestão (JSON, string ;)? |
| Largura / escala / padding por campo das cinco tabelas. |
Interino: o ps-java segue como exemplo simulado (simulaXadm) até o contrato; a formatação
por tipo já está especificada para a equipe.
P4 — Gate de identidade da segurança 🧮 · bloqueia fechar as telas administrativas¶
O int-pied roda com security.enabled: false (Fase 1 por design — decisões
0001, 0004,
0005): /console, /captura, /destinatarios e o
/admin/mensageria que veio da lib xadm-mensageria (decisão 0012)
nascem abertos. Ligar xadm-seguranca (Firebase idToken + sessão JWT, como o bi-transporte-xls)
é trabalho pequeno.
Decidido (2026-09-12) — quem loga e o que fica público, para quando fechar:
| Rota | Acesso |
|---|---|
GET / (painel) e GET /pedidos/{code} |
público |
POST /webhook/pied |
público (autenticado pelo segredo do webhook) |
todo o resto — ações do painel (POST /pedidos/{code}/importar, /dispensar, /reimportar, /importado-manual), /console, /captura, /dados, /destinatarios, /admin/mensageria, /test |
login Google restrito a @xadm.com.br (xadm-seguranca, AUTH_XADM_EMAIL_DOMAIN, default xadm.com.br) |
Operador da Maxsul não loga nesta etapa.
Interino (decisão do dono): tudo aberto enquanto a implantação está em fase final. O deploy é público
(https://pied.maxsul.xadm.biz) — as telas não expõem segredo, mas expõem ações (enfileirar, reenviar,
reconciliar). Risco aceito conscientemente, não esquecido.
Reavaliar a cada release: o CLAUDE.md do repo manda o agente perguntar ao dono, antes de cada release,
"a implantação terminou? fechar as telas agora?". Fechar = ligar micronaut.security, configurar auth.* e as regras de rota acima, e provar no e2e
(rota pública responde sem sessão; rota fechada redireciona para o login).
Resumo — o que trava o quê¶
| Para desenvolver… | Precisa antes |
|---|---|
| maxsul-pied Fase 1 (captura raw) | ✅ pronta (em produção) |
| maxsul-pied Fase 2 (transform + push) | ✅ implementada e em produção (2026-08-07). P1 resolvida (gate = transição de pagamento payment.status→received); resta P2 código do financiamento (default configurável cobre) |
| Integrador | ✅ colunas de venda + fones + INT-PIED em KNOWN_ORIGENS + GET …/retorno já no repo do Integrador |
| ps-java (gravação real) | P3 (zimrtmu) — até lá, exemplo simulado |
| PowerSync | pronto (sync rules já definidas) |
| Fechar as telas administrativas | P4 decidida (só @xadm.com.br); aguarda o fim da implantação — até lá tudo aberto |
Operação
Implantação — integração PIED → X-Adm (Maxsul)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15
O que é¶
Guia de implantação e operação da vertical que leva pedidos, clientes e produtos da PIED (sistema do cliente Maxsul) ao X-Adm, via Integrador. Cobre o que subir, com que variáveis, como conferir que subiu e — o que mais importa no pior dia — como desligar.
O desenho (arquitetura, fluxo de captura, máquina de status, modelo de dados) é do livro: Documentação Completa. Aqui não se re-digita. A referência de cada variável, uma a uma, é de Configuração; aqui está o que o operador precisa na ordem em que implanta.
Quando usar¶
- Ao provisionar o
maxsul-piedem produção (Coolify) pela primeira vez. - Ao atualizar a versão implantada.
- Ao ligar a Fase 2 (a integração nasce desligada — decisão 0001).
- Ao precisar parar o envio ao X-Adm sem derrubar a captura (§ Reversão).
Pré-requisitos¶
- Postgres compartilhado com o Integrador provisionado e acessível.
- DNS de
pied.maxsul.xadm.bizapontando para o Coolify. - Integrador implantado e alcançável, com o
INTEGRADOR_API_TOKENà mão. - Token da API da PIED e acesso ao painel dela (o webhook é cadastrado à mão lá).
- Secrets do §Variáveis à mão (cofre da X-Adm).
Topologia de operação¶
O que sobe, onde, e o que cada peça precisa. O porquê desta topologia (por que captura raw primeiro, por que caminho robusto) está no livro e na decisão 0002.
%% caption: O que se implanta — um serviço nosso, um banco compartilhado, três externos
flowchart TB
PIED["API + Webhook da PIED<br/>EXTERNO — fora do nosso controle"]
APP["maxsul-pied<br/>pied.maxsul.xadm.biz<br/>(recurso Coolify)"]
DB[("Postgres<br/>COMPARTILHADO com o Integrador")]
INT["Integrador<br/>(outro app X-Adm)"]
RESEND["Resend<br/>EXTERNO — e-mail de alerta"]
PIED -->|"POST /webhook/pied"| APP
APP -->|"poll GET (token)"| PIED
APP --> DB
APP -->|"PUT/DELETE /api/v1/xadm (Bearer)"| INT
APP -->|"POST /emails"| RESEND
INT --> DB
| Peça | Onde | Auth | Quem é dono |
|---|---|---|---|
maxsul-pied |
recurso Coolify, pied.maxsul.xadm.biz |
nenhuma — telas e rotas abertas, atrás da rede | este repo |
| Postgres | compartilhado com o Integrador | usuário por app | Integrador (dono do schema) |
| Integrador | outro recurso Coolify | Bearer em /api/v1/** |
repo integracao/integrador |
| API/Webhook da PIED | externo | token (poll) / segredo (webhook) | PIED (terceiro) |
| Resend | externo | API key | terceiro |
Duas instâncias no mesmo banco (jar + native) são seguras
O app é scheduler/poller; sob 2 instâncias lado a lado (meta 50/50 jar/native) os
@Scheduled são serializados por advisory lock — só uma varre por tick, a outra coalesce, e
o lock solta sozinho na queda (HA/failover). Detalhe e nomes de lock em
decisão 0014. A entrega M2M já é
N-instâncias-safe por conta própria. O pipeline.yml já builda e deploya os dois flavors
(build_jar + build_native, build.targets=['jar','native']; decisão 0015).
O native está no ar desde a v0.7.0 (2026-09-02): o recurso Coolify native existe e todo
release desde então sai com o trailer Deploy: jar,native. O boot do binário native (revela
config de reflexão/recurso faltando, que o build não pega) é gateado pelo HEALTHCHECK do
deploy — container native que não sobe não é promovido pelo Coolify.
SENTRY_DSN no native é ENV do recurso, não ponte no Dockerfile. Só o flavor jar lê o
app.json com jq no entrypoint; o Dockerfile.native não tem essa ponte — é o desenho do
template da casa (binário sem sh/jq no ENTRYPOINT). Então o DSN tem de estar cadastrado
como variável de ambiente no recurso Coolify native; sem ela, o native roda cego no
GlitchTip — mesmo com features.glitchtip.enabled: true no app.json.
O banco é compartilhado — o Flyway deste app usa histórico próprio
Os dois apps versionam migrations sobre o mesmo schema public e ambos têm V1/V2/….
Por isso o maxsul-pied grava em flyway_schema_history_pied e o Integrador fica com a
tabela default (application.yml). Apontar este app para a tabela default faria os dois
históricos colidirem.
Contrato de entrada¶
Quem alimenta o sistema é a PIED, por duas vias independentes: o webhook (push) e o poll da API REST (pull, backstop). A forma completa é de API da PIED — não se copia aqui.
O webhook não é registrado por código: é cadastrado à mão no painel da PIED
(Configurações → API e Webhooks → Webhooks), apontando para
https://pied.maxsul.xadm.biz/webhook/pied.
A confirmar com a PIED — como o segredo do webhook viaja
O maxsul-pied valida o segredo no header X-Pied-Secret (WebhookConfig.HEADER_SEGREDO).
Esse nome é suposição nossa: a PIED nunca confirmou a forma de envio
(anexo do webhook §Segurança). Fixture nossa não confirma
contrato de terceiro.
Efeito de errar: se a PIED mandar o segredo com outro nome de header, todo POST responde
401 e nada é gravado — e a perda é silenciosa: o log é warn, não error, então
não sobe alarme no GlitchTip. O poll (se ligado) mascara, trazendo os mesmos pedidos
atrasados; com o poll desligado (o default), a captura simplesmente para.
Enquanto não confirmado: opere com PIED_WEBHOOK_SECRET vazio — os headers recebidos
ficam em pied_webhook.headers para diagnóstico, e é assim que se descobre o nome real.
Atenção à assimetria: segredo errado falha fechado (401, nada entra); segredo vazio
falha aberto — aceita qualquer POST sem validar (WebhookController).
O contrato de saída (o que este app grava no X-Adm) é fato do Integrador, importado do
raw/ dele: API do Integrador.
Superfície de operação¶
O que o operador olha, depois de implantado (tudo aberto, sem login — ver § Reversão):
https://pied.maxsul.xadm.biz/— painel do colaborador Maxsul: pedidos por status amigável..../console— console do operador: máquina de status, e os botões que agem (Enviar, Reenviar, Enviar todos, Atualizar, Puxar da PIED)..../capturae.../captura/json— a captura crua, para diagnóstico..../dados/*— browser read-only das tabelaspied_*..../destinatarios— quem recebe o e-mail de erro terminal..../health→{"status":"UP","versao":"X.Y.Z"}.
Variáveis de ambiente¶
Um recurso só (maxsul-pied). Segredos via secrets do Coolify, nunca no repo. Referência
campo a campo em Configuração; aqui, o que decide a implantação.
Toda mudança de env exige restart do container: não há @Refreshable nem /refresh no app —
os @ConfigurationProperties são preenchidos na criação do contexto. Isto vale inclusive para
os toggles de § Reversão.
maxsul-pied — pied.maxsul.xadm.biz¶
| Variável | Valor / descrição |
|---|---|
CLIENTE |
Maxsul (nome no cabeçalho das telas) |
PORT |
8080 (default) |
DATASOURCES_DEFAULT_URL |
JDBC do Postgres compartilhado com o Integrador |
DATASOURCES_DEFAULT_USERNAME |
usuário deste app |
🔑 DATASOURCES_DEFAULT_PASSWORD |
segredo |
PIED_BASE_URL |
https://backend-pied-prod.piedadmin.com.br/api (default) |
🔑 PIED_TOKEN |
segredo — token da API da PIED. Vazio: o poll não roda (só um warn) |
PIED_POLL_HABILITADO |
default false — ligar exige decisão consciente |
PIED_POLL_INTERVALO |
6h |
PIED_WEBHOOK_HABILITADO |
default true |
🔑 PIED_WEBHOOK_SECRET |
segredo — ver § Contrato de entrada (vazio = aceita qualquer POST) |
PIED_INTEGRACAO_HABILITADA |
default false — a Fase 2 nasce desligada (decisão 0001) |
PIED_INTEGRACAO_MODO |
PRODUCAO (default) ou STAGING — decisão 0004 |
PIED_INTEGRACAO_INTERVALO |
5m (transform/push) |
PIED_INTEGRACAO_RECONC_INTERVALO |
5m (reconciliação) |
INTEGRADOR_BASE_URL |
base do Integrador. Vazio: toda saída vira ERRO tratado |
🔑 INTEGRADOR_API_TOKEN |
segredo — ver § Pares |
PIED_RETENCAO_HABILITADO |
default true — expurgo do raw |
PIED_RETENCAO_DIAS |
7 |
PIED_ALERTA_HABILITADO |
default false |
🔑 RESEND_API_KEY |
segredo |
PIED_ALERTA_REMETENTE |
remetente do e-mail de alerta |
PIED_ALERTA_BASE_URL |
URL pública do app, p/ o link no corpo do e-mail |
SENTRY_DSN |
ENV do recurso native, com o valor de features.glitchtip.dsn do docs/app.json — ver o aviso do native acima. Sem ela o app roda cego no GlitchTip. GLITCHTIP_DSN não vale (a xadm-comum-web 0.10.0 lê só o SENTRY_DSN) |
Pares que têm de casar¶
Valor que existe nos dois lados e diverge em silêncio é falha de implantação que só aparece em produção. Sintomas conferidos no código:
| Ponta A (aqui) | Ponta B | Sintoma se divergirem |
|---|---|---|
INTEGRADOR_API_TOKEN |
INTEGRADOR_API_TOKEN do Integrador |
PUT responde 401/403, aborta sem retentar → pedido vira ERRO com "Autenticação falhou no Integrador". Visível no console e reenviável. Mas na reconciliação vira FALHA_CONSULTA e o pedido segue ENVIADO — só um warn: a reconciliação trava em silêncio |
PIED_WEBHOOK_SECRET |
segredo cadastrado no painel da PIED | 401, nada gravado, sem alarme (é warn) — ver § Contrato de entrada |
🔑 PIED_TOKEN |
token do painel da PIED | Vazio → poll não roda (warn). Inválido → LOG.error com stacktrace (sobe ao GlitchTip) e a rodada segue nas outras entidades |
INTEGRADOR_BASE_URL |
URL real do Integrador | Vazio → IntegradorException "Integrador não configurado" antes de tocar a rede → pedido vira ERRO |
Passos de implantação¶
- Banco. Garantir o Postgres compartilhado acessível; criar o usuário deste app. O Flyway
roda no start e usa
flyway_schema_history_pied(§ Topologia). - App. Deploy deste repo no Coolify em
pied.maxsul.xadm.biz; setar as ENV do §Variáveis. DeixePIED_INTEGRACAO_HABILITADA=falsenesta etapa — sobe capturando só (decisão 0001). - Webhook na PIED. Cadastrar
https://pied.maxsul.xadm.biz/webhook/piedà mão no painel da PIED, com os gatilhosbudget.*/order.*. Segredo: ver o aviso do § Contrato de entrada. - Conferir a captura (§ Verificação, passos 1–3) antes de ligar a Fase 2.
- Ligar a Fase 2, quando for a hora:
PIED_INTEGRACAO_HABILITADA=true+INTEGRADOR_BASE_URL INTEGRADOR_API_TOKEN+ restart. ConsiderePIED_INTEGRACAO_MODO=STAGINGna estreia — a entrega fica represada emNA_FILAe só sai por clique no console (decisão 0004).- Alerta (opcional):
PIED_ALERTA_HABILITADO=true+RESEND_API_KEY+ remetente, e cadastrar os destinatários em/destinatarios.
Verificação¶
Todo passo declara o resultado esperado, conferido no código — não no que parece razoável.
GET /health→{"status":"UP","versao":"X.Y.Z"}. É"UP", maiúsculo (status canônico da casa) — o shape é plano{status, versao}(norma da casa), servido pela libxadm-comum-web.- Webhook desligado (
PIED_WEBHOOK_HABILITADO=false) →POST /webhook/piedresponde503comapplication/problem+jsonecode: WEBHOOK_DESABILITADO, e não grava nada (o guard vem antes do insert emWebhookController). - Webhook ligado → um evento real da PIED aparece em
SELECT evento, recebido_em FROM pied_webhook ORDER BY recebido_em DESC LIMIT 10;, e em/captura. As credenciais dos headers são gravadas mascaradas (guardado porWebhookControllerTest). - Segredo errado →
401+code: SEGREDO_INVALIDO, nada gravado, e nenhum evento no GlitchTip (éwarn). Se você esperava alarme, ele não vem — é este o ponto cego. - Fase 2 ligada → um pedido elegível sai de
CAPTURADOe chega aENVIADO/CONFIRMADOno/console. EmMODO=STAGINGele para emNA_FILAe só sai por clique — se sair sozinho em STAGING, o modo não está valendo (confira que houve restart). - Idempotência. Reenviar o mesmo estado (sem mudar valores) → o envio é pulado, não
duplicado: o
PushServicecompara ocontent_hashdo payload com o gravado e só reescreve em mudança real. (Aqui a idempotência é por conteúdo, não por timestamp de escrita: repetir o envio de estado idêntico não gera linha nova. Se gerar, o content-hash quebrou.)
Pedido travado no X-Adm — como conferir¶
O X-Adm rejeita filho sem pai de forma terminal (cod_retorno 1XX não volta sozinho), e
até 2026-09 este app só olhava o retorno do contratos — um pedido podia constar importado com
os itens recusados. Hoje a reconciliação fecha pela remessa inteira
(decisão 0021).
| onde olhar | o que significa |
|---|---|
| painel, bucket "Falha ao importar" | o pedido tem remessa TRAVADA — alguma linha em 1XX |
msg_retorno do pedido |
a causa: é a mensagem do item que não está bloqueado |
GET /api/v1/integracao/remessas?origem=INT-PIED&status=TRAVADA no Integrador |
a lista completa, com tabela, codRetorno e bloqueado de cada item |
Ler o bloqueado importa. Item bloqueado é colateral de um pai morto, não a causa: no
260079421 o contrato levou 120 "Cliente não cadastrado" e o item levou 120 "Pedido não
cadastrado" — duas mensagens, uma causa. Corrigir o item não resolve nada; corrigir o cliente
resolve os dois.
Como se resolve uma Falha. Lançando à mão no X-Adm o que o diagnóstico do detalhe aponta em
"NÃO gravado" e marcando Importado manualmente — o X-Adm é caminho só de ida, e o que ele gravou
não volta (não excluir nada que esteja em "Gravado"). Dado novo da PIED não devolve à fila um
pedido em Falha; só o ERRO de envio volta sozinho.
Quando clicar Reimportar. Só quando o botão aparece: parte do pedido ainda está pendente,
travada por um pai rejeitado (ex. cliente não cadastrado) — aí lançar à mão duplicaria, e o
"Importado manualmente" responde 409. Corrija o pai (no X-Adm ou na PIED) e clique. O botão
enfileira; o re-arme da linha no espelho sai junto com a entrega, não no clique — se saísse
antes, o cliente do ERP poderia coletar a linha com o dado velho.
Se o pedido fica ENVIADO sem remessa por mais de 30 min, o log emite um WARN — é sinal de que
o push não virou manifesto no Integrador, e o app não muda o status por conta própria.
Smoke pós-deploy — quem confere isso sozinho¶
Os passos 1–6 acima são a conferência manual. Desde a constituição 1.4.1 o pipeline roda um
smoke contra produção depois de todo deploy (decisão 0020,
norma smoke-producao), porque o
POST /api/ci/deploy é assíncrono: sem ele o CI ficava verde antes de o container novo servir.
| camada | o que afirma |
|---|---|
| identidade | o commit do /health é o sha desta entrega (não o do container velho) |
| rotas | as seis rotas de docs/app.json → smoke.routes respondem 200, sem seguir redirect |
| log-guarantee | nenhuma exceção nova no GlitchTip desde a entrega (reincidência avisa, não reprova) |
| veredito | reprovou → reverte para <imagem>:<sha anterior>-<target>, confirma, e fica vermelho |
Duas consequências operacionais:
GLITCHTIP_API_TOKEN(read-only) tem de existir nos secrets do repo no GitHub. Oapp.jsondeclarasmoke.glitchtip, e declarar sem o secret reprova o smoke — de propósito: o modo de falhar caro não é o vermelho, é o verde provando menos do que diz.- Não pode podar a tag imutável
<imagem>:<sha>-<target>que está no/healthde um recurso em produção: ela é o alvo do rollback. Retenção mínima: as 10 últimas por (imagem, target).
O rollback do smoke troca a imagem. É coisa diferente da tabela abaixo, que para o envio ao X-Adm com o mesmo container de pé.
Reversão¶
Parar o envio ao X-Adm sem derrubar a captura. Leia a tabela antes de agir: este sistema tem mais de uma via de saída, e o toggle que parece o kill switch não corta todas.
PIED_INTEGRACAO_HABILITADA=false NÃO é kill switch da saída
O toggle é consultado em três pontos — TransformJob, ReconciliacaoJob e
IntegradorClientFactory (este só decide logar um WARN). Nenhum botão do console o
consulta, e o PushService não consulta toggle nenhum. Com a integração "desligada", quem
abrir /console e clicar Enviar envia ao X-Adm — e o /console não tem autenticação.
| Caminho | Efeito |
|---|---|
| Parar o container (Coolify) | Para tudo. Mais simples e garantido — prefira este |
PIED_INTEGRACAO_HABILITADA=false |
Para o TransformJob (gate, normalização, push automático) e a reconciliação agendada. ⚠️ Não para os botões do console: Enviar, Enviar todos, Reenviar e Atualizar seguem saindo |
INTEGRADOR_BASE_URL="" |
Corta toda saída, inclusive os botões — falha antes da rede. Custo: cada tentativa marca o pedido como ERRO (reversível depois por Reenviar) |
PIED_INTEGRACAO_MODO=STAGING |
⚠️ Não é kill switch. Para só o drenarFila(); o gate → NA_FILA, a normalização, a reconciliação inteira e todos os botões continuam |
PIED_WEBHOOK_HABILITADO=false |
Para a entrada por webhook (503, sem gravar). ⚠️ Não para o poll (se ligado) nem os botões "Puxar da PIED"/"Sincronizar clientes" |
PIED_POLL_HABILITADO=false |
Para o poll agendado. ⚠️ Não para o webhook nem os botões de captura sob demanda — o CapturaSobDemandaService ignora os toggles e bate na PIED do mesmo jeito |
PIED_ALERTA_HABILITADO=false |
Para o e-mail automático de erro terminal. ⚠️ Não para o "Enviar teste" da tela /destinatarios, que ignora o toggle de propósito |
Corte garantido sem parar o container: PIED_INTEGRACAO_HABILITADA=false +
INTEGRADOR_BASE_URL="" + restart. O primeiro para o automático; o segundo fecha a porta dos
botões. Só o primeiro não basta enquanto o console estiver alcançável.
Em qualquer caso, a captura pode seguir rodando: o raw continua entrando e o atraso de entrega sai quando religar. Nada se perde por desligar a saída — é para isso que a captura vem primeiro (decisão 0001).
Reverter versão: redeploy da tag anterior (Coolify). As migrations são aditivas.
Configuração — env vars e toggles¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15
Toda a configuração é por variável de ambiente (no Coolify, nas envs do recurso). Não há
arquivo de configuração em produção; o .env.example na raiz do repo documenta o conjunto.
Banco de dados¶
| Variável | Obrigatória | Uso |
|---|---|---|
DATASOURCES_DEFAULT_URL |
Sim | JDBC do Postgres 18 (ex.: jdbc:postgresql://host:5432/maxsul_pied). |
DATASOURCES_DEFAULT_USERNAME |
Sim | Usuário do banco. |
DATASOURCES_DEFAULT_PASSWORD |
Sim | Senha do banco. |
As migrações (Flyway) rodam automaticamente no start — a V1__landing_pied.sql cria as três
tabelas landing. Requer Postgres 18+ (uuidv7() nativo).
PIED¶
| Variável | Default | Uso |
|---|---|---|
PIED_BASE_URL |
https://backend-pied-prod.piedadmin.com.br/api |
Base da API REST da PIED. |
PIED_TOKEN |
(vazio) | Token Bearer fornecido pela PIED. Sem ele, o poll loga aviso e não roda. |
PIED_WEBHOOK_SECRET |
(vazio) | Segredo compartilhado do webhook (header X-Pied-Secret). Vazio = aceita sem validar (MVP). |
Toggles¶
| Variável | Default | O que liga |
|---|---|---|
PIED_WEBHOOK_HABILITADO |
true |
Recepção do POST /webhook/pied. Desligado → 503. |
PIED_RETENCAO_HABILITADO |
true |
Job de limpeza do landing raw (pied_rest/pied_webhook). |
PIED_RETENCAO_DIAS |
7 |
Janela de retenção do raw — linhas mais antigas são apagadas. O dado útil vive nas tabelas normalizadas (guardam o próprio payload). |
PIED_RETENCAO_INTERVALO |
24h |
Intervalo do job de retenção (Duration). |
PIED_WATCHDOG_HABILITADO |
true |
Vigia o silêncio do webhook: alerta no GlitchTip por duas réguas, a que vier primeiro. Modo de falha: a PIED desativa o webhook após uma falha de entrega (404 ou timeout) e não volta sozinha — ver recadastro do webhook, decisão 0016 e 0023. |
PIED_WATCHDOG_LIMITE_MIN_COMERCIAL |
45 |
Régua rápida: minutos de silêncio dentro do comercial (seg–sex, faixa abaixo) antes de alertar. Calibrado no histórico real — o maior silêncio legítimo medido foi 37 min. 0 desliga só esta régua. |
PIED_WATCHDOG_HORA_INICIO |
8 |
Abertura do comercial da Maxsul (hora local, 0–23). |
PIED_WATCHDOG_HORA_FIM |
18 |
Fechamento do comercial (hora local, 1–24). |
PIED_WATCHDOG_LIMITE_HORAS |
24 |
Rede de fora do horário: limite de horas úteis (dia útil inteiro, seg–sex; sáb/dom não contam) sem webhook antes de alertar. |
PIED_WATCHDOG_INTERVALO |
5m |
Intervalo do job de verificação (Duration). Tem que ser bem menor que a régua comercial. |
PIED_GATE_PRIMEIRA_CAPTURA_DESDE |
(vazio) | Resgate da 1ª captura já paga (decisão 0023): data de ativação AAAA-MM-DD. Pedido visto já pago na primeira captura — webhook perdido num apagão, poll REST o alcança depois de pago — vai direto para NA_FILA se foi criado depois desta data. Vazio desliga (default): só o Enfileirar do console resgata. A data é o piso que impede o deploy de despejar legado. |
PIED_GATE_PRIMEIRA_CAPTURA_JANELA_DIAS |
7 |
Idade máxima do orderCreated para o pedido contar como novo. Cobre apagão que atravessa o fim de semana sem alcançar o backlog antigo da PIED. |
PIED_POLL_HABILITADO |
false |
Job agendado de poll REST (3 entidades → pied_rest). |
PIED_POLL_INTERVALO |
6h |
Intervalo entre rodadas do poll (Duration: 6h, 1h, 30m...). Produtos/clientes re-varrem tudo a cada rodada (sem cursor incremental) e o landing (pied_rest) é append-only sem dedup — intervalo curto incha o raw; 6h dá 4 backstops/dia (o webhook cobre o tempo-real). |
PIED_POLL_LIMITE_PAGINA |
50 |
Tamanho de página nas chamadas REST (máximo aceito pela PIED: 50). |
PIED_POLL_ENRIQUECIMENTO |
true |
Varre produtos/clientes na rodada periódica (re-varre tudo — sem cursor incremental). Clientes/produtos chegam por webhook (order.* + company.* que traz InscEst); com false, o poll periódico fica só nos pedidos (backstop) e para de inflar o landing. O backfill sob demanda ("Sincronizar clientes") segue funcionando. |
PIED_INTEGRACAO_HABILITADA |
false |
Fase 2 (transform → push ao Integrador). Ligado: o TransformJob normaliza, aplica o gate e (em PRODUCAO) envia; a reconciliação do retorno roda. Desligado: só captura raw. |
Fase 2 — transform + push ao Integrador¶
| Variável | Default | Uso |
|---|---|---|
PIED_INTEGRACAO_MODO |
PRODUCAO |
PRODUCAO envia automático assim que o pedido fica elegível; STAGING deixa NA_FILA até o clique no /console (decisão 0004). |
PIED_INTEGRACAO_INTERVALO |
5m |
Intervalo do job de normalização+gate (+push em PRODUCAO). Duration. |
PIED_INTEGRACAO_RECONC_INTERVALO |
5m |
Intervalo do job de reconciliação do retorno do X-Adm. Duration. |
MENSAGERIA_RELAY_ENABLED |
true |
Agendador do relay da lib (xadm-mensageria), que drena a fila de saída a cada 2 min. Desligar para a entrega ao Integrador (a fila acumula; nada se perde). Nasceu false enquanto a lib não elegia réplica e um job local fazia a guarda — histórico na decisão 0014. |
MENSAGERIA_RELAY_CLUSTER_LOCK |
true |
Eleição de réplica por tick (pg_try_advisory_lock), desde a lib 0.3.4. Desligar devolve a entrega dobrada sob as duas instâncias (jar + native) — medido, 92 PUT para 52 payloads distintos em 24h. Escotilha de diagnóstico; nunca em produção. |
INTEGRADOR_BASE_URL |
(vazio) | Base do Integrador (ex.: https://integrador.xadm.biz). Vazio com integração habilitada: log WARN no startup e cada envio/reconciliação vira ERRO tratado ("Integrador não configurado") — não crash. Configure para operar. |
INTEGRADOR_API_TOKEN |
(vazio) | Bearer estático do Integrador (Authorization: Bearer …). |
Cada rodada do job começa pelo enriquecimento sob demanda: havendo pedido NA_FILA sem invoice
(capturado só por webhook), ele busca a janela desse pedido no REST e a normalização da mesma rodada
o destrava — sem isso a espera seria a do poll (PIED_POLL_INTERVALO, 6h). Exige PIED_TOKEN; sem
preso não faz chamada nenhuma. Desenho, limites e diagnóstico do preso residual na
decisão 0017.
O gate de envio é a transição de pagamento (payment.status → received), detectada por
evento no upsert de pied_pedido (Maxsul, 2026-08-07). pied.integracao.estados-venda não governa
mais o envio — sobrou só como filtro do "Puxar da PIED" (captura sob demanda do console). O de-para de forma de
pagamento por pied.integracao.transform.class-forma (default financiamento→99). Só se ajustam via
application.yml/env estruturada; os defaults cobrem o caso comum (mapeamento,
pendências).
Como habilitar o poll¶
- Obter o token de produção da PIED e definir
PIED_TOKEN. - Definir
PIED_POLL_HABILITADO=true(e o intervalo desejado emPIED_POLL_INTERVALO). - Redeploy/restart. A primeira rodada ocorre ~1 minuto após o start; pedidos começam com carga
completa (cursor vazio) e as rodadas seguintes usam
?lastUpdateAftera partir depied_cursor. - Conferir:
SELECT entidade, count(*) FROM pied_rest GROUP BY 1;e o cursor empied_cursor.
Como habilitar a integração (fase 2)¶
- Definir
INTEGRADOR_BASE_URLeINTEGRADOR_API_TOKEN(Bearer estático do Integrador). - Confirmar o de-para e o conjunto de estados do gate com a Maxsul (pendências P1/P2); ajustar
pied.integracao.estados-venda/transform.class-formase preciso — os defaults cobrem o comum. - Recomendado no 1º deploy:
PIED_INTEGRACAO_MODO=STAGING— os pedidos elegíveis ficamNA_FILAe você confere e solta pelo/console(puxar → conferir de-para → enfileirar → enviar → atualizar) antes de deixar automático. Validado, mudar paraPRODUCAO. PIED_INTEGRACAO_HABILITADA=true+ redeploy. Não há despejo do histórico: o gate é a transição de pagamento (só entra o pedido que passa areceivednum update depois do enable; o backlog já-pago não re-transiciona).
Alerta de erro terminal do pedido (e-mail via Resend)¶
Quando um pedido é rejeitado de forma irrecuperável pelo X-Adm na reconciliação (ERRO_XADM com
codRetorno 1XX terminal), os contatos cadastrados na tela /destinatarios recebem um e-mail
via Resend (decisão 0005). Erros
reprocessáveis (ERRO de transporte, ERRO_XADM 9XX) e de sistema (esses vão ao GlitchTip) não
disparam. O envio é best-effort: se falhar, apenas loga (LOG.error → GlitchTip) e a reconciliação
segue. Como depende da reconciliação, o alerta fica dormente até a Fase 2 ser ligada.
Um e-mail por pedido e código de retorno. O último código alertado fica gravado no pedido
(pied_pedido.alerta_cod_retorno), então reiniciar o app (deploy, reboot do servidor) não reenvia os
alertas de pedidos que já estavam travados. O pedido volta a alertar quando o código muda, quando ele é
confirmado e depois trava de novo, ou quando o operador o reenfileira.
| Variável | Default | Uso |
|---|---|---|
PIED_ALERTA_HABILITADO |
false |
Liga o envio do alerta. Desligado (ou sem api-key/remetente) = no-op silencioso. |
RESEND_API_KEY |
(vazio) | Chave da API do Resend. Vazio = não envia. |
PIED_ALERTA_REMETENTE |
(vazio) | Remetente (from) do e-mail. O domínio precisa estar verificado no painel do Resend, senão o envio é recusado. |
PIED_ALERTA_BASE_URL |
(vazio) | URL pública do app p/ montar o link /pedido/{code} no corpo do e-mail (ex.: https://pied.maxsul.xadm.biz). |
Para ativar: (1) criar a conta/domínio no Resend e verificar o domínio do remetente; (2) definir
RESEND_API_KEY, PIED_ALERTA_REMETENTE e PIED_ALERTA_BASE_URL; (3) cadastrar os destinatários em
/destinatarios; (4) PIED_ALERTA_HABILITADO=true + redeploy.
Testar o envio: na tela /destinatarios, cada contato tem um botão "Enviar teste" que dispara um
e-mail de teste àquele endereço. O teste ignora o toggle PIED_ALERTA_HABILITADO (é ação manual),
mas exige RESEND_API_KEY + remetente — sem eles, a tela avisa "Configure RESEND_API_KEY…". Serve de
smoke-test pós-deploy do caminho do e-mail antes de ligar o alerta automático.
Console operador (/console)¶
Superfície única de operação da Fase 2 (absorveu o antigo /inspetor e /fila): dirige cada pedido
pela máquina de estados real (pied_pedido.status:
CAPTURADO→NA_FILA→ENVIANDO→ENVIADO→CONFIRMADO | ERRO_XADM | ERRO).
| Rota | O que faz | Modo |
|---|---|---|
GET /console[?status=] |
Lista filtrável por status, com stepper. | ambos |
GET /console/{code} |
Detalhe: bloco 1 (origem), 2 (de-para campo a campo), 3 (status+retorno). Read-only. O bloco 2 é dry-run sobre o payload atual — pode divergir do que foi efetivamente entregue, se o payload evoluiu após o envio (caso 260081441). | ambos |
POST /console/puxar-pied?n={1\|5} |
Puxa 1/5 pedidos finalizados da PIED → CAPTURADO (só elegíveis). |
STAGING |
POST /console/{code}/enfileirar |
Gate manual CAPTURADO→NA_FILA — exige pagamento recebido (payment_status='received'); recusa não-pago. Cobre o pedido que veio já-pago na 1ª captura. |
STAGING |
POST /console/{code}/enviar · POST /console/enviar-todos |
Envia 1 / todos os NA_FILA ao Integrador. |
STAGING |
POST /console/{code}/atualizar |
Reconcilia o retorno do X-Adm agora (ENVIADO→CONFIRMADO/ERRO_XADM). |
ambos |
POST /console/{code}/reenviar |
Reenvia ERRO/ERRO_XADM 9XX; barra o 1XX terminal. |
ambos |
POST /console/{code}/renormalizar |
Re-normaliza do próprio payload (sem chamar a PIED). |
ambos |
POST /console/sincronizar-clientes |
Varre v1/companies → preenche InscEst. |
ambos |
Em STAGING o console é interativo (o pedido espera NA_FILA até o clique); em PRODUCAO é
monitor (o pipeline anda sozinho; ficam só as ações de exceção — Atualizar/Reenviar).
⚠️ Aberto (sem auth, como o
/captura) — expõe PII e tem ações ao vivo (push ao X-Adm, fetch na PIED). O gate por login das rotas de ação está deferido (hardening separado; nenhuma regressão vs. o antigo/fila, que já era aberto) — proteger antes de expor fora da rede (decisão 0004, L8).
Webhook — cadastro do lado PIED (manual)¶
O registro do endpoint não é automático: é feito à mão no painel da PIED (Configurações → API e Webhooks → aba Webhooks → Adicionar):
- Endpoint:
https://pied.maxsul.xadm.biz/webhook/pied. - Gatilhos: eventos de orçamento e pedido (
budget.*,order.*) — e o que mais o painel oferecer: o serviço aceita qualquer evento. - Autenticação: definir o segredo e replicá-lo em
PIED_WEBHOOK_SECRET. Enquanto a forma exata de envio não for confirmada com a PIED, pode-se operar sem segredo (vazio) — os headers recebidos ficam registrados empied_webhook.headerspara diagnóstico. - Conferir:
SELECT evento, recebido_em FROM pied_webhook ORDER BY recebido_em DESC LIMIT 10;
Health check¶
GET /health → {"status":"UP","versao":"X.Y.Z"} (versão da release, lida do
version.properties gerado no build; servido pela lib xadm-comum-web). Usado pelo healthcheck do
container e pelo Coolify/Traefik.
Observabilidade — GlitchTip (erros)¶
Todo log ERROR — inclusive exceções não-tratadas do Micronaut — sobe para o GlitchTip via
SentryAppender no logback.xml, sem instrumentar o domínio. Configurado pelo /xadm-setup
(features.glitchtip no app.json).
| Variável | Origem | Uso |
|---|---|---|
SENTRY_DSN |
ENV do recurso Coolify no native (o alvo de deploy): o binário não tem sh nem jq no ENTRYPOINT. Só a imagem jar (Dockerfile) lê docs/app.json (features.glitchtip.dsn) via jq no entrypoint. |
DSN do projeto no GlitchTip, com o mesmo valor do app.json. Só SENTRY_DSN: a xadm-comum-web 0.10.0+ não lê GLITCHTIP_DSN e, com só ele, sobe com o error tracking desligado e WARN no boot. Vazio/ausente = no-op (dev/local). |
O DSN é client-grade (público) — por isso vive no app.json versionado, não é segredo. O
SENTRY_AUTH_TOKEN (upload de sourcemap) não se aplica a backend JVM (não há sourcemap).
Smoke-test pós-deploy¶
POST /test/glitchtip emite um erro de propósito para confirmar que o DSN chega ao GlitchTip sem
esperar um erro real:
curl -s -X POST https://pied.maxsul.xadm.biz/test/glitchtip | jq .
# → {"status":"enviado", ...} e o evento aparece no projeto maxsul-pied no GlitchTip
⚠️ Aberto (sem auth —
micronaut-securityvem transitivo da libxadm-comum-webmas está desligado viamicronaut.security.enabled=false), no mesmo espírito do/captura. Fecha na Fase 2: ligar o filtro + gate por login, ou remoção.
Diagnóstico da captura (/captura)¶
Superfície de debug do que foi capturado da PIED, sem acesso direto ao banco (que só existe dentro do
servidor Coolify) — o análogo da tabela requests do Integrador.
| Rota | O que faz |
|---|---|
GET /captura[?fonte=] |
Tela HTML: timeline unificada webhook + páginas REST (mais recente primeiro). ?fonte=rest\|webhook\|ambos filtra a fonte (default ambos; valor inválido → ambos). |
GET /captura/{tipo}/{id} |
Payload cru (pretty) de uma captura; {tipo} = webhook|rest. |
GET /captura/json |
Dump JSON agregado das 3 tabelas (pied_webhook, pied_rest, pied_cursor) com o payload aninhado, para colar de uma vez. ?limite=N por tabela (default 1000, teto 5000). |
# no browser: https://pied.maxsul.xadm.biz/captura → clica numa linha p/ ver o raw
curl -s https://pied.maxsul.xadm.biz/captura/json | jq .webhook
⚠️ Aberto e expõe PII (CNPJ, contatos) — sem auth por enquanto, diagnóstico atrás da rede. O gate por login fica deferido para um hardening separado (mesmo espírito do console; decisão 0004).
Telas e navegação (UI)¶
As telas são server-rendered (JTE — templates compilados, decisão 0025) com o kit de UI da casa
(engenharia/java-micronaut §UI): o
kit/layout.jte e o public/css/custom-theme.css são templates rastreados — vêm do repo
central e não se editam por app (a /xadm-docs re-deriva quando a identidade muda). A composição
inverte o antigo Thymeleaf: a página chama @template.kit.layout(...) passando o conteúdo (e um
navMenu opcional); o layout monta o <head>, o header com a marca e o bloco direito
(versão/modo/chip de sessão, em kit/headerRight.jte).
O que é deste app vive em src/main/jte/: kit/navMenu.jte (menu de operador + os chips
MODO/versão) e componentes/jsonCellScript.jte. O CSS de componente (timeline, tiles, buckets,
stepper) é o /css/app.css, que o layout.jte linka direto no <head>. Os chips ficam no slot do
menu porque o kit não tem slot de status no header; editá-lo por app faria o arquivo divergir do
central para sempre.
O GlobalViewModel injeta em toda tela o contrato do kit — appNome/clienteXadm/authEnabled
— mais modo/staging/versao. (authEnabled=false: sem chip de sessão, o app não tem auth.)
Duas superfícies, o mesmo layout: o painel do usuário final usa o fragment navbar (marca
sem menu, o chrome limpo que a decisão 0006 pedia — o
layout-painel.html que ela cita foi absorvido pelo kit e não existe mais); as telas internas
(operador) usam o navbarComMenu.
Painel do usuário Maxsul (decisão 0006):
| Rota | O que faz |
|---|---|
GET / |
Dashboard ("Importação de Pedidos PIED → X-Adm"): tiles de resumo por status amigável (Pedidos em aberto/Enviando ao X-Adm/Importados no X-Adm/Falha ao importar) + lista paginada de pedidos (clicável, 100/página, por last_update); ?status=<slug> filtra por bucket. |
GET /pedidos/{code} |
Detalhe: timeline do status + dados (cliente, documento, datas, status na PIED = deal_status, pagamento = payment.status, pago em) + itens do pedido. code inexistente → 404 amigável. |
POST /pedidos/{code}/importar |
Botão "Importar" (home): força o pedido em aberto já pago (CAPTURADO+received) para NA_FILA (gate manual). |
POST /pedidos/{code}/dispensar |
Botão "Dispensar" (home): marca o pedido em aberto já pago como IMPORTADO_MANUAL (já lançado à mão no X-Adm), sem enviar. |
POST /pedidos/{code}/reimportar |
Botão "Re-Importar" (detalhe), só em Falha ao importar e só com parte do pedido pendente (achada pelo diagnóstico, ou ?pendente=1 depois de um 409 do "Importado manualmente"): reenfileira; o re-arme das linhas 1XX no espelho sai junto com a entrega (PushEnviador). |
POST /pedidos/{code}/importado-manual |
Botão "Importado manualmente" (detalhe e home), só em Falha ao importar — o caminho padrão da Falha: ERRO_XADM → IMPORTADO_MANUAL depois que o operador lançou à mão no X-Adm o que faltou. Observação obrigatória (observacao, até 500 caracteres) e operador opcional (operador, até 100, lembrado no navegador; na home os dois vêm de prompts) — o painel não tem login, então eles são o rastro de o que e quem; gravados em importacao_manual_obs (operador no fim) e _em, exibidos no detalhe. Fecha antes a remessa no Integrador (POST /api/v1/integracao/remessas/resolver-manual → RESOLVIDA_MANUAL) e só com o 200 marca aqui: parte do pedido ainda pendente de entrega (409: remessa ABERTA, ou TRAVADA com filho 000 bloqueado — não lançar à mão; aguardar ou corrigir e Re-Importar) ou Integrador fora deixam o pedido em Falha, com o motivo no flash. Ver API do Integrador §5. |
Diagnóstico da falha (detalhe, só em Falha ao importar): consulta o Integrador ao vivo na
abertura da página — a remessa viva (GET /api/v1/integracao/remessas, o que foi rejeitado,
inclusive formulas) e o retorno por tabela (GET /api/v1/xadm/retorno/{tabela}, o que foi
gravado e o código do contrato no X-Adm). Mostra o tipo do pedido (Kit + componentes ×
produtos avulsos), Gravado no X-Adm (definitivo — a integração não reenvia), NÃO gravado
(com os dados do envio: quantidades, valores, frete, componentes com o nome, o produto do item do
kit, a venda a prazo) e avisos "Possível" tirados do texto do X-Adm: produto-kit não criado
(estoque 006 com mensagem de pedido e composição rejeitada), contrato em duplicidade (códigos
diferentes no estoque e no contrato — o operador escolhe qual fica antes de lançar) e item com o
valor no lugar do número do pedido. O "O que fazer" é um só — não excluir o que está em Gravado,
lançar à mão o que falta, marcar Importado manualmente —, exceto com parte pendente (linha no
espelho que não é 006 nem 1XX, o filho de um pai rejeitado): aí orienta corrigir e Re-Importar,
e só então o botão aparece. Integrador fora do ar ou sem INTEGRADOR_BASE_URL → aviso no lugar do
diagnóstico, a página não cai.
Telas internas (operador) — layout :: navbarComMenu(~{componentes :: navMenu}):
| Menu | Rotas |
|---|---|
(home) GET /homedev |
Painel de cards com a contagem de linhas por tabela + atalhos (a antiga /). |
| Operação | /console (ver acima). |
| Alertas | /destinatarios — CRUD dos contatos que recebem o e-mail de erro terminal (ver acima). |
| Banco | Browser read-only, uma tela por tabela pied_* (ver abaixo). |
| Captura | /captura (timeline) e /captura/json (dump). |
| Sistema | /health. |
Browser do banco (/dados/*)¶
Uma tela de listagem read-only por tabela — colunas escalares + célula JSON clicável (clica →
copia o payload formatado), teto ?limite=N (default 1000, teto 5000). Cobre as 7 tabelas pied_*:
| Rota | Tabela | Observação |
|---|---|---|
GET /dados/pedidos |
pied_pedido |
Link por linha abre o /console. |
GET /dados/clientes |
pied_cliente |
Expõe PII; lista todas as linhas, incl. deletadas. |
GET /dados/produtos |
pied_produto |
Lista todas, incl. deletados. |
GET /dados/webhooks |
pied_webhook |
Payload + headers como célula JSON. |
GET /dados/rest |
pied_rest |
Páginas cruas do poll. |
GET /dados/cursor |
pied_cursor |
Cursor incremental + ultima_pagina. |
GET /dados/estado |
pied_integracao_estado |
Singleton com o corte go-forward. |
⚠️ Tudo aberto (sem auth) e expõe PII — mesma postura do
/captura//console: diagnóstico atrás da rede, hardening por login deferido. É read-only (não escreve no banco); as ações de operação ficam só no/console.
Desenvolvimento local¶
./gradlew run sobe o Postgres de dev via docker-compose.dev.yml (porta 5433, para não
colidir com o Postgres de dev do integrador) e ativa o perfil dev (application-dev.yml).
Os testes não usam esse banco: provisionam o próprio Postgres via PostgresTestResource da casa
(xadm-comum-teste, Testcontainers).
Reset — recomeçar a integração do zero (Maxsul)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-04
O que é¶
Procedimento para zerar a integração PIED → X-Adm e recomeçar limpo, sem inundar o X-Adm com inserts/updates. Serve quando uma mudança de identidade canônica ou de contrato exige recomeçar a base — ex.: a adoção do cliente do Faturamento (decisão 0009), que entra sem backfill nem re-envio dos pedidos existentes.
O desenho (arquitetura, fluxo) é do livro do projeto; como subir e desligar no dia a dia é de Implantação. Aqui está só o reset.
Por que não é só "apagar as tabelas"¶
O PowerSync sincroniza as 5 tabelas espelho (contratos, propriedades, fones, itensped,
estoque) para o cliente do X-Adm. Um resync completo re-snapshota a fonte: se a fonte ainda
tiver linhas velhas quando o PowerSync subir, elas viram um flood de escrita no X-Adm. Também há o
lixo do espelho antigo — propriedade/estoque/fone sem contrato no X-Adm não vem do
maxsul-pied (que só envia por pedido, no PushService.enviarPedido), e sim de um snapshot
anterior que não foi esvaziado.
Chave anti-flood: esvaziar a fonte ANTES de o PowerSync subir fresh, com o cliente do X-Adm parado. Fonte vazia + cliente parado = zero flood.
Pré-requisitos¶
- Acesso ao Coolify da Maxsul (recursos
pied.maxsul,integrador.maxsul,powersync.maxsul+ o Postgres compartilhado). - Acesso ao repo do PowerSync (
maxsul/powersync) e ao cliente do X-Adm (integrador-client). - Secrets no cofre da X-Adm (nunca neste doc):
PS_*,MONGO_ROOT_*, credenciais do Postgres. - Backup antes de apagar (
pg_dumpdo banco do cliente).
Topologia (o que existe)¶
- 1 Postgres compartilhado, 1 banco por cliente. O do Maxsul contém
pied_*e as tabelas espelho do Integrador (schemapublic), com dois históricos Flyway (o do pied e o do Integrador). - PowerSync (Maxsul): storage em MongoDB (dois volumes: estado do bucket + metadados). A config está assada na imagem — matar o disco do Mongo não perde config. Lê da fonte via replication slot + publication no Postgres (o slot vive no Postgres, não no Mongo). Sincroniza só as 5 espelho.
- Cliente do X-Adm =
integrador-client: consome o PowerSync e escreve no ZIM em lotes.
Passo a passo¶
Ordem importa — cada passo evita um modo de flood do seguinte.
- Parar quem escreve. Pare o
pied.maxsule ointegrador.maxsul(para o push por pedido) e pare o clienteintegrador-client(para a escrita no ZIM). - Backup.
pg_dumpdo banco do cliente (fonte da verdade antes de apagar). - Parar o PowerSync (
powersync.maxsul). - Esvaziar a fonte com
DELETE, nuncaTRUNCATE(por quê no fim deste doc): as 5 tabelas espelho + aspied_*.DELETE, nãoDROP TABLE— o drop quebra a publication e o Flyway do Integrador sem ganho. ⚠️ Reseede os singletons logo em seguida (abaixo): há tabelapied_*cuja linha única é semeada pela migration, e oDELETEa leva junto. - Matar o storage do PowerSync (
docker compose down -v, ou remover só os volumes do Mongo se o Coolify já removeu o container no Stop). Não exclua o recurso Coolify — isso perde os secretsPS_*/MONGO_ROOT_*. - Dropar o replication slot órfão no Postgres, de forma idempotente — só os inativos:
SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots WHERE database = '<banco_do_cliente>' AND NOT active; - Subir apps + PowerSync fresh. Suba
pied.maxsul/integrador.maxsule redeploy dopowersync.maxsul(Mongo vazio → snapshot de uma fonte vazia, sem flood). - Semear o cursor da PIED para não re-puxar o histórico:
pied_cursorcom a data de hoje. E reseedar os singletons apagados no passo 4 (ver abaixo). - Zerar o cliente do X-Adm.
disconnectAndClearnointegrador-client(limpa o SQLite local) e religue.
Singletons semeados por migration (reseedar depois do DELETE)¶
Nem toda tabela pied_* é só dado: algumas têm uma linha fixa semeada pela migration, e o código
a atualiza com UPDATE ... WHERE id = 1. Se o DELETE do passo 4 leva a linha e ninguém reseeda, o
UPDATE afeta 0 linhas — falha silenciosa, sem erro no log.
-- pied_integracao_estado (V4): corte go-forward. Reseedar após o DELETE das pied_*.
INSERT INTO pied_integracao_estado (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
Hoje é inerte, não deixe enganar. Desde a revisão do gate (2026-08-07) quem manda no envio é a transição de pagamento no upsert; o corte go-forward ficou sem chamador, e o código que o gravava saiu na migração para
micronaut-data(2026-09-11). A tabela ficou — só oGET /dados/estadoa lê; dropá-la é DDL destrutivo, decisão à parte (expand/contract). A linha ausente não trava envio nenhum — mas deixaGET /dados/estadovazio, o que atrapalha o diagnóstico (já custou uma investigação, 2026-09-04). Reseede.
Ao criar um singleton novo (INSERT ... VALUES (1) numa migration), acrescente-o aqui no mesmo
PR — senão o próximo reset o apaga em silêncio.
Variante: reset só-envio (mantém o raw)¶
Usada na ida pra produção (2026-09-04): recomeçar o envio do zero sem perder a captura
raw já feita (evita re-puxar meses de PIED). Zera o lado X-Adm e a fila, mantém
pied_webhook, pied_rest, pied_cliente, pied_produto e o payload do pied_pedido. É o
reset total menos o DELETE das pied_* — no lugar dele, reseta a máquina de entrega.
Mesma ordem e mesmos cuidados do passo a passo acima (parar escritores → backup → parar PowerSync →
mexer no Postgres → matar Mongo → dropar slot → subir fresh → disconnectAndClear). Só o passo 4
muda, e o passo 8 (semear cursor) não se aplica — mantendo o raw, o cursor continua de onde
parou (reseedar arriscaria pular pedidos atualizados no intervalo).
Passo 4 (variante) — DELETE nunca TRUNCATE:
BEGIN;
-- (a) Espelho X-Adm: esvazia a FONTE antes do PowerSync subir fresh (anti-flood).
DELETE FROM estoque; DELETE FROM itensped; DELETE FROM fones;
DELETE FROM propriedades; DELETE FROM contratos;
-- (b) Outbox M2M: mata o dispatch pendente/enviado do push. 'xadm-push' é o ÚNICO tipo do PIED
-- (PushEnviador.TIPO); o write-back do X-Adm volta na resposta HTTP, NÃO cria linha ENTRADA.
DELETE FROM mensagem_m2m WHERE tipo = 'xadm-push';
-- (c) Reset da máquina de entrega no pied_pedido — MANTÉM linha e payload (raw).
-- payment_status_anterior := payment_status => já-pago NÃO refira o gate (sem transição
-- testemunhada), senão o backlog inteiro despejaria no X-Adm.
UPDATE pied_pedido SET
status = 'CAPTURADO',
content_hash = NULL,
cod_retorno = NULL,
msg_retorno = NULL,
chave_xadm = NULL,
enviado_em = NULL,
payment_status_anterior = payment_status,
atualizado_em = now();
COMMIT;
O gate manda no "só pedidos novos". O disparo do envio é a transição payment_status
não-received → received num pedido CAPTURADO (upsert ON CONFLICT, V6__gate_pagamento.sql +
PiedPedidoRepository.upsert). Ao setar payment_status_anterior = payment_status, os já-received
não refira; só quem pagar depois do reset entra na fila. Pedido que reaparece por webhook é
INSERT (1ª captura) — já-pago no INSERT nunca dispara.
Opção — começar do zero de verdade (usada em 2026-09-04): os já-pagos de teste eram ruído;
apagamos em vez de deixar parados em CAPTURADO. Mantém só os pendentes reais, que vão pro X-Adm
quando pagarem:
DELETE FROM pied_pedido WHERE payment_status = 'received';
Verificação (antes de ligar a Fase 2):
SELECT status, count(*) FROM pied_pedido GROUP BY status; -- só CAPTURADO
SELECT count(*) FROM mensagem_m2m WHERE tipo='xadm-push'; -- 0
SELECT count(*) FROM contratos; -- 0 (idem as outras 4 espelho)
SELECT count(*) FROM pied_webhook; -- >0 (raw intacto)
Por que DELETE e não TRUNCATE¶
Este banco tem PowerSync com replicação lógica; TRUNCATE não se comporta bem com a publication
usada pelo slot. Use DELETE nas tabelas espelho e pied_*. (Regra do repo — nunca TRUNCATE
onde há PowerSync.)
Depois do reset¶
- Confira que o X-Adm não recebeu escrita durante o procedimento (cliente parado).
- Religue a Fase 2 conforme Implantação só quando a fonte estiver populada de novo pelos pedidos correntes.
- Runbooks operacionais do PowerSync em si (Mongo, slot, docker) vivem nos repos donos
(
maxsul/powersynceintegrador-server); aqui fica só a fatia do maxsul-pied.
Recadastrar o webhook da PIED¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-21
Sintoma¶
Pararam de chegar pedidos em tempo real. O watchdog registra no GlitchTip um erro do tipo "Webhook PIED silencioso há ~Nh úteis…" — ou alguém nota pedidos recentes que não apareceram no painel (só via poll, com atraso).
Causa¶
Quando a entrega em POST /webhook/pied falha, a PIED desativa o webhook e para de
enviar. Ela não religa sozinha quando o servidor volta. É preciso excluir e recadastrar o
webhook no dashboard da PIED.
Duas formas de falha já vistas em produção:
- 404 — nossos servidores fora do ar (deploy, reinício, indisponibilidade);
- timeout — o servidor está de pé e saudável, mas a rede oscila e a PIED não consegue
entregar. Foi o caso de 18/09/2026: ~5 min de rede degradada (
Read Timeout, depoisTemporary failure in name resolutionno log) derrubaram o webhook por 2h44, 33× a duração da falha. Containerrestarts=0e jobs logando normalmente durante todo o silêncio — app de pé não descarta esta causa.
⚠️ A PIED não reenvia o que se perdeu. O recadastro restabelece o fluxo dali para a frente; os eventos do apagão estão perdidos e só voltam pelo poll REST (abaixo).
Conserto (dashboard da PIED — ação da Maxsul)¶
- Confirme que o servidor está de pé:
GET https://pied.maxsul.xadm.biz/healthresponde{status: UP, ...}. - No dashboard da PIED, vá em webhooks/integrações.
- Exclua o webhook do X-Adm (o que aponta para
https://pied.maxsul.xadm.biz/webhook/pied). - Recadastre com a mesma URL (
POSTparahttps://pied.maxsul.xadm.biz/webhook/pied) e, se houver, o mesmo segredo (X-Pied-Secret=PIED_WEBHOOK_SECRET). - Dispare um evento de teste (ou aguarde o próximo pedido) e confira que chegou: painel
/mostra o pedido, ou em/dadosa tabelapied_webhooktemrecebido_emrecente.
Assim que um webhook novo chega, o watchdog fecha o episódio sozinho (o max(recebido_em) avança)
e volta a vigiar — sem reset manual.
Prevenção / reduzir recorrência¶
- Minimizar 404s: o deploy 50/50 (jar + native lado a lado) e o
initialDelaydos jobs reduzem janela de indisponibilidade; ainda assim, todo reinício é uma janela em que a PIED pode ver 404. - Backstop: o poll REST (a cada 6h) recupera o dado que o webhook perdeu — não é tempo real,
mas a normalização é idempotente pela chave
code. ⚠️ Recuperar o dado não era o mesmo que entregar o pedido. Se o apagão engoliu o evento de pagamento, o poll traz o pedido já pago — e o gate, que é a transição parareceived, não tem transição a testemunhar: o pedido encalha emCAPTURADO, sem erro e sem alarme. Foi assim que quatro pedidos ficaram parados de sexta a segunda em 18/09/2026. A decisão 0023 fecha isso com o resgate da 1ª captura já paga (PIED_GATE_PRIMEIRA_CAPTURA_DESDE). Com o resgate desligado (default), confira o console depois de todo apagão: pedidos pagos na janela cega ficam emCAPTURADOesperando Enfileirar. - Vigia: mantenha
PIED_WATCHDOG_HABILITADO=trueem produção. O silêncio vira alerta por duas réguas — 45 min comerciais (seg–sex 8h–18h) e 24h úteis como rede de fora do horário. Ajuste porPIED_WATCHDOG_LIMITE_MIN_COMERCIAL/PIED_WATCHDOG_LIMITE_HORAS(ver Configuração).
Dev
Guia do código¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15
Audiência: dev que abriu o repositório e quer passar os olhos e entender como o código está
organizado, antes de mergulhar classe a classe. Para o porquê das decisões, veja
decisoes/; para o desenho completo, a
Documentação Completa; para os contratos da PIED,
API da PIED.
O retrato em uma frase¶
maxsul-pied é a Fase 1 da integração PIED → X-Adm: um servidor Micronaut que só captura o raw
da PIED por dois canais (webhook + poll REST) e o persiste cru em três tabelas landing. Não
transforma nem escreve no X-Adm — isso é a Fase 2, que nasce desligada por toggle.
flowchart TB
PIED[PIED]
PIED -->|POST /webhook/pied| WH[webhook: WebhookController]
PIED -->|GET REST paginado| POLL[poll: PollJob → PollService → PiedClient]
WH --> PW[(pied_webhook)]
POLL --> PR[(pied_rest)]
POLL --> PC[(pied_cursor)]
Como o código é organizado¶
Organização package-by-feature: cada canal é um pacote de topo que carrega suas próprias camadas,
em vez de pastas globais controller//service/. O transversal vive em comum. Base:
br.com.xadm.maxsul.pied.
| Pacote | É | Responsabilidade |
|---|---|---|
webhook |
feature | recebe POST /webhook/pied, valida toggle/segredo, grava cru em pied_webhook. |
poll |
feature | job agendado que pagina a API REST da PIED e grava cada página em pied_rest (+ cursor). |
normalizado |
feature | deriva as tabelas normalizadas (pied_pedido/pied_cliente/pied_produto) do raw capturado. |
integracao |
feature | Fase 2: transform → push ao Integrador, console do operador, reconciliação. Toggle IntegracaoConfig (decisão 0001), modo PRODUCAO/STAGING (decisão 0004). |
painel |
feature | painel do colaborador Maxsul: dashboard por bucket de status + detalhe do pedido (decisão 0006). |
alerta |
feature | destinatários + envio do e-mail de erro terminal via Resend (decisão 0005). |
dados |
feature | browser read-only das tabelas pied_* + índice de operador (/homedev). |
diagnostico |
feature | captura crua (timeline/dump) e o smoke-test do GlitchTip. |
retencao |
feature | job de expurgo do raw por janela de retenção. |
comum |
base | transversal local: GlobalExceptionHandler (500 + página HTML), Json, GlobalViewModel, port ModoIntegracao. A base compartilhada da casa — HealthController, UnifiedErrorResponseProcessor (RFC 7807 de ponto único), ProblemDetail, VersaoInfo — vem da lib xadm-comum-web (br.com.xadm.comum.web; decisão 0008). |
As camadas¶
Persistência é Micronaut Data (micronaut-data-jdbc,
decisão 0022): todo repositório é @JdbcRepository. O
CRUD simples usa o CrudRepository; o que carrega regra — os upserts com ON CONFLICT, as transições da
FSM guardadas por status, as projeções — é @Query nativo numa interface Sql aninhada na classe do
repositório, que mantém a API de domínio e a transação. O corpo cru nunca é desserializado num modelo de
negócio: é gravado como jsonb como chegou. A varredura do raw que alimenta a normalização segue a mesma
forma — @Query nativo no RawCapturadoRepository, devolvendo a projeção RawLinha.
Em cada feature o controller é a borda HTTP e delega ao service, que é quem fala com o repositório
(DadosService, CapturaService, PainelService, ConsoleService, WebhookService; os destinatários
de alerta passam pelo AlertaService). Nas telas de leitura o service só repassa a consulta: a fronteira
existe para o controller não depender da persistência, não para criar regra nova.
- Webhook:
WebhookController→WebhookService→PiedWebhookRepository. O controller responde 200 imediato após o insert; erro de persistência propaga →500(a reconciliação do poll é a rede de segurança, ver api-pied.md §3.1). - Poll:
PollJob(@Scheduled) →PollService(orquestra a rodada) →PiedClient(HTTP, envelope, retry) +Paginador(paginação/parada) →PiedRestRepository/CursorRepository. Só pedidos usa o cursorpied_cursorcomo?lastUpdateAfter, avançando só em sucesso; falha de uma entidade não derruba as demais.
Travas de arquitetura¶
O gate é ./gradlew check (checkstyle + testes + cobertura + fronteiras). Cinco fronteiras são
guardadas por teste (arquitetura/FronteirasTest, ArchUnit), e não por convenção — poucas
regras, alto sinal. As quatro primeiras vêm da fábrica RegrasArquitetura da xadm-comum-teste:
| Fronteira | Por que ela importa |
|---|---|
comum não depende de nenhuma feature |
comum é a base que toda feature usa; se ela conhece uma feature, a seta se inverte e o pacote base vira refém do canal. |
| features não formam ciclo entre si | cada canal tem de poder ser lido, testado e desligado em separado — o desligar-em-separado é o kill switch da decisão 0001. |
| controller não acessa repository | o controller é a borda HTTP e delega ao service da feature, que é quem fala com a persistência; no package-by-feature os dois moram no mesmo pacote, então a fronteira é pelo tipo. A regra da lib casa GenericRepository; uma regra local casa o sufixo Repository, porque aqui o repositório é a classe que embrulha a interface Sql. |
| nada depende de controller | domínio, jobs e services não conhecem o controller nem os DTOs aninhados nele; o controller mapeia o que o service devolve. |
| persistência só por Micronaut Data | DataSource.getConnection() só na allowlist — hoje, o advisory lock de sessão do PiedClusterLock. Classe nova ali é decisão, com o motivo no comentário (decisão 0022). |
A guarda nasceu junto com a migração para a constituição 0.24.0 e pegou violação real na
primeira execução: o GlobalViewModel (em comum) injetava o IntegracaoConfig concreto,
prendendo a base ao canal de integração e fechando quatro ciclos. A correção foi inverter a seta:
comum declara o port ModoIntegracao (só o que o header precisa — rótulo do modo e se é
staging) e o IntegracaoConfig o implementa.
Limite conhecido: a guarda lê bytecode, então dependência que exista só via constante
public static final é invisível (o compilador inlina o valor). Acoplamento real — tipo em campo
ou parâmetro, injeção, chamada — é pego.
A outra fronteira que importa (não persistir credencial) também é guardada por teste:
WebhookControllerTest verifica que Authorization/X-Pied-Secret são gravados mascarados.
Por onde começar a ler¶
Application.java— entrypoint Micronaut.webhook/WebhookController.java— o caminho quente mais simples, ponta a ponta.poll/PollService.java+poll/Paginador.java— a orquestração da captura REST e a paginação.V1__landing_pied.sql(Flyway) — o schema landing que tudo alimenta.
Rodar e testar¶
Subir o app, os dois conjuntos de teste, o padrão do teste de integração e o gate local estão em Como rodar.
Referência completa¶
A árvore de classes navegável (Javadoc, interna) não é publicada neste app — o job de docs do
pipeline.yml não gera dev/api/ (a geração é opt-in). A narrativa acima é o atalho para achar por onde entrar.
Como rodar¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15
Audiência: dev que clonou o repositório e quer subir o app, rodar os testes e passar o gate local. Para a organização do código, veja o Guia do código; para variáveis de ambiente e toggles, a Configuração.
Pré-requisitos¶
- JDK 25 (a versão de
docs/app.json→toolchain.java). Com outro JDK como padrão da máquina, aponte oJAVA_HOMEpara o 25 ao chamar o Gradle. - Docker — o Postgres de dev (
docker-compose.dev.yml) e o de teste (xadm-comum-teste) sobem em container.
Rodar em desenvolvimento¶
./gradlew run # sobe o Postgres de dev (docker compose, porta 5433) e o app na 8080
O run ativa o perfil dev (MICRONAUT_ENVIRONMENTS=dev, application-dev.yml). Quem já tem o próprio
Postgres exporta DATASOURCES_DEFAULT_URL e o compose não sobe. Webhook ligado; poll desligado até
existir PIED_TOKEN.
Conferência rápida:
curl http://localhost:8080/health
curl -X POST http://localhost:8080/webhook/pied \
-H 'Content-Type: application/json' \
-d '{"event":"order.created","data":{"id":1}}'
Testes¶
Os testes são divididos por source set:
./gradlew test— só unitários (src/test/java: função pura, fake, WireMock, ArchUnit). Não sobe Docker e roda em segundos: é o loop rápido de dev../gradlew integrationTest— os@MicronautTest(src/integrationTest/java), contra um Postgres real doPostgresTestResourceda casa (xadm-comum-teste): umpostgres:18-alpinecompartilhado por JVM, injetado por classe viaPostgresTestPropertyProvider(a classe implementa a interface e usa@TestInstance(PER_CLASS)).- Onde cada teste mora: precisa de bean context,
DataSourceou HTTP client Micronaut →src/integrationTest; função pura, fake, WireMock ou ArchUnit →src/test. - Padrão do teste de integração (decisão 0013):
@MicronautTest(transactional = false), sem JDBC cru. Seed e leitura vão pelos repositórios de produção; o que eles não expõem (limpeza entre testes, seed com timestamp no passado, contagem do outboxmensagem_m2m, atalhos de FSM) vive emsupport/BancoFixture, o único ponto com SQL de baixo nível. O@BeforeEachlimpa comfixture.apagarTudo(), porDELETEe poupando as tabelas semeadas por migration. Com a transação de teste padrão, um write via repositório@Transactionalficaria sem commit: travaria contra uma segunda conexão e sumiria para leituras por outra conexão. - Trava vira falha: a task de teste cai em 15 min (
build.gradle.kts), cada teste em 2 min (src/test/resources/junit-platform.properties) e a conexão de teste em 5 s (application-test.yml).
Gate local¶
./gradlew check # checkstyle + test + integrationTest + fronteiras (ArchUnit) + % de cobertura
./gradlew coverage # testes + relatório JaCoCo (build/reports/jacoco/test/html/index.html)
O check é o que o CI roda. A cobertura é visível no log, não bloqueante.
Gate lento ou travando
Nunca rode --no-daemon. Sem daemon o Micronaut refaz o annotation processing do zero a cada run
e empilha JVMs órfãs (~1,5 GB cada) até a RAM saturar. O daemon fica ligado
(org.gradle.daemon=true); itere com ./gradlew test e rode integrationTest/check quando mexer
no banco. O container do Postgres de teste não se mata à mão: o Ryuk do Testcontainers o recolhe
quando a JVM do teste cai. Com o check pendurado por containers órfãos (SIGKILL repetido), remova
só os do Testcontainers, pelo rótulo
(docker rm -f $(docker ps -aq --filter "label=org.testcontainers=true")), e rode de novo — filtrar
pela imagem postgres:18-alpine derruba também o Postgres de dev do docker-compose.dev.yml
(Java/Micronaut, Armadilhas).
Lib da casa ainda não publicada¶
O build procura br.com.xadm primeiro no registro e depois no ~/.m2
(Bibliotecas da casa). Para testar contra uma versão
da xadm-commons que ainda não saiu, publique-a no mavenLocal a partir do repo da lib e declare o
número no build.gradle.kts. A /xadm-release recusa dependência da casa que não esteja no registro.
Com o registro fora do ar, o Gradle não cai no local: use ./gradlew --offline.
API da PIED — REST e Webhook¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-11
Referência técnica dos dois canais de entrada do maxsul-pied. Contrato validado contra o
ambiente real em 2026-06-17.
1. Acesso¶
- Base URL v2:
https://backend-pied-prod.piedadmin.com.br/api/v2(produtos, pedidos). - Base URL v1:
https://backend-pied-prod.piedadmin.com.br/api/v1(clientes/companies). - Autenticação: header
Authorization: Bearer <token>. O token de produção é provido por variável de ambiente do Coolify (PIED_TOKEN), nunca no código; o poll nasce desligado até ele existir. - Envelope de resposta:
{ "error": null, "data": { "items": [ … ], "totalItems": N } }. - Paginação: rota
/{recurso}/{página}/{limite}, 1-based, limite máx. 50. Página além do fim retorna HTTP 200 comitemsvazio (condição de parada);204= nenhum registro. - Limite de uso: dois tetos por hora (nº de chamadas e tempo de processamento). Estourar →
429comhardLimit/timeLimitno corpo → backoff; emtimeLimit, esperar a virada da janela.
2. REST¶
2.1 Produtos — GET /api/v2/equipments/{página}/{limite}¶
Varredura completa (sem delta). Upsert por productCode.
{ "productCode": "MOD-550", "name": "Painel Solar 550W Monocristalino",
"manufacturer": "Canadian Solar", "model": "CS6W-550MS", "type": "module",
"baseprice": 780.00, "inventory": { "current": 320, "reserved": 12, "minimum": 50 } }
2.2 Clientes — GET /api/v1/companies/{página}/{limite} (v1)¶
Mesmo objeto company do pedido (cnpj/cpf, companyName, mainContact, address) + stateInscription.
Varredura completa. Use para clientes que ainda não geraram pedido (os demais vêm embutidos nos pedidos).
2.3 Pedidos — GET /api/v2/requests/order/{página}/{limite}[?lastUpdateAfter=AAAA-MM-DD]¶
Cliente (company) e itens (products) vêm embutidos. Orçamentos usam
GET /api/v2/requests/budget/… com o mesmo formato (uso interno — não vão ao X-Adm).
{
"id": "6f3a9c12", "code": "200000123", "type": "order",
"kind": "Kit Personalizado", "totalPower": 5.5,
"dealStatus": "Novo", "originalValue": 25000, "discount": 5, "finalValue": 23750,
"company": {
"cnpj": "12.345.678/0001-09", "companyName": "Solar Sul Ltda",
"mainContact": { "name": "João", "surname": "Silva", "email": "joao@solarsul.com", "cellphone": "5642999990000" },
"address": { "CEP": "84010-000", "state": "PR", "city": "Ponta Grossa",
"neighborhood": "Centro", "patio": "Rua das Acácias", "number": "100", "complement": "" }
},
"products": [ { "productCode": "MOD-550", "type": "module", "quantity": 10,
"singlePrice": 780, "totalPrice": 7800, "center": { "code": "CD001" } } ],
"payment": { "type": "credito", "status": "received", "paidAt": "2026-08-18T16:00:38.620Z",
"transactionId": "fb35…", "transactionStatus": "approved", "links": [],
"condition": { "name": "30/60 dias", "quantityOfInstallments": 2 } },
"freight": { "type": "CIF", "price": 350 },
"lastUpdate": "2026-06-15T19:12:24.391Z"
}
Pagamento — um status macro por pedido. O payment é um objeto único (não um array): um
status, um paidAt, uma condition. Verificado em 1409 objetos capturados. Valores de status:
notRequested · requested · partial · received · cancelled.
paidAt= quando entrou o primeiro dinheiro; permanece preenchido mesmo apóscancelled(é histórico) — para saber o estado atual olhestatus, nãopaidAt.- O status individualizado por parcela/forma (o que a tela "Pagamento" da PIED mostra por linha)
não vem embutido: só via
payment.links[](GET /api/v2/payments/{id}, um por lançamento — esparso, presente em ~4% dos pedidos). A integração não consome esse sub-recurso hoje. - O valor da NF independe do pagamento:
finalValue/originalValue/invoice.serviceTotal/ produtos são idênticos empartialereceived(ver decisão 0010).
2.4 Incremental¶
?lastUpdateAfter=AAAA-MM-DDsó tem efeito em pedidos (produtos/clientes são varridos por inteiro). Granularidade de dia → a janela do último dia é reprocessada de propósito (dedup é da fase 2). O cursor vive empied_cursor(Modelagem §2.1).- A documentação da PIED cita um delta implícito por
apiSent/apiSentUpdatedsem parâmetros — não confirmado no ambiente real; o mecanismo confiável é olastUpdateAfter.
2.5 Erros¶
401/403 aborta a entidade; 429/5xx/timeout → retentativa com espera progressiva; resposta
não-JSON → falha controlada.
3. Webhook (PIED → nós)¶
POST HTTPS para a URL configurada, corpo { "event": …, "data": { … } }. O data é completo
(mesmo formato do pedido REST — em geral dispensa segunda consulta).
Eventos: budget.created · budget.updated · order.created · order.updated. Empresas também
disparam evento, mas sem nome publicado (a confirmar). Não há webhook de produto.
{ "event": "order.created",
"data": { "id": "6f3a9c12", "code": "200000123", "type": "order", "kind": "Kit Personalizado",
"totalPower": 5.5, "dealStatus": "Novo", "finalValue": 23750,
"company": { "cnpj": "12.345.678/0001-09", "companyName": "Solar Sul Ltda", "…": "…" },
"products": [ { "productCode": "MOD-550", "quantity": 10, "singlePrice": 780 } ],
"payment": { "type": "credito", "status": "received" },
"lastUpdate": "2026-06-15T19:12:24.391Z" } }
Resposta: 200 OK rápido (só confirma o recebimento; processamento assíncrono).
3.1 Lacunas (NÃO disparam webhook)¶
Alteração de responsável, edição de frete, campos customizados, anotações, upload de arquivos. Por isso o webhook não é espelho fiel — o que falta é coberto pela reconciliação REST (§2.3). É por isso que o poll REST é obrigatório, não rede de segurança.
3.2 Segurança (a confirmar com a PIED)¶
- Sem HMAC/assinatura documentada → segredo compartilhado (header
X-Pied-Secret, a confirmar) + IP allowlist no Traefik + HTTPS. - Política de reenvio não documentada → assumir "pode perder evento"; a reconciliação REST cobre.
3.3 Cadastro do webhook (manual, painel da PIED)¶
Configurações → API e Webhooks → aba Webhooks → Adicionar: endpoint
https://pied.maxsul.xadm.biz/webhook/pied, gatilhos budget.*/order.*, segredo. Testar com
webhook.site/ngrok antes de apontar para produção. Ver Configuração.
API do Integrador — /api/v1/xadm¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-10
Como o maxsul-pied grava o estado desejado nas tabelas espelho do X-Adm
(contratos/propriedades/fones/itensped/estoque): pelo mesmo contrato REST que o agente
on-premises do X-Adm já usa para popular o Integrador. O maxsul-pied não escreve SQL direto —
mesmo compartilhando o Postgres, respeita a propriedade do Integrador (Projeto
§2.2). O schema autoritativo das tabelas vive no repositório do Integrador.
1. O contrato de ingestão¶
O contrato abaixo é fato do Integrador, que é o dono dele: chega do raw/ dele vendorado em
docs/_importado/, byte a byte, fixado (pin) na versão que este app implementa — não re-digitado
(constituição §1.9). Adotar versão nova do contrato é atualizar a cópia no mesmo PR que muda o código.
Por que isto não é uma cópia
Até 2026-07-17 esta página re-digitava o contrato à mão, e ele já havia envelhecido: dizia
que nenhuma origem da PIED estava registrada no Integrador (a INT-PIED já estava), e não
trazia a guarda de no-op nem os casos 401/500. Fato re-digitado longe do dono envelhece
sozinho; fato importado, não.
Endpoint e autenticação¶
O integrador tem um endpoint de ingestão, com dois verbos sobre o mesmo corpo:
| Verbo | Efeito |
|---|---|
PUT /api/v1/xadm |
upsert — cria ou atualiza; registro que existia deletado é revivido |
DELETE /api/v1/xadm |
soft delete dos registros identificados pelas chaves de negócio do corpo |
Toda rota /api/** exige Authorization: Bearer <token>. O token é estático e por
instalação (há uma instalação por cliente), entregue por canal seguro — sem ele a
resposta é 401.
Envelope¶
O corpo é um objeto JSON com o campo Origem (string) e uma chave por tabela, cada
uma com um array de objetos:
{
"Origem": "PNOTAI",
"ESTOQUE": [ { "ChaveEst": "...", "Saldo": 10 } ],
"MUNICIPIO": [ { "CodMun": "4314902", "Estado": "RS", "NomeMun": "Porto Alegre" } ]
}
As chaves de tabela reconhecidas são 15:
CONTRATOS · ITENSPED · ITENSPEDAMX · ITPEDAMXDI · LOTEEST · ESTOQUE ·
MUNICIPIO · PROPRIEDADES · FONES · VEICULOS · NOTACOMPL · ITENS ·
ITENSLOTE · TBPCOEST · FORMULAS
O nome da chave é case-sensitive: chave com caixa errada não casa tabela nenhuma e o payload é tratado como vazio (ver Resposta). Tabela ausente do corpo é ignorada — só se envia o que mudou.
Resposta¶
O status HTTP não diz se deu certo. Falha de processamento responde 200 com
resultado: "ERRO" — o contrato foi mantido assim por compatibilidade com o integrador
antigo. Quem consome tem de olhar o campo resultado, não o status HTTP; um cliente
que trate 200 como sucesso perde toda falha em silêncio.
{ "requestId": 1234, "resultado": "SUCESSO", "mensagem": null }
| Campo | Valor |
|---|---|
requestId |
id do registro Request criado para esta chamada (rastreia o processamento) |
resultado |
SUCESSO, ERRO ou PENDENTE |
mensagem |
null em sucesso; o detalhe do erro quando resultado é ERRO |
| HTTP | Quando |
|---|---|
200 |
o corpo era JSON válido — inclusive quando o processamento falhou (resultado: "ERRO") |
400 |
JSON inválido ou malformado; corpo {"requestId": null, "resultado": null, "mensagem": "JSON inválido: …"} |
401 |
Bearer ausente ou inválido |
500 |
falha antes do processamento (ex.: o registro da chamada não gravou). Erro de processamento não é 500 — é 200 com ERRO |
Payload que não casa nenhuma tabela conhecida responde 200 com resultado: "ERRO" e a
mensagem "payload sem nenhuma tabela reconhecida (verifique as chaves de array por
tabela)". É guarda deliberada contra o no-op silencioso: sem ela, chave com caixa errada
voltaria SUCESSO sem gravar nada.
Origem¶
Origem identifica a rotina do X-Adm que originou a carga. Ela é gravada e usada para
rastreio, nunca validada: origem desconhecida é aceita e processada normalmente.
O que a lista de origens conhecidas muda é só o roteamento de erro ao Sentry — falha
de processamento numa origem conhecida vira evento com as tags origem/tipo/request_id;
numa origem fora da lista, a falha não é reportada. Ou seja, origem errada não quebra a
chamada, mas apaga o alarme: o erro fica invisível.
Origens conhecidas hoje: PPEDAMX · PPEDIDO · PBLDI · PNOTAI · PNOTAD ·
PCSTPROD · PREQUIS · INT-PIED.
FORMULAS — fórmula (BOM) do kit (fluxo de entrada PIED)¶
Tabela de entrada (Origem: INT-PIED): a fórmula de um pedido-kit — um objeto por
componente do kit. O produtor manda só três campos de input (caixa exata):
| Campo | Tipo | Conteúdo |
|---|---|---|
xPed |
string(15) | código do pedido do kit (casa com o xPed de CONTRATOS/ITENSPED) |
CodProd |
string(6) | código do produto componente |
Qtde |
decimal | quantidade do componente consumida na fórmula |
{ "Origem": "INT-PIED",
"FORMULAS": [ { "xPed": "260069986", "CodProd": "22162", "Qtde": 10 },
{ "xPed": "260069986", "CodProd": "16442", "Qtde": 2 } ] }
Chave natural (xPed, CodProd). É fluxo de entrada de mão única para o dado: o X-Adm cadastra
e devolve só o status (CodRetorno/MsgRetorno) pelo write-back (006 = gravado) — nunca
devolve chave, então a tabela não carrega colunas Chave*. Reenvio idempotente preserva o status já
gravado. Sem DELETE (pedido pago é imutável).
Tabela ESTOQUE¶
Cada item do array ESTOQUE é um produto. Todos os campos são opcionais no envelope —
o que não vier não é escrito.
| Campo | Tipo | Nota |
|---|---|---|
ChaveEst |
texto | identidade do produto. É a chave de join do espelho |
codProd |
texto | identidade alternativa, usada onde ChaveEst chega vazio. Aceita CodProd (PascalCase) |
NomeProd |
texto | descrição. Aceita nomeProd |
EAN13 |
texto | código de barras. Aceita ean13 |
CodProdAlt |
texto | código alternativo do produto no X-Adm |
CodigoAlt |
texto | código do produto na PIED — rastreabilidade de origem externa, gravado em pied_codigo_alt. Não é identidade nem critério de join |
Venda |
decimal | preço de venda. Aceita venda |
VendaPz |
decimal | preço a prazo. Aceita vendaPz |
Saldo |
decimal | saldo em estoque. Aceita saldo |
Identidade. O produto é identificado por ChaveEst; onde ele chega vazio (o caso da
instância Thoms), a identidade é o codProd. A chave do parceiro (CodigoAlt) nunca
é identidade — entra prefixada no espelho (pied_codigo_alt) e serve só para rastrear de
onde o dado veio. Promover chave de parceiro a identidade de primeira classe acopla o
espelho ao vocabulário de um terceiro.
Write-back. CodRetorno e MsgRetorno não são lidos deste payload: são escritos
pelo X-Adm no sentido inverso. Mandá-los aqui não tem efeito.
Caixa dos campos. Onde a tabela diz "aceita", o parser lê as duas grafias; nos demais
campos a grafia é exata. Campo com caixa fora do previsto é ignorado em silêncio — o
registro é gravado sem ele, e a chamada volta SUCESSO.
2. Como o maxsul-pied usa esse contrato¶
O que segue não é o contrato — é a decisão deste app sobre como consumi-lo.
- Ordem
DELETE → PUT: o padrão do ERP para limpar/reescrever estado. Omaxsul-piedenvia os pares na ordem certa e não paraleliza writes concorrentes na mesma chave. (Do outro lado, o Integrador serializa o apply num lock global, FIFO — mas depender disso seria depender de detalhe interno do dono; a ordem é responsabilidade de quem envia.) - Soft-delete/revive: combina com o content-hash do transform — só reescreve em mudança real.
- Idempotência: a chave natural (código do pedido/produto, documento do cliente) é o que torna o reenvio do mesmo estado inofensivo.
- Leitura da resposta: o
IntegradorClienttrata sóresultado: "SUCESSO"como enviado; qualquer outro viraERROna máquina de status, sem retentar. O transporte (timeout/5xx/429) é que é retentado. Isto implementa o aviso do contrato acima — o status HTTP não diz se deu certo. - Reconciliação pela remessa: o status do pedido sai do manifesto (§5), não do retorno de uma
tabela —
CONFIRMADOsó quando não há remessa viva e existe ao menos umaENTREGUE. Origem: omaxsul-piedenviaINT-PIEDem todo o payload da Maxsul (já registrada no Integrador — ver Origem no contrato acima; é o que faz a falha destes writes ser reportada).
3. Corpo por tabela (o mapeamento deste app)¶
Payload = Origem + um array por tabela, na ordem de dependência
(propriedades → fones → estoque → contratos → itensped). Nomes de campo conforme
Modelagem §1 (PascalCase do MAXSUL2025_610110). PUT faz upsert por chave
natural; DELETE leva só as chaves.
PUT (upsert):
{
"Origem": "INT-PIED",
"propriedades": [ { "CgcCpf": "12345678000109", "CodProp": " 0", "NomeProp": "Solar Sul Ltda",
"Endereco": "Rua das Acácias", "Numero": "100", "Bairro": "Centro",
"Cidade": "Ponta Grossa", "Estado": " PR", "CEP": "84010000" } ],
"fones": [ { "CgcCpf": "12345678000109", "DDD": "42", "Telefone": "999990000",
"Nome": "João", "Email": "joao@solarsul.com" } ],
"estoque": [ { "CodigoAlt": "MOD-550", "NomeProd": "Painel Solar 550W", "Venda": 780.00000 } ],
"contratos": [ { "xPed": "6f3a9c12", "CgcCpfCliente": "12345678000109", "ChaveUnid": " 1 29",
"CodMat": " 1 0 134", "DtEm": "20260615", "VlTot": 7800.00, "NatOp": "6.102",
"TpVda": "C" } ],
"itensped": [ { "xPed": "6f3a9c12", "CodigoAlt": "MOD-550", "Qtde": 10.000, "Valor": 780.00000,
"Total": 7800.00 } ]
}
DELETE (por chave natural):
{ "Origem": "INT-PIED",
"itensped": [ { "xPed": "6f3a9c12", "CodigoAlt": "MOD-550" } ],
"contratos": [ { "xPed": "6f3a9c12" } ] }
Formatação dos valores: no pipeline viajam tipados (
NUMERIC/TEXT/DATE); a conversão para o formato de armazenamento do ZIM (Character com espaços significativos, VastInt com escala fixa, datasAAAAMMDD) é feita no cliente — ver Tratamento de dados no ZIM.
4. Write-back (status de volta)¶
O resultado da gravação no X-Adm (CodRetorno/MsgRetorno e as Chave*) volta pelo conector
PowerSync do Integrador (POST /api/v1/powersync), que persiste nas tabelas espelho. Ver
Projeto §5.
5. Manifesto de remessa — a consulta de retorno e o fechamento à mão¶
O aceite do PUT (§1) só diz que o Integrador persistiu o estado desejado — não o resultado do
X-Adm. Quem fecha esse laço é o manifesto de remessa: uma consulta cobre o pedido inteiro, com o
status da remessa e, nas TRAVADA, os itens rejeitados. O contrato é fato do dono, vendorado como os
da §1 — pin: integrador-server 43c3048 (decisão 0025, com o 409 para linha pendente).
Consultar remessas¶
GET /api/v1/integracao/remessas?origem=<origem>[&chave=<chave>]...[&status=<status>]
Fora do namespace /xadm de propósito: remessa é dado de controle do Integrador, não espelho do
ERP. Toda rota /api/** exige Authorization: Bearer <token>; sem ele, 401.
| parâmetro | efeito |
|---|---|
origem |
obrigatória — hoje só INT-PIED tem manifesto |
chave |
pied_x_ped do pedido ou cgc_cpf do cliente. Aceita repetição (&chave=A&chave=B) — uma chamada por rodada, não uma por pedido |
status |
filtra por ABERTA, ENTREGUE, TRAVADA ou RESOLVIDA_MANUAL |
Chave inexistente não é 404: é 200 com lista vazia. Quem consome decide pelo conteúdo.
[
{ "id": "0199…", "chaveNegocio": "260079421", "status": "TRAVADA", "totalItens": 5,
"criadoEm": "2026-09-08T14:02:11Z",
"itensRejeitados": [
{ "tabela": "propriedades", "linhaId": "0199…", "codRetorno": "120",
"msgRetorno": "Cliente CPF 072.508.149-07 não cadastrado.", "bloqueado": false },
{ "tabela": "contratos", "linhaId": "0199…", "codRetorno": "120",
"msgRetorno": "Pedido (10738.24) não cadastrado.", "bloqueado": true } ] },
{ "id": "0199…", "chaveNegocio": "260081234", "status": "RESOLVIDA_MANUAL", "totalItens": 4,
"criadoEm": "2026-09-09T10:40:02Z", "itensRejeitados": [],
"resolucaoManual": { "em": "2026-09-10T16:20:00Z",
"observacao": "Lançado manualmente no X-Adm: kit sem produto cadastrado.",
"operador": "fulano@maxsul.com.br" } }
]
| campo | valor |
|---|---|
id |
id da remessa |
chaveNegocio |
pied_x_ped ou cgc_cpf |
status |
ver a tabela de estados abaixo |
totalItens |
quantos itens a remessa declara — a coluna, não um COUNT(*); é o que detecta lista rasgada |
criadoEm |
quando a remessa nasceu |
itensRejeitados |
os itens em 1XX, só nas TRAVADA. Nas demais vem [] — nunca ausente |
resolucaoManual |
só nas RESOLVIDA_MANUAL: em, observacao e operador (este some quando não foi informado). Nas demais, o campo some |
Em cada item rejeitado vêm tabela, linhaId, codRetorno, msgRetorno e bloqueado. O
bloqueado separa a causa do colateral: true quer dizer que um pai desta linha, na mesma
remessa, também está em 1XX. Quem reporta ao operador nomeia a mensagem do item não
bloqueado. No exemplo, a causa é o cliente; o "Pedido não cadastrado" é consequência.
Os estados¶
status |
significa | viva? |
|---|---|---|
ABERTA |
ainda não entregue, sem rejeição terminal | sim |
TRAVADA |
alguma linha em 1XX; só o re-arme a reabre |
sim |
ENTREGUE |
todas as linhas aceitas (006) — histórico |
não |
RESOLVIDA_MANUAL |
o operador lançou o pedido à mão no X-Adm e fechou a remessa por comando — histórico | não |
Uma remessa viva por (origem, chave): é invariante de banco. As não vivas acumulam. Os três
primeiros estados são derivados dos cod_retorno. RESOLVIDA_MANUAL é o único que nasce de
comando e que a derivação nunca reabre.
Um consumidor que encontre um status que não conhece deve tratá-lo como não travado e não enviável. É o que mantém a adição de um estado não quebrante.
Fechar à mão uma remessa travada¶
POST /api/v1/integracao/remessas/resolver-manual?origem=INT-PIED&chave=<xPed>
Content-Type: application/json
{ "observacao": "Lançado manualmente no X-Adm: kit sem produto cadastrado.",
"operador": "fulano@maxsul.com.br" }
Serve para o pedido que não se resolve reenviando (ex.: o produto-kit que o ERP não criou) e que o
operador lançou direto no X-Adm. Sem isto, a remessa seguiria TRAVADA para sempre. Quem chama é o
botão "Importado manualmente" do painel do maxsul-pied.
| corpo | regra |
|---|---|
observacao |
obrigatória, até 2000 caracteres — é o que sobra para reconstruir o caso |
operador |
opcional, até 100 caracteres. O Bearer é estático e não identifica pessoa |
{ "chave": "260079421", "resolvidas": 1 }
| HTTP | quando |
|---|---|
200 |
fechou (resolvidas: 1), ou não havia remessa viva (resolvidas: 0: nunca existiu, já entregue ou já resolvida) |
400 |
origem ausente ou diferente de INT-PIED; chave ausente; observacao ausente, branca ou longa demais; operador longo demais |
401 |
Bearer ausente ou inválido |
409 |
a remessa viva está ABERTA (ainda em entrega), está TRAVADA com linha ainda pendente (000/001/9XX), ou mudou de estado durante o comando — consulte e tente de novo |
Só TRAVADA. Fechar uma ABERTA enquanto o cliente pode estar mandando as linhas arrisca
duplicar no ERP.
E sem linha pendente. Numa TRAVADA o filho de um pai 1XX fica 000, bloqueado. Fechar a
remessa a tira do manifesto, e o cliente passaria a mandar esse filho por presença — duplicando no
ERP o que o operador lançou à mão. Nesse caso o caminho é corrigir o dado e re-armar.
Não mexe nas linhas. As 1XX continuam 1XX e o cliente não as coleta. Muda só a remessa.
Idempotente: a segunda chamada devolve resolvidas: 0, porque a remessa já não está viva.
Depois dele, o re-arme da chave responde 409. Re-armar reenviaria ao ERP o que o operador já
lançou. E se a chave ganhar uma remessa viva nova, o re-arme dela não reabre as linhas de pedido
(contratos/itensped/formulas) que também são itens da resolvida.
Como o maxsul-pied usa o manifesto¶
- Reconciliação (
ReconciliacaoJob):CONFIRMADOsó quando não há remessa viva e existe ao menos umaENTREGUE.RESOLVIDA_MANUALnão é viva, mas também não conta como entregue — o ERP recusou e quem resolveu foi gente; confirmar por ela diria que o X-Adm aceitou o que rejeitou. Status que este app não conhece conta como viva (conservador: nunca viraCONFIRMADO). - Botão "Importado manualmente" (
PainelController, só em Falha): chama oresolver-manualantes de marcar o pedido aqui, e só o200(resolvidas1, ou 0 = nada vivo lá) levaERRO_XADM → IMPORTADO_MANUAL. O409(parte do pedido ainda pendente de entrega: remessaABERTA, ex. depois de um re-arme, ouTRAVADAcom filho000bloqueado por um pai1XX) e a falha de transporte deixam o pedido em Falha, com flash dizendo por quê — no409, não lançar à mão, que duplicaria: aguardar, ou corrigir o dado e Re-Importar (o detalhe volta com?pendente=1, que põe o botão à mostra). Na ordem inversa, uma falha do comando deixaria o pedido fechado aqui e a remessaTRAVADAlá para sempre, sem ninguém que a reconcilie de novo. operador: opcional na tela (o painel não tem login), lembrado no navegador; vai no corpo só quando preenchido. Localmente entra no fim da observação (importacao_manual_obs).- Fixtures do contrato: o
IntegradorClientTestlê deste arquivo os exemplosjson(listagem e resposta do resolver) e o de requisição do resolver (rota + corpo), e alimenta o servidor fake com eles (ContratoImportado). Atualizar o pin re-testa o parser contra o contrato novo.
O campo é itensRejeitados — não itens
O nome vem do RemessaResponse do dono. Este app lia itens e recebia sempre lista vazia: a
reconciliação gravava "Remessa travada no X-Adm." sem cod_retorno, e sem ele o alerta 1XX
não disparava e o reenvio não re-armava o espelho. Quinze pedidos-kit ficaram parados em silêncio
(2026-09-10). As fixtures de teste também usavam itens e por isso passavam — daí elas saírem,
agora, do exemplo do contrato.
A rota por tabela (GET /api/v1/xadm/retorno/{tabela}) não reconcilia — só alimenta o diagnóstico do painel
Desde 2026-09-10 o detalhe do pedido em Falha a consulta ao vivo (DiagnosticoService →
IntegradorClient.consultarRetorno): a remessa só lista o que foi rejeitado, e o operador
precisa ver o que o X-Adm gravou (e o código do contrato lá) para lançar o resto à mão. Na
reconciliação ela segue fora, pelo motivo abaixo.
Ela responde uma linha por chave natural e cobre só contratos/estoque/itensped/propriedades/
fones — não formulas. Reconciliar por ela deixava item e fórmula rejeitados invisíveis
daqui: o pedido 260079618 ficou CONFIRMADO com o itensped em 120, e 64 das 76 linhas
travadas eram fórmulas. Além disso itensped exige xPed e codigoAlt (sem os dois é
400), então nem dá para varrer um pedido por ela. Ver
decisão 0021.
Contrato do dono ainda não publicado — dívida do Integrador
Este endpoint é do Integrador (XadmRetornoController, criado justamente para a reconciliação
da PIED). Hoje o dono já publica o fato (raw/xadm-retorno.md, que cobre também o re-arme da
§6), mas este repo ainda não o importou — só o xadm-ingest, o xadm-ingest-estoque e o
integracao-remessa. Por isso aqui há link, não cópia: re-digitar o contrato de retorno
recriaria exatamente o apodrecimento que a §1 acabou de matar.
A dívida é do dono, não deste repo (constituição §2): o certo é um PR no
integracao/integrador publicando o fato do retorno, e então importá-lo aqui como os outros
dois. Enquanto isso, a fonte é o código do dono — XadmRetornoController — e o cliente deste
lado é o IntegradorClient.consultarRetornoContrato.
6. Re-arme do espelho — POST /api/v1/xadm/retorno/pedido/reenfileirar¶
Rota que o maxsul-pied chama no PushEnviador, logo depois da entrega confirmada
(IntegradorClient.reenfileirarPedido), e só para pedido que carrega rejeição terminal
(cod_retorno 1XX gravado).
Não é mais o clique do botão — e a razão é a ordem obrigatória abaixo
Até 2026-09 quem chamava era o botão Reimportar do painel. Mas o botão só enfileira: o
PUT sai até 2 min depois, pelo relay. Re-armar no clique reabria a linha no espelho antes
de o dado novo chegar — exatamente o que a ordem obrigatória proíbe. Movendo a chamada para
depois do marcarEnviado, a ordem passa a valer por construção, e vale igual para o caminho
automático (dado novo da PIED reenfileira sozinho — desde 2026-09-10 só o ERRO de envio; o
ERRO_XADM se resolve à mão, revisão da decisão 0006). Ver
decisão 0021.
Por que existe. O write-back (§4) grava cod_retorno nas linhas do espelho, e o cliente do X-Adm
só coleta WHERE cod_retorno = '000' OR LIKE '9%'. Linha em 1XX (rejeição de dado) é invisível
para ele para sempre. O Integrador não zera esse campo sozinho — é a regra de mão única: sem
ela, um re-push de rotina apagaria o retorno de um pedido já gravado e duplicaria pedido no ERP.
Esta rota é a exceção sob comando: quem pede é o operador, depois de corrigir o dado.
Ordem obrigatória: push (§1) primeiro, re-arme depois. São transações separadas — re-armar antes deixaria o cliente do ERP coletar a linha com o dado velho. Com esta ordem, quando a linha volta a ser coletável já está atualizada.
POST /api/v1/xadm/retorno/pedido/reenfileirar?xPed=<code>
Authorization: Bearer $INTEGRADOR_API_TOKEN
Resposta = contagem de linhas re-armadas por tabela; este lado usa o total:
{ "xPed": "260079421", "reArmadas": { "contratos": 1, "itensped": 3, "formulas": 2 }, "total": 6 }
Leitura tolerante de propósito (IntegradorClient.contarReArmadas): usa total quando vier, senão
soma os números do mapa (reArmadas, tabelas ou raiz). Corpo sem número nenhum é contrato
quebrado → erro, nunca "zero linhas" — o painel não pode dizer que reenviou sem ter reenviado.
Zero é resposta legítima (não havia linha em 1XX) e o painel a mostra como tal; falha de transporte
vira flash de erro explícito.
Mesma dívida da §5 — e um pino a mais
Este endpoint também é do Integrador e também não está publicado como fato no raw/ dele, daí
o contrato acima estar re-digitado (o que a §1 combate). Pior: ele nasceu depois deste cliente
— o formato do envelope foi acordado por handoff, não importado. Enquanto o dono não publicar, a
verdade é o código dele (XadmRetornoController) e a tolerância do contarReArmadas é o
para-choque. O certo segue sendo o PR no integracao/integrador publicando §5 e §6.
Tratamento de dados no ZIM (gravação no X-Adm)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-11
O X-Adm roda sobre ZIM (Zim Databases), um 4GL/BD com um modelo de dados diferente de um
Postgres comum. Este documento existe porque o ponto de maior risco da integração é a gravação
final no ZIM feita pelo cliente Java (ps-java, via zimrtmu): se os valores chegarem no formato
errado, o cadastro no X-Adm falha ou grava sujo. Aqui está como o ZIM trata os dados e a regra de
representação adotada no pipeline.
1. Como o ZIM trata os dados¶
Tipos (fonte: Zim Databases):
| Tipo ZIM | Comportamento |
|---|---|
| Character | String de comprimento variável; ignora brancos à direita (trailing), mas espaço à esquerda / embutido é significativo; case-sensitive em ordenação/comparação. |
| NUMERIC | Número armazenado em forma de caractere; pode ter sinal e ponto decimal. |
| INT / LONGINT / VASTINT | Inteiros de 2 / 4 / 8 bytes. VASTINT guarda até 15 dígitos significativos com casas decimais — é um inteiro escalado. O VastInt 0-3 dec / 0-5 dec do MAXSUL2025_610110 é isso: valor com escala fixa (3 ou 5 casas). |
Máscara ≠ valor armazenado — a distinção que costuma confundir:
- O ZIM separa o valor gravado do valor exibido (mascarado). A máscara
9preenche com zeros à esquerda,Zcom espaços, sempre justificando à direita; separadores (/,-,,) são só exibição, não fazem parte do valor. - Exemplo: data gravada
20140628é exibida como28/06/2014com a máscaraDD/MM/YYYY; um código de 5 dígitos é gravado como00123e exibido conforme a máscara.
Consequência prática: aquele " 001" que se vê numa tela é a exibição mascarada, não o que
está gravado. Não se deve enviar o valor mascarado ao gravar — envia-se o valor de armazenamento.
E esse valor depende do tipo ZIM do campo:
| Campo (exemplos) | Tipo ZIM | Valor de armazenamento |
|---|---|---|
NatOp (5.101), CodRetorno (001) |
Character | a string literal — trailing blank irrelevante; espaço à esquerda é significativo. |
ChaveUnid (" 1 29"), CodMat (" 1 0 134") |
Character | chave interna do X-Adm com espaços embutidos significativos — vai byte a byte. |
VlTot, Qtde, Valor |
VastInt (N dec) | número com escala fixa — o perigo é float (arredondamento binário), não padding. |
DtEm, DataInc |
Date (8) | AAAAMMDD, sem separadores. |
2. Regra de representação no pipeline (decisão)¶
Decisão: ao longo do pipeline (Postgres → PowerSync → SQLite → cliente Java), os campos viajam
como valores tipados/semânticos — não pré-formatados para o ZIM. A formatação para o formato de
armazenamento do ZIM acontece no cliente, na fronteira com o zimrtmu (onde a equipe do ERP conhece
largura, escala e máscara exatas de cada campo).
- Vantagem: o pipeline carrega dados limpos; a lógica específica do ZIM fica num único ponto (o cliente/ERP), que já tem esse contexto.
- Contrapartida (o risco a mitigar): o cliente precisa aplicar corretamente as regras da §3. Este documento é a especificação dessas regras para a equipe do ERP.
Alternativa considerada e não adotada: guardar tudo já pré-formatado como TEXT verbatim (o transform do maxsul-pied formataria e o cliente só repassaria). Rejeitada nesta fase — concentra a formatação onde há menos conhecimento do alvo ZIM. Pode ser reavaliada se o cliente acumular lógica de formatação demais.
3. Como cada tipo deve ser representado e formatado¶
| Origem (Postgres) | Tipo Postgres | Regra no cliente (→ ZIM) |
|---|---|---|
Código de texto (NatOp, CodRetorno, ClassForma) |
TEXT |
Repassar literal; preservar espaços à esquerda/embutidos. Se o campo ZIM for Character de largura fixa, ajustar largura conforme o contrato do zimrtmu. |
Chave interna do X-Adm (ChaveUnid, CodMat, Chave*) |
TEXT |
Verbatim, byte a byte — nunca “limpar” espaços. |
Valor monetário / quantidade (VlTot, Valor, Qtde, TaxaFrete) |
NUMERIC |
Usar BigDecimal/NUMERIC fim a fim (nunca double/float). Aplicar a escala fixa do campo (ex.: Valor = 5 casas) na hora de gravar. |
Data (DtEm, DataInc) |
DATE |
Formatar como AAAAMMDD (sem separadores). |
Hora (HoraInc) |
TEXT |
HHMM (Char 4). |
| Campo não informado | — | Gravar em branco, não em nulo (regra X-Adm — Mapeamento §1). |
4. Armadilhas conhecidas (checklist para o cliente/ERP)¶
- Float em valor/quantidade → arredondamento. Use decimal com escala fixa fim a fim.
- Perder espaço à esquerda/embutido de chave Character (
ChaveUnid,CodMat) → chave inválida no X-Adm. Trate como opaco. - Confundir mascarado com armazenado (enviar
" 001"em vez de001/1conforme o tipo). - Nulo em vez de branco em campo Character não informado.
- Escala errada de VastInt (3 vs 5 casas) entre entidades.
5. Pendências (confirmar com a equipe X-Adm)¶
- Contrato de entrada do
zimrtmu: ele recebe o valor de armazenamento ou o mascarado? Qual o formato de ingestão (JSON, string;-separada)? - Largura / escala / padding por campo de cada uma das cinco tabelas espelho.
- Código
Origempara os writes originados da PIED no Integrador (ver API do Integrador — hoje oKNOWN_ORIGENSnão tem um).
Fontes (Zim Databases): How To Use Data Types · Number Data Types · Mask.
Decisões
0001 — MVP entra em produção só capturando raw¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-10 · Decidido em: 2026-07-10
Contexto¶
O caminho robusto da integração PIED → X-Adm prevê webhook + poll + transform + entrega via
PowerSync. A documentação da PIED já se mostrou imprecisa uma vez: o pré-projeto descrevia um
delta implícito (apiSent/apiSentUpdated) que não existe na API real — só a validação com
token de produção revelou o mecanismo que funciona (?lastUpdateAfter, ADR 0002 do
poc-pied-simples). Para o webhook a situação é pior: os eventos, formas de payload e autenticação
estão descritos, mas nunca foram observados em produção.
Escrever o transform em cima de contrato não observado repetiria o erro do pré-projeto — com custo maior, porque transform errado suja o staging e o ERP.
Decisão¶
O MVP entra em produção apenas capturando dados brutos: recepção de webhook em
pied_webhook e poll REST em pied_rest, sem nenhuma transformação. A integração com o X-Adm
(fase 2) nasce desligada por toggle (pied.integracao.habilitada=false /
PIED_INTEGRACAO_HABILITADA) — hoje só o esqueleto do toggle existe no código.
Prioridade máxima é o tempo até produção: quanto antes o serviço estiver no ar, mais cedo há payloads reais acumulados para projetar a fase 2 com evidência.
Consequências¶
- A análise da API (volumes, eventos que chegam de fato, campos por payload) é feita com SQL nas tabelas landing, com dados reais.
- O webhook não rejeita payload inesperado — evento desconhecido é dado capturado (e a PIED não fica reentregando por erro nosso). Corpo não-JSON é envelopado como string JSON.
- Nenhuma escrita no ERP ou no staging do Integrador nesta fase; risco operacional é mínimo.
- O transform da fase 2 poderá reprocessar todo o histórico capturado desde o dia 1 (o raw é persistido antes de qualquer processamento — blueprint do poc-pied).
- Custo aceito: dados ficam "parados" no landing até a fase 2; a redigitação manual continua nesse meio-tempo.
Alternativas consideradas¶
- Entregar já com transform (MVP completo): rejeitado — contrato do webhook não observado; retrabalho quase certo e prazo maior até produção.
- Capturar só via poll (sem webhook): rejeitado — o webhook é exatamente a parte que precisa de observação em produção, e recebê-lo não custa quase nada.
0002 — Caminho robusto (servidor always-on)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-11 · Decidido em: 2026-07-09
Contexto¶
O pré-projeto da integração PIED → X-Adm avaliou dois caminhos:
- Caminho 1 — integração simples: cliente Java CLI que puxa a API REST sob demanda/agendado
e entrega arquivos JSON ao X-Adm. Foi aprovado primeiro e validado em produção como
poc-pied-simples(contrato da API comprovado com token real em 2026-06-17). - Caminho 2 — robusto: servidor always-on na nuvem com webhook (tempo real) + poll REST +
staging Postgres + entrega desacoplada via PowerSync até um cliente Java junto ao ERP. Foi
desenhado em detalhe no
poc-pied(ARQUITETURA.md,servidor/SPEC.md).
O caminho 1 cumpre o essencial, mas depende de acionamento pelo lado X-Adm, não tem tempo real e não deixa trilha em banco para análise/reprocessamento contínuo.
Decisão¶
A diretoria aprovou o caminho 2 (robusto) em 2026-07-09 (Marcelo Garabeli). Adotado neste
projeto (maxsul-pied, publicado em pied.maxsul.xadm.biz), reaproveitando dos POCs o que já foi
comprovado:
- do poc-pied-simples: o contrato validado da API (endpoints, envelope, paginação 1-based com
limite 50, parada em página vazia) e a estratégia incremental por
?lastUpdateAftersó para pedidos (ADR 0002 de lá); - do poc-pied: os princípios do servidor — persistir raw ANTES de processar, webhook responde 200 rápido sem processamento síncrono, nunca escrever síncrono no ERP (entrega via staging + PowerSync), e a constatação de que o webhook sozinho não cobre tudo (o poll REST é obrigatório, não só rede de segurança).
A implantação é em fases (ver decisão 0001): fase 1 captura raw; fase 2 transform + entrega.
Consequências¶
- Banco compartilhado, donos separados. O Integrador (instância Maxsul) e o
maxsul-piedconectam no mesmo Postgres. O Integrador é dono das tabelas espelho do X-Adm (contratos,propriedades,fones,itensped,estoque); omaxsul-piedé dono das tabelas PIED (pied_webhook_*,pied_request_*,pied_produto,pied_cliente,pied_pedido). - Escrita cruzada só por contrato REST. O
maxsul-piedgrava nas tabelas do Integrador pelo mesmoPUT/DELETE /api/v1/xadmque o agente on-premises do X-Adm já usa — não por SQL direto. Preserva a serialização (ReentrantLock), a auditoria (request) e o soft-delete/revive do Integrador. O PowerSync lê as tabelas do Integrador. - Um serviço always-on a operar (Coolify, healthcheck, Postgres compartilhado) — custo de operação aceito em troca de tempo real e trilha completa em banco.
- O cursor incremental sai do
estado.json(arquivo, caminho 1) para a tabelapied_cursor. - O
poc-pied-simplespermanece como está: referência validada e fallback operacional; este projeto não o substitui até a fase 2 estar entregue. - Os 4 componentes da solução (este app, Integrador Maxsul, PowerSync, cliente Java no ERP) estão descritos na Documentação Completa.
Alternativas consideradas¶
- Continuar só com o caminho 1: rejeitado para o alvo final — sem tempo real, dependente de acionamento manual/agendado do lado ERP e sem trilha em banco.
- Webhook sem poll: rejeitado — lacunas conhecidas de eventos (produtos não têm webhook; alterações de frete/responsável não disparam evento), conforme o blueprint do poc-pied.
0003 — Valores tipados no pipeline, formatação ZIM no cliente¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-11 · Decidido em: 2026-07-11
Contexto¶
O X-Adm roda sobre ZIM, cujo modelo de dados difere de um Postgres comum: campos Character de
comprimento variável (espaço à esquerda/embutido é significativo, trailing é ignorado), numéricos
VastInt escalados (inteiro com N decimais implícitos), e — crucial — o valor armazenado difere
do exibido (a máscara 9/Z/? e os separadores são só apresentação). Ver
Tratamento de dados no ZIM.
Esses dados cruzam Postgres → PowerSync → SQLite → cliente Java → ZIM. PowerSync/SQLite são fracamente tipados. A dúvida: em que ponto formatar para o formato de armazenamento do ZIM (largura, escala, padding)?
Decisão¶
Os campos viajam tipados/semânticos no pipeline (NUMERIC, TEXT, DATE no Postgres) — não
pré-formatados. A formatação para o ZIM acontece no cliente, na fronteira com o zimrtmu, onde a
equipe do ERP conhece largura, escala e máscara exatas de cada campo.
Consequências¶
- O pipeline carrega dados limpos; a lógica específica do ZIM fica num único ponto (o cliente/ERP).
- O cliente precisa aplicar as regras de formatação por tipo — especificadas em
integracao-zim.md §3 (Character com espaços significativos verbatim,
BigDecimal/escala fixa semdouble, datasAAAAMMDD, "branco não nulo"). - Fica em aberto o contrato de entrada do
zimrtmu(valor-armazenado vs mascarado; largura/escala por campo) — Pendências P3. Até lá, ops-javaé exemplo simulado.
Alternativas consideradas¶
- Pré-formatar tudo como TEXT verbatim no transform (o maxsul-pied produziria a string exata do ZIM e o cliente só repassaria): rejeitada nesta fase — concentra a formatação onde há menos conhecimento do alvo ZIM. Pode ser reavaliada se o cliente acumular lógica de formatação demais.
0004 — MODO staging: gate manual da entrega ao Integrador¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-11 · Decidido em: 2026-07-11
Contexto¶
A Fase 1 (decisão 0001) só captura raw; a Fase 2 fará o transform e o push ao Integrador, que chega no ERP do cliente via PowerSync → cliente Java. Antes de confiar nesse fluxo em produção, a equipe X-Adm precisa validar a integração ponta a ponta — mas um push automático no primeiro deploy da Fase 2 inunda o ERP do cliente com dado de teste, sem chance de inspecionar pedido a pedido o que está sendo gravado.
Falta um gate controlável sobre o passo de entrega — separado do transform, para poder segurar o envio sem parar a captura nem a normalização.
Decisão¶
Uma variável de ambiente MODO (env do Coolify, PRODUCAO | STAGING, default PRODUCAO)
governa o push ao Integrador na Fase 2:
PRODUCAO— fluxo natural da Fase 2: cada pedido normalizado e elegível é enviado automaticamente ao Integrador.STAGING— o transform roda e o pedido é enfileirado como pendente; nada é enviado ao Integrador até um comando manual. A liberação se dá por uma tela com:- Processar próximo — solta exatamente 1 item da fila (inspeção item a item);
- Processar todos os pendentes — libera a fila inteira, para quando a confiança subir.
A fila é sobre o normalizado, não sobre o raw. O que se processa é um pedido (pied_pedido, Fase
2), não uma página crua (pied_rest, Fase 1). O estado vive como status na tabela normalizada
(pendente → enviando → enviado | erro); o transform sempre roda até pendente — o MODO só decide
se o passo seguinte (push) é automático ou manual.
Precedência sobre o toggle existente: o pied.integracao.habilitada (esqueleto de hoje) liga a
Fase 2 como um todo; o MODO refina como ela entrega. Combinação:
pied.integracao.habilitada |
MODO |
Comportamento |
|---|---|---|
false |
(ignorado) | Fase 2 desligada — só captura raw (estado atual, Fase 1). |
true |
PRODUCAO |
Transform + push automático ao Integrador. |
true |
STAGING |
Transform + enfileira pendente; push só por comando na tela. |
Consequências¶
- A Fase 2 nasce com o transform desacoplado do push. O passo de entrega é uma etapa separada,
gated pelo
MODO— não dá para "colar" o push dentro do transform sem quebrar o gate. - Status de entrega na tabela normalizada (
pied_pedido), com o ciclopendente/enviando/enviado/ erro— vira também a base de reprocessamento e de auditoria da entrega. - Superfície nova: tela + auth. Este app hoje é API pura, sem views nem security
(
build.gradle.kts). A tela de fila (processar próximo / todos) e seu login-gate são escopo de Fase 2, a desenhar junto com o transform. MODOé env do Coolify, defaultPRODUCAO— o comportamento seguro de produção é o padrão; STAGING é opt-in por deploy/ambiente de teste.- Não muda a Fase 1 (spec
001) nem o código atual — é uma restrição de desenho que a Fase 2 herda, não trabalho a fazer agora.
Alternativas consideradas¶
- Toggle único liga/desliga o push, sem fila nem tela: rejeitada — desligar o push segura tudo, mas não deixa inspecionar e liberar pedido a pedido, que é justamente o valor do teste da equipe.
- Push sempre automático (sem
MODO): rejeitada — arrisca inundar o ERP do cliente com dado de teste no primeiro ciclo da Fase 2. - Só um ambiente de staging separado (deploy apontando a um Integrador de teste): complementar, não
substituto — resolve onde grava, não dá o controle manual passo a passo contra o fluxo real.
Pode coexistir com o
MODO.
0005 — Alerta por e-mail no erro terminal do pedido¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-14 · Decidido em: 2026-07-14
Contexto¶
Na Fase 2, um pedido pode ser rejeitado pelo X-Adm na reconciliação do retorno
(decisão 0004). A máquina de status distingue dois tipos de
rejeição pelo codRetorno cru: 9XX reprocessável (dá para reenfileirar) e 1XX terminal
(rejeição pelos dados do pedido — irrecuperável). Hoje esse desfecho é silencioso: só quem abre
o /console percebe. A operação da Maxsul precisa ser avisada quando um pedido morre assim.
Distinção-chave: é um erro do pedido/dado, não do sistema. Erros de sistema (transporte,
exceção) já vão ao GlitchTip via o SentryAppender do logback — não é isso que se quer notificar aqui.
Decisão¶
Quando um pedido entra em ERRO_XADM com codRetorno iniciando em "1" (terminal), o app envia um
e-mail aos destinatários cadastrados, no ponto único ReconciliacaoJob.reconciliar (que serve tanto o
job agendado quanto a ação "Atualizar" do /console). Um pacote novo alerta/:
- Destinatários — tabela
pied_destinatario_alerta(nome + e-mail, único porlower(email)), mantida por uma tela Admin aberta/destinatarios(CRUD hard-delete), no mesmo padrão de/console(form-urlencoded, PRG com?flash=). - Envio via Resend —
ResendClientsobre ojava.net.http.HttpClientdo JDK (idioma da casa, espelhaIntegradorClient),POST /emailscomAuthorization: Bearer; um e-mail com todos os destinatários noto[]. O corpo trazcode,codRetorno,msgRetorno,chaveXadm+ link{base-url}/pedido/{code}. - Best-effort — se desabilitado, sem chave/remetente, sem destinatários, ou se o envio falhar, o
AlertaServicesó loga (LOG.error→ GlitchTip) e retorna normal: nunca quebra a reconciliação nem altera a máquina de status. - Nasce desligado (
pied.alerta.habilitado=false) e depende da Fase 2 — fica dormente até a integração ser ligada. Configuração em operação/configuração.
Revisão (2026-08-14) — o 1XX ganha reentrada manual no painel. O detalhe do pedido no painel (0006) passou a exibir o
msgRetornodo X-Adm no erro terminal (1XX = rejeição de dado, acionável por quem cadastra, ex.: "Cliente CPF … não cadastrado.") e a oferecer um botão Re-Importar que reenfileira e reenvia. Consequência semântica: "1XX terminal" é terminal-até-corrigir, não "nunca reprocessa" — o operador corrige o dado no X-Adm e reenvia. O que não mudou: o gate automático e a reconciliação seguem tratando o 1XX como final (nunca re-tentam sozinhos), o e-mail de alerta continua só no 1XX, e o/consolemantém sua regra de barrar o 1XX no reenviar. A reentrada é manual (clique do operador), nunca automática.Revisão (2026-09-08) — reentrada manual só funciona se o espelho for re-armado. O botão Re-Importar dizia "Reenvio: ENFILEIRADO" e não reenviava: reenfileirar (
pied_pedido → NA_FILA) e empurrar o dado não bastam, porque quem manda no reprocessamento é ocontratos.cod_retornodo espelho — escrito pelo write-back e não tocado pelo push. O cliente do X-Adm só coleta000|9XX, então linha em1XXfica invisível para ele para sempre (caso real: pedido 260079421, três cliques, zero reenvios). O painel passou a chamarPOST /api/v1/xadm/retorno/pedido/reenfileirarno Integrador depois do push (ordem: dado fresco primeiro, fila reaberta depois — transações separadas), e o flash agora conta a verdade (N linhas reabertas · nada a reabrir · falhou). O que não mudou: gate automático e reconciliação seguem tratando 1XX como final, e o/consolemantém a regra de barrar 1XX no reenviar (decidido em 2026-09-08: só o painel faz recuperação manual).Revisão (2026-09-10) — a reentrada padrão do 1XX é no X-Adm, à mão. O X-Adm é caminho só de ida e o re-arme só reabre o que falhou; nos casos reais (incidente dos 16 pedidos-kit) reimportar não resolvia. O painel passa a orientar o lançamento à mão + "Importado manualmente" e só oferece o Re-Importar com parte pendente (filho travado por pai
1XX) — ver 0006. O reenvio automático por dado novo da PIED deixa de pegarERRO_XADMe fica só com oERROde envio. O e-mail não muda: só no 1XX, com o link do detalhe.Revisão (2026-09-14) — a marca de "já alertado" passa a morar no banco. O e-mail sai uma vez por (
code,codRetorno), porque a rodada revisita asTRAVADAa cada minuto. Essa marca vivia na RAM doReconciliacaoJob, com a premissa de que realertar uma vez a cada reinício era desejável. Medido em produção: dois reboots do servidor reenviaram os mesmos 20 alertas antigos duas vezes (80 e-mails, 80% da cota diária do Resend, que é compartilhada com outros apps). A premissa caiu. O último código alertado fica empied_pedido.alerta_cod_retorno(V13), e o envio só acontece quando oUPDATE … WHERE alerta_cod_retorno IS DISTINCT FROM :codRetornograva alguma linha. A marca zera quando o pedido confirma ou é reenfileirado: uma nova rejeição depois de uma nova tentativa volta a alertar, mesmo com o mesmo código. A V13 preenche a marca dosERRO_XADM1XX existentes para o deploy não disparar nada. Trade-off aceito: a marca é gravada antes do envio, então um e-mail que falha (cota esgotada, Resend fora) não é reenviado. Antes, o reinício fazia papel de retry por acidente. Isso segue a linha de "Retry/fila de e-mail" abaixo: a falha fica noLOG.error(GlitchTip), e o pedido continua em Falha no painel. Preterido: manter a memória na RAM e aceitar o realerta no boot. O custo foi medido, e a cota é de todos os apps.
Consequências¶
- Notificação só no 1XX terminal. 9XX reprocessável e transporte não geram e-mail (seguem o fluxo de reprocesso / GlitchTip) — evita ruído em falha transitória.
- Superfície nova sem auth. A tela
/destinatariossegue a postura atual (aberta atrás da rede, como/consolee/dados); o hardening por login continua deferido para quando a Fase 2 trouxer auth. - Dependência externa nova (Resend) + segredo.
RESEND_API_KEYe o remetente vêm de env; o domínio do remetente precisa ser verificado no Resend, senão o envio é recusado (tratado como best-effort). - O link
/pedido/{code}do e-mail aponta para o painel colaborador (home pública + detalhe do pedido sem login) — uma feature separada (próxima linhagem). Até ela existir, o link pode não resolver; aceitável enquanto tudo está dormente.
Alternativas consideradas¶
- SMTP (micronaut-email) em vez de provedor HTTP: rejeitada — preferência por API de provedor (Resend), menos infra de e-mail para operar.
- Notificar todo estado de erro (
ERRO/9XX incluídos): rejeitada — ruído; o pedido do usuário é sobre o irrecuperável, e os reprocessáveis têm caminho próprio no console. - Retry/fila de e-mail: fora de escopo — para um alerta operacional, best-effort com
LOG.error(→ GlitchTip) basta; a falha de e-mail fica observável sem uma fila dedicada. - Fechar a tela com login agora (micronaut-security): rejeitada — escopo grande que valeria para todas as telas; segue a dívida já registrada, a resolver junto da auth da Fase 2.
0006 — Painel colaborador: dashboard e detalhe abertos para o usuário Maxsul¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-08 · Decidido em: 2026-07-14
Contexto¶
A home (/) era um painel de cards de contagem por tabela — útil para diagnóstico, impróprio para o
usuário final da Maxsul, que precisa acompanhar seus pedidos e onde cada um está rumo ao X-Adm,
sem jargão de integração e sem login. Além disso, o alerta de erro terminal
(decisão 0005) já linka o e-mail para o detalhe do pedido, que não
existia.
Decisão¶
Duas telas abertas (sem auth, atrás da rede — postura atual do app) num chrome limpo da marca
XADM (layout-painel, sem os menus de operador), no pacote painel/:
GET /— dashboard: tiles de resumo por status + lista de pedidos (nº, cliente via joinpied_cliente, data, badge), linha clicável →/pedidos/{code}, filtro?status=<slug>.GET /pedidos/{code}— detalhe: timeline do status + dados (nº, cliente, documento, datas) + itens (payload.products[]).codeinexistente → 404 amigável. Pedido Falhou → mensagem amigável, semcodRetorno/de-para.
Vocabulário de status (4 buckets) — tradução única (painel/StatusBucket) do status técnico:
Recebido (CAPTURADO,NA_FILA) · Em processamento (ENVIANDO,ENVIADO) · Importado (CONFIRMADO)
· Falhou (ERRO,ERRO_XADM); desconhecido→Recebido. Nenhum segundo campo de estado — a fonte segue
sendo pied_pedido.status.
Revisão (spec
.ia/003, 2026-08-07). O vocabulário dos 4 buckets foi reescrito para refletir a esteira real: Pedidos em aberto (CAPTURADO) · Enviando ao X-Adm (NA_FILA, ENVIANDO, ENVIADO, ERRO) · Importado no X-Adm (CONFIRMADO) · Erro (ERRO_XADM); desconhecido→Pedidos em aberto.ERRO(transporte, transitório) passou de "Falhou" para Enviando ao X-Adm; sóERRO_XADM(rejeição final do X-Adm) fica em Erro. A decisão estrutural (duas telas abertas, chrome limpo, fonte únicaStatusBucket) permanece — mudou só o rótulo/agrupamento. Título da home: "Importação de Pedidos PIED → X-Adm"; lista paginada porlast_update.Revisão (prontidão, 2026-09-04).
NA_FILAdeixou de ser traduzido só pelo nome: sem oinvoiceno payload oPushServicesegura o pedido na fila (pode ser horas, até o poll REST passar), então ele volta para Pedidos em aberto — anunciar "Enviando ao X-Adm" enquanto nada sai era mentira visível ao cliente. Cominvoice, segue Enviando ao X-Adm. A fonte única continua oStatusBucket, que agora recebe a prontidão como 2º insumo (de(status, pronto)+sqlFiltro()para tiles/lista/filtro lerem a mesma regra). A outra metade do mesmo incidente — encurtar a espera — está na decisão 0017, que também guarda o diagnóstico.Revisão (2026-09-04) — dois eixos de status na lista (integração × PIED). A coluna única "Status" confundia dois conceitos: "Pedidos em aberto" é o nosso estado de integração, mas na PIED o mesmo pedido já pode constar pago. A lista do dashboard passa a ter duas colunas ortogonais: Status Integração (o bucket do
StatusBucket, nosso lado) e Pagamento PIED (pagamento traduzido pelo novopainel/PagamentoPied+deal_statuscru). Segue "nenhum segundo campo de estado" no sentido de fonte —PagamentoPiedsó traduzpied_pedido.payment_status, não é estado novo. Vocabulário dos dois eixos no glossário.
Reorganização: a home de diagnóstico (cards) migra de / para /homedev (o navbar de operador
aponta para lá). As demais telas internas (/console, /dados, /captura, /health,
/destinatarios) ficam inalteradas.
Reconciliação: o link do e-mail de alerta (0005) passou de /pedido/{code} para /pedidos/{code}.
Consequências¶
- Duas superfícies de UI: painel do usuário (chrome limpo) × telas de operador (navbar cheio) — separadas por layout, sem misturar menus.
- Read-only: o painel não tem ações de escrita; reenviar/enfileirar seguem só no
/console. - Aberto, sem auth: mantém a postura atual (dívida de login deferida, como
/consolee/dados). - Vocabulário amigável durável: registrado no glossário; o
StatusBucketé a fonte única (tiles, lista e timeline consomem dele). - Dependência do link:
/pedidos/{code}agora resolve — fecha a promessa do e-mail da 0005.
Revisão (2026-08-14) — painel expõe o motivo do erro terminal e deixa de ser read-only. Duas afirmações acima estão superadas: (a) "detalhe … sem
codRetorno/de-para" — o detalhe agora mostra omsgRetornodo X-Adm no erro terminal (1XX), limpo do traço de prefixo, com orientação de corrigir e reimportar; 9XX/transporte mantêm o aviso genérico. (b) "read-only … sem ações de escrita; reenviar/enfileirar seguem só no /console" — o painel já ganhara Importar/Dispensar (pedido em aberto pago) e agora Re-Importar no detalhe de qualquer pedido em Erro (reenfileira + envia na hora, espelhando o/console). A distinção 1XX×9XX e a regra de não re-tentar o 1XX automaticamente vivem na 0005.Revisão (2026-09-08) — o Re-Importar re-arma o espelho, e o flash conta a verdade. "Reenfileira + envia na hora" era insuficiente: sem re-armar o
cod_retornoda linha no espelho o X-Adm nunca reprocessava, e o painel dizia "ENFILEIRADO" mesmo assim. O botão agora chamaPOST /api/v1/xadm/retorno/pedido/reenfileirardepois do push (ver api-integrador §6) e o flash reporta o desfecho real — N linhas reabertas · nada a reabrir · falha explícita. Nunca dizer "reenviado" quando a chamada falhou é requisito de produto, não detalhe: foi o que fez o operador clicar três vezes no mesmo pedido.Revisão (2026-09-10) — a Falha diz o que falta e pode ser fechada à mão. Incidente dos 15 kits: o contrato gravava no X-Adm e o produto-kit, a composição ou o item não — e o detalhe só dizia "Remessa travada". Duas mudanças: (a) o detalhe em Falha ganha um diagnóstico ao vivo (tipo do pedido, Gravado no X-Adm × NÃO gravado — lance manualmente com os dados do envio, avisos "Possível" tirados do texto do X-Adm), consultando o Integrador na abertura — decidido ao vivo, sem persistir (sem migração, sempre fresco; Integrador fora → aviso no lugar); (b) botão Importado manualmente ao lado do Re-Importar (e na home, nas linhas em Falha), que leva
ERRO_XADM → IMPORTADO_MANUAL— o mesmo status do Dispensar, em vez de um novo — com observação obrigatória (importacao_manual_obs, V12), porque o painel não tem login e ela é o único rastro de o que foi lançado e por quem. A reconciliação passa a ignorarIMPORTADO_MANUAL(senão a remessa, que segueTRAVADAno Integrador, devolvia o pedido à Falha a cada minuto). Rotas e texto em configuração.Revisão (2026-09-10, mesma data) — o botão fecha também a remessa no Integrador. O dono ganhou o
resolver-manual(TRAVADA → RESOLVIDA_MANUAL, decisão 0025 dele), e o clique passa a chamá-lo antes de marcar o pedido aqui: só o200fecha o pedido;409(parte do pedido ainda pendente de entrega — remessaABERTA, ouTRAVADAcom filho000bloqueado, que fechar soltaria para o ERP) e falha de transporte o deixam em Falha, com o motivo. Decidido Integrador primeiro — na ordem inversa, uma falha do comando deixaria a remessa travada lá para sempre, sem reconciliação que a revisite; e sem retry em fila (M2M), porque o operador está na tela e pode clicar de novo. Ganha também um campo operador, opcional (o dono o aceita no corpo; o painel segue sem login), gravado localmente no fim da observação — sem migração nova. Detalhe em API do Integrador §5.Revisão (2026-09-10, terceira) — a Falha tem um caminho só: lançar à mão. O X-Adm é caminho só de ida: o que ele gravou (
006) é definitivo e a integração nunca reenvia, e o re-arme só reabre1XX— reimportar serve, no máximo, para a parte que falhou, e nos 16 pedidos-kit do incidente nem isso resolvia (defeitos do lado ZIM,integrador-clientdocs/dev/contrato-pabast.md§8). Decidido: (a) o caminho padrão de toda Falha é conferir no X-Adm → lançar à mão o que falta → Importado manualmente, num "O que fazer" único, com o aviso de não excluir no X-Adm o que está em Gravado; (b) o Re-Importar só aparece com parte do pedido pendente (linha no espelho que não é006nem1XX— o filho que espera um pai rejeitado, ex. cliente não cadastrado): aí oresolver-manualresponde409por desenho e o caminho é corrigir e reimportar. O409também o põe à mostra (?pendente=1), cobrindoformulas, que o diagnóstico não consulta; (c) o reenvio automático por dado novo da PIED deixa de tocarERRO_XADM(ver 0005) — reenviar sozinho re-armaria o1XXenquanto o operador lança o mesmo à mão; (d) o diagnóstico completa os dados de lançamento: nome de cada componente, produto do item do kit, venda a prazo e, na duplicidade de contrato, os dois códigos e a ordem de escolher qual fica antes de lançar. Rejeitado: excluir no X-Adm e reimportar — esbarra na mão única e exigiria no Integrador um re-arme de006, com risco de duplicar pedido no ERP.
Alternativas consideradas¶
- Reusar o layout/navbar de operador nas telas do usuário: rejeitada — vaza ferramenta interna e foge do "limpo e elegante".
- Mostrar o estado técnico cru (CAPTURADO/ENVIADO/…): rejeitada — jargão de integração para o usuário final; os 4 buckets comunicam "onde meu pedido está".
- Manter a home de cards em
/e pôr o painel em outra rota: rejeitada — o usuário final é o público primário da raiz; o diagnóstico é secundário (vai para/homedev). - Valor monetário e busca no dashboard, ações no detalhe: fora de escopo do MVP (mantém enxuto).
0007 — Saneamento de texto para o X-Adm: sem diacríticos e caixa alta¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-21 · Decidido em: 2026-07-21
Contexto¶
O X-Adm — ERP legado da Maxsul — historicamente exige texto em caixa alta e sem
acentos nos campos texto-livre (Painel Solar 550W chega como PAINEL SOLAR 550W).
O TransformService já emitia o NomeProd do Kit assim ("GERADOR FOTOVOLTAICO DE
CORRENTE CONTINUA COM POTENCIA …", ver mapeamento §4),
mas os demais textos vindos do PIED (nome, razão social, endereço, cidade, contato,
nome de produto do fluxo não-Kit) passavam como recebidos — mistura de caixa e
com diacríticos.
Ao avaliar, três candidatos:
- Convenção do X-Adm — regra do ERP, aplicável por campo (não a tudo).
- Limitação de charset do ZIM — regra do banco, universal para toda saída.
- Não fazer — deixar o próprio driver
zimrtmudecidir.
A Maxsul confirmou: é (1) — convenção do ERP, não do ZIM. Logo, a regra é por campo, não universal, e a escolha da allowlist é nossa.
Decisão¶
Introduzir SaneadorTexto.paraXadm(String) (NFD + strip \p{Mn} + toUpperCase(ROOT),
null/blank → null preservando a regra "branco não nulo"), aplicado no
TransformService na seguinte allowlist de campos texto-livre destinados ao X-Adm:
| Aplica | Campos X-Adm |
|---|---|
| ✅ | propriedades.NomeProp, propriedades.Fantasia, propriedades.Endereco, propriedades.Bairro, propriedades.Cidade, propriedades.Numero, fones.Nome, estoque.NomeProd (Kit e não-Kit) |
| ❌ | fones.Email — local-part é case-sensitive por RFC; uppercase + strip quebra entrega |
| — | CgcCpf, CEP, DDD, Telefone, InscEst (só dígitos), Estado (UF já em caixa alta), códigos (xPed, CodigoAlt, NatOp, Compl, TpVda, ClassForma) |
O saneador vive na camada de transform, imediatamente antes de compor o
EstadoDesejado. O pied_pedido.payload (e demais landing pied_*) permanece cru
— saneamento não toca a trilha de auditoria, só a saída para o Integrador.
Consequências¶
- O de-para fica consistente: o que chega no X-Adm sempre segue a convenção do ERP, sem depender de dado bem-comportado na PIED.
- O
NomeProddo Kit deixa de ser literal hardcoded em caixa alta e passa pelo mesmo saneador — se o texto-base mudar amanhã, continua saneado. Efeito colateral: okWviraKW(aceitável — a regra é global sobre o campo). - E-mail preservado — evita quebra de entrega com provedores que respeitam case no local-part.
- Reversível: o saneador é uma função pura sem estado; retirar/mudar a allowlist é
uma edição pontual no
TransformService. Idempotente: aplicar duas vezes dá o mesmo resultado.
Alternativas consideradas¶
- Sanear tudo (blanket) — rejeitado: quebra
Email(case-sensitive por RFC), poluiria códigos/dígitos sem ganho, e o próprio Estado (UF) já vem em caixa alta. - Sanear no
NormalizadorService(gravar já sem acento) — rejeitado: perde a fidelidade dopied_cliente/pied_produto/pied_pedidocomo espelho do PIED, dificultando diagnóstico e reprocessamento futuro. - Deixar para o
zimrtmu— rejeitado: é convenção do ERP, não do ZIM (fonte da regra confirmada com a Maxsul); e oTransformServiceé o lugar onde o de-para já está — mantém a regra visível no ponto onde os campos são de-parados.
0008 — Adota xadm-comum-web (base transversal da casa) e desliga o security transitivo¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-05 · Decidido em: 2026-08-05
Contexto¶
O int-pied mantinha, no pacote comum, cópias divergentes da base transversal que a casa extraiu
para a lib br.com.xadm:xadm-comum-web:0.1.0 (br.com.xadm.comum.web, publicada no Forgejo
Packages): ProblemDetail e UnifiedErrorResponseProcessor (byte-idênticos ao canônico),
VersaoProvider (mesmo papel de VersaoInfo, nome divergente) e HealthController respondendo
{status:"ok"} sem @Secured (o canônico é {status:"UP"} + @Secured(IS_ANONYMOUS)). maxsul
era o único dos 4 apps da casa que ainda não consumia a lib — sem o repo maven do Forgejo, sem
xadm-seguranca.
O refino da spec pegou dois fatos que só o artefato publicado responde: o POM da lib declara
io.micronaut.security:micronaut-security em scope compile — logo, transitivo — e o shape real
do /health. maxsul é deliberadamente aberto, sem auth (diagnóstico atrás da rede — decisões
0004/0005). No Micronaut, micronaut-security no classpath liga o SecurityFilter por default e
rejeita (401) todo endpoint não-@Secured — o que trancaria o webhook, as telas e o próprio
/health do healthcheck.
Decisão¶
Consumir a lib e remover as cópias, reconciliando nome/contrato para o canônico:
build.gradle.kts: repo maven do Forgejo (https://fonte.xadm.biz/api/packages/xadm/maven, org pública → leitura anônima) +implementation("br.com.xadm:xadm-comum-web:0.1.0").- Deletadas as cópias locais (
ProblemDetail,UnifiedErrorResponseProcessor,HealthController,VersaoProvider); imports migrados parabr.com.xadm.comum.web(WebhookController,GlobalExceptionHandler,Application,GlobalViewModel). - Security transitivo desligado em
application.yml:micronaut.security.enabled: false. Mantém a postura "tudo aberto" e deixa o@Secured(IS_ANONYMOUS)da lib inerte — não há SecurityFilter para interceptar. - Contrato do
/health:ok→UP(mudança observável). A lib mantém o campoversao(shape §5{status, versao}intacto; verificado pelo gate). Docs de operação e o teste atualizados.
Consequências¶
- Uma fonte única para a base transversal (§1.9): correção/evolução na lib chega aos 4 apps sem
divergência de cópia.
VersaoProviderdeixa de existir;VersaoInfolê o mesmo/version.properties#app.version(a taskgerarVersionPropertiesdo build permanece). micronaut-security(emicronaut-management) agora estão no classpath, apenas desligados. Um dev da Fase 2 que for introduzir auth não precisa adicionar a dependência — precisa religar o filtro (micronaut.security.enabled: true) e desenhar as regras (intercept-url-map/@Secured). O/healthdo management segueenabled: false(oHealthControllerda lib é quem serve/health).- Mudança de contrato
ok→UPé HTTP-200 nos dois casos: o healthcheck do Dockerfile (curl -f) e o Coolify/Traefik julgam por status HTTP, não pelo corpo — imunes. Nenhum monitor faz grep de"ok". - Reversível: remover a dependência e restaurar as cópias é uma edição de build + git revert; o
contrato do
/healthvolta com o literal.
Alternativas consideradas¶
- Manter security ligado +
intercept-url-mapliberando/**anonimamente — rejeitado: mais superfície de config agora, sem ganho presente (a auth da Fase 2 ainda não está desenhada). Desligar o filtro é a expressão mais enxuta da postura atual e trivial de reverter. - Excluir a transitiva no Gradle (
exclude group: "io.micronaut.security") — rejeitado: o@Securedda lib passaria a não resolver em runtime de forma silenciosa, e a casa pode vir a expor membros que dependem de tipos de security; desligar por config é mais honesto que amputar o classpath. - Pedir à casa expor security como
compileOnly/optional na lib — encaminhado como feedback §6 pro repo central (beneficia os outros apps abertos), mas não bloqueia esta adoção: desligar por config resolve no app hoje.
0009 — Cliente do transform vem da aba Faturamento (invoice), não do Integrador (company)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-11 · Decidido em: 2026-08-11
Contexto¶
Os pedidos entravam no X-Adm com o cliente errado. O TransformService montava a identidade do
cliente (propriedades/fones/contratos) 100% de data.company — a aba Integrador da PIED, que
é a revenda/instaladora (CNPJ, ex. ARGON SOLAR LTDA). O X-Adm precisa do cliente final faturado
— a aba Faturamento = data.invoice (tipicamente pessoa física/CPF, ex. IVO ANZINI). Bug reportado
pela Maxsul (Felype, 2026-08-10), confirmado no pedido #260068273 e no dump json.json (243 pedidos
REST únicos: 63 CNPJ / 180 CPF em invoice).
A raiz é dupla:
- o transform lê a aba errada (
companyem vez deinvoice); invoiceefreightsó existem no REST — o webhook de pedido é um subconjunto (0/150 webhooks trazeminvoice/freight). OupsertPedidogravavaorder.toString()inteiro nopied_pedido.payload, então umorder.updatedde webhook chegando depois de um poll REST apagavainvoice/freight. Sem corrigir (2), corrigir (1) não se sustenta.
Decisão¶
Identidade do cliente = data.invoice sempre. company deixa de alimentar propriedades/fones/
contratos; segue alimentando só o catálogo pied_cliente no normalizado (keado pelo doc do
company).
- Documento (
CgcCpf/CgcCpfCliente) =digitos(invoice.cnpj || invoice.cpf). Opied_pedido.documento_clientepassa a ser o doc doinvoice; opied_clientesegue keado pelo doc docompany(dois documentos distintos noupsertPedido). - Nome/Fantasia/Contato =
invoice.razaoSocial/invoice.nomeFantasia/invoice.telephone/invoice.email(oinvoicenão temmainContact— o nome do contato é a razão social). - InscEst =
invoice.ieinline (dropa o lookup nopied_clientepara o cliente). - Endereço = entrega quando divergir, senão faturamento, com discriminador robusto CIF + completude (não diff cru):
fonte = freight.address SE (freight.type == "CIF" E freight.address completo)
senão invoice.address
completo := CEP != null E patio != null E number != null
No FOB o freight.address traz só city/state (incompleto) → cai no faturamento. Dump: FOB 0/12
completo → faturamento; CIF 230/231 completo → entrega. Endereço único (CodProp " 0") — não se
manda faturamento+entrega em dois CodProp (evita travar a ordem de fabricação do X-Adm).
- UF fiscal do NatOp = invoice.address.state (nota). Pode divergir de propriedades.Estado
(entrega) num CIF interestadual — divergência intencional.
- Merge no upsert: NormalizadorService.upsertPedido lê o pied_pedido.payload existente e mescla
o pedido novo sobre ele no nível de topo (setAll só das chaves que a fonte nova traz não-nulas;
ausentes são preservadas). Corrige o cliente e o bug latente do frete. Vale para os 3 callers
(normalizarCronologico, normalizarPedidosElegiveis, renormalizar).
- Guarda de envio: pedido sem invoice (só-webhook) não é enviado — PushService.enviarPedido
devolve PULADO (estado transitório até o REST preencher), sem marcar ERRO.
Alternativas preteridas¶
- Manter
companycomo cliente — é a revenda, não o comprador faturado (o bug). - Discriminar endereço por
type=='FOB'na unha — a completude dofreight.addressé mais robusta (cobre o CIF anômalo com endereço incompleto). - Coluna
invoicededicada no schema — não corrige o frete; o merge shallow já resolve sem migração. - Deep-merge do payload — as chaves em jogo (
invoice/freight/payment) são de topo; shallow basta.
Consequências¶
- Sem tabela nova, sem migração. Tudo deriva do raw
pied_pedido.payload(REST). - Mudança de identidade canônica (o doc do cliente passa a ser do
invoice): entra com reset da base do zero, sem backfill/re-envio dos pedidos existentes (ver reset). - Tradeoff do merge (read-modify-write): o
upsertPedidodeixa de ser função pura do input — passa a depender do estado no banco; uma chave de topo escrita não é apagada por um snapshot posterior que a dropa (intencional — nunca perderinvoice/freight). A ordem cronológica global + merge converge. invoice.iepresente em só 59/243 no dump — os demais gravamInscEstem branco (coerente cominvoice.temInscricaoEstadual == "nao").
Em aberto¶
- UF fiscal do
NatOpno CIF interestadual: defaultinvoice.address.state; confirmar com a equipe X-Adm se não deveria serfreight.address.state(reversão = 1 linha emnatOpCompl). - Deprecar
pied_cliente/ pollcompanies? Perdem parte do propósito (InscEst agora inline); decisão adiada.
0010 — Visibilidade de pagamento: parcial travado e cancelamento pós-import¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-20 · Decidido em: 2026-08-20
Contexto¶
Investigação sobre o banco real (db_maxsul, janela REST de 8 dias, ~500 pedidos pagos) expôs dois
pontos cegos no fluxo PIED → X-Adm:
- Parcial que trava. Pedido com pagamento dividido (ex.: Pix pago + Cartão pendente) fica em
payment.status = partial. O gate de envio dispara só emreceived, então o parcial nunca é enviado — e some do radar se o saldo não fecha. Dados: 6 parciais/8 dias; 4 viraramreceivedsozinhos em ~1 dia; 2 travaram (ex.:260065499, parado +1d19h). Nenhum parcial cancelou. - Cancelamento pós-importação. Pedido pago, enviado e importado no X-Adm (
status = CONFIRMADO) depois cancelado na PIED (payment.status → cancelled). A integração não propaga — o pedido segue ativo e faturável no X-Adm sem ninguém saber. Caso real:260070866(requested → received → [CONFIRMADO] → cancelled). 1 caso em 8 dias / ~500 pagos; os outros 15 cancelamentos nunca pagaram → corretamente não importados → sem alerta (zero falso-positivo).
Decisão¶
Envio segue received-only (decisão do cliente, confirmada com dados). Comparação do MESMO
pedido em partial × received mostrou os campos que dirigem a NF (finalValue, originalValue,
invoice.serviceTotal, freight.price, produtos) idênticos — enviar parcial não corrige valor
nenhum; o único risco é faturar um pedido não-quitado. Então parcial é tratado por visibilidade,
não por envio.
Duas adições (spec .ia/007, migration V8), atrás do toggle pied.integracao.habilitada:
- A — Parcial travado: coluna
pied_pedido.parcial_desde(carimbo da transição →partial, espelho dopago_em). Home e detalhe mostram aviso quando o pedido está empartialhá mais de 5 dias corridos. Regra pura emAvisosPagamento(Java, testável). - B — Cancelamento pós-import: coluna
pied_pedido.cancelado_pied_em(set-once, na transição paracancelledde um pedidoCONFIRMADO). Ao detectar a transição, um e-mail aos destinatários cadastrados (reusaAlertaService/Resend, best-effort), com orientação de ação manual no X-Adm. Home e detalhe mostram aviso. O X-Adm não é tocado (o cancelamento é manual, é o ponto).
Detecção do e-mail em Java no NormalizadorService (o mesclar já carrega o estado anterior):
dispara na transição em que cancelado_pied_em vai de NULL para setado — unicidade do e-mail =
unicidade do set-once da coluna (robusto a cancelled → received → cancelled; sem flag extra).
Consequências¶
- Status permanece
CONFIRMADOno cancelamento pós-import — o pedido está importado; o aviso vem da coluna, não de um estado novo (não mexe emStatusBucket/timeline). parcial_desde/cancelado_pied_emnão retroalimentam histórico (nascem NULL). No deploy, um pedido jáCONFIRMADO+cancelled(ex.:260070866) não dispara e-mail retroativo — a transição já passou.- Cancelamento durante a janela
ENVIADO(enviado, ainda não reconciliado) não alerta — sóCONFIRMADO. Janela ~1min (reconciliação); aceito.
Alternativas consideradas¶
- Enviar parcial ao X-Adm: rejeitada — não melhora o valor da NF (idêntico ao
received) e arrisca faturar saldo em aberto; os 2 travados são exatamente os arriscados. - Novo estado
CANCELADO_PIED: rejeitada — exigiria mapear bucket/timeline; a coluna + aviso resolve com menos superfície. - E-mail de parcial travado: rejeitada — parcial é ~1% e raro; e-mail viraria ruído. Fica só o aviso no painel (o operador já vive nessa tela).
- Dedup do e-mail por
payment_status_anterior/ colunaalerta_enviado+ job: rejeitada — amarrar ao set-once da própria coluna é mais simples e sem re-alerta em re-cancelamento.
0011 — int-pied native-ready (GraalVM); imagem/deploy na fábrica¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-11 · Decidido em: 2026-08-23
Contexto¶
A constituição fixa GraalVM native-image como o alvo de deploy do server Micronaut
elegível (RD central 0022): RSS ~3× menor, imagem
menor, boot rápido, e — sobretudo — o build sai do host de produção (o build-spike do
shadowJar/native na VM do Coolify foi gatilho de freeze). O int-pied é elegível: server
Micronaut sem Apache POI (a lib hostil ao native), então não cai na exceção JVM.
Faltava torná-lo native-ready. Este trabalho habilita o build native e prova que compila, sem trocar o deploy.
Decisão¶
O int-pied é native-ready: ./gradlew nativeCompile compila. A imagem native e o
deploy ficam para a fábrica de imagens (central 0003) — este repo não os faz.
- Perfil GraalVM CE no
build.gradle.kts(configure<GraalVMExtension>):-Os(prod) /-Ob(-PnativeQuick, loop) +--gc=serial,baseName=app. CE 25 não tem G1/PGO/build-report;-O3==-O2. docs/app.jsonmarcanative: true— discriminador que o gate do CI e o e2e native leem.- Gate no repo do app =
nativeCompilecompilar (0022 §Gate). Runtime/boot/e2e native ficam num repo de e2e separado; a imagem é buildada na fábrica (0003), fora daqui. - Sentry native-safe:
SentryInitializer.inicializar()nomain(SDK sobe antes do contexto) + reflect-config doSentryAppenderemMETA-INF/native-image/(o Joran o instancia por reflexão — o único hint manual recorrente da casa). Demais gaps (jackson-databind, OkHttp,@Scheduled) foram resolvidos sozinhos pelo reachability-metadata do native-build-tools + serde build-time — zero hint extra. IntegradorClientdeixou OkHttp e passou a falar peloHttpClientdo Micronaut (build-time, native-limpo), alinhando o transporte app↔app da casa (central 0020).
Consequências¶
Revisão (2026-09-09) — o native saiu do "compila" e virou alvo de deploy, aqui no repo. A primeira consequência abaixo ("não há
Dockerfile.nativeneste repo") não vale mais: o repo ganhou oDockerfile.native— par hermético doDockerfileJVM — edocs/app.jsondeclarabuild.targets: [jar, native]. O CI (.github/workflows/pipeline.yml) tem o jobbuild_native(GraalVM, ~13min,mode=mine sem cache-dance —mode=maxjá travou oexporting to imagee estourou o timeout), publica:native-amd64e deploya jar e native lado a lado no mesmo banco (targets resolvidos por nome no control-plane, sem uuid nem ponte SSH — central 0026). Há ainda um gate de paridadeDockerfile×Dockerfile.nativenos dirs app-writable sob/app, para os dois não divergirem em silêncio.O que não mudou: o perfil GraalVM (
-Os/-Ob,--gc=serial,baseName=app), o hint manual doSentryAppender, e oIntegradorClientnoHttpClientdo Micronaut.Revisão (2026-09-11) — o hint do
SentryAppendersaiu deste repo. Axadm-comum-web0.9.1 embarcaMETA-INF/native-image/br.com.xadm/xadm-comum-web/reflect-config.jsoncom as três entradas que o logback exige no native:SentryAppendereSentryOptionscom construtor e métodos públicos, ech.qos.logback.classic.Level.valueOf(String). A cópia local registrava só o appender — com ela,<options>e<minimumEventLevel>eram ignorados em silêncio e o evento do native chegava ao GlitchTip semenvironment(o DSN sobrevivia porque o SDK o lê da env). O repo subiu para a 0.9.1 e apagou oreflect-config.jsonlocal no mesmo commit, como manda o §Migração da lib. A prova no binário (evento de teste com oenvironmentcerto) é do e2e native.
- O
DockerfileJVM continua sendo o deploy (0022 §Topologia — durante a migração o repo mantém a cara JVM). Não háDockerfile.nativeneste repo; a receitadockerfile-java-nativevive/roda na fábrica. (Superado — ver a revisão acima.) - Build native local usou Oracle GraalVM (o
native-imageda máquina do dev) como proxy — o gate autoritativo é a fábrica em CE. O perfil (-Os/-Ob,--gc=serial) roda nos dois. micronaut-http-clientvirouimplementation. OkHttp permanece só para PIED e Resend (terceiros externos) — decisão consciente, não dívida.- Conflito casa×repo declarado: [0020] pede
@Clientdeclarativo, mas a integração nasce desligada (INTEGRADOR_BASE_URLvazio) e o app tem de bootar assim — um@Client(id)resolveria a URL no startup e a vazia arriscaria o boot. EscolhidoHttpClientlow-level (URL absoluta por chamada, guarda o vazio antes do request): tira OkHttp sem quebrar o disabled-by-default.
Pendências declaradas (§1.6 / REGRA Nº 3)¶
- Reflect-config do
SentryAppenderdeve subir para axadm-comum-web(dona doSentryInitializer), via reachability-metadata da lib — hoje é cópia local byte-idêntica à debi-transporte-xls/central-backend/webstorm-ecom. Enquanto não sobe, fica aqui. (Resolvida em 2026-09-11 — subiu naxadm-comum-web0.9.1; ver a revisão acima.) - Imagem native + deploy Coolify + rollout/rollback + e2e ponta-a-ponta = trabalho posterior (fábrica + repo de e2e), fora deste escopo.
Alternativas descartadas¶
- Buildar a imagem native no repo do app (
dockerBuildNative, plugin). Contraria 0022 §Topologia (o native builda na fábrica; a wolfi-base do plugin nem trazcurl, quebrando o HEALTHCHECK §5). Native aqui é sónativeCompileprovando compilação. - Manter OkHttp no
IntegradorClient. Compila em native (reachability-metadata), mas diverge do transporte app↔app da casa (0020). Trocado.
0012 — Convergência da entrega ao outbox da casa e adoção do micronaut-data¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-11 · Decidido em: 2026-08-24
Contexto¶
O int-pied garantia a entrega ao Integrador com um motor de outbox próprio (bespoke): FSM em
pied_pedido.status, PushService (envio idempotente por content_hash, atômico por code via
marcarEnviando), retry/backoff à mão. A casa extraiu essa capacidade para a lib
xadm-mensageria (MensagemM2m + RelayM2m + SPI EnviadorMensagem + tela
/admin/mensageria) — dois motores de outbox na casa. A convergência é débito de uniformidade
(spec 004), não gap funcional.
Decisão¶
Converge a ENTREGA (não a FSM de negócio) para o outbox da casa; a FSM pied_pedido e a
reconciliação ficam.
- Separação domínio × entrega:
pied_pedido.statussegue a FSM de negócio; o PUSH ao Integrador vira umaMensagemM2m tipo="xadm-push". OPushEnviador(SPIEnviadorMensagem) faz o PUT; oRelayM2mda lib substitui o drain automático. - Idempotência preservada sobre at-least-once: o
RelayM2mé at-least-once sem lock; oPushEnviadorre-checa ocontent_hashantes do PUT, e o/api/v1/xadmdo Integrador é upsert idempotente por chave (confirmado noXadmController) — dupla proteção. - Entrega × negócio no PUT sempre-200: o
/api/v1/xadmresponde 200 mesmo emresultado=ERRO. Logo 200 = entrega OK (ResultadoEnvio.ok, não retenta); o resultado de negócio (SUCESSO/ERRO) vai pro domínio (marcarEnviado/marcarErro). Só falha de transporte (timeout/5xx/401) →ResultadoEnvio.falha(o relay retenta). ERRO de negócio não auto-retenta. - Prontidão fica no domínio: o guard
temInvoice(segura o envio até o REST preencher a invoice) fica no passo que enfileira — enfileira só o send-ready, sem penalizar o relay.
Consequência dura: adotar xadm-mensageria FORÇA micronaut-data¶
O MensagemM2mRepository da lib é @JdbcRepository → arrasta micronaut-data-jdbc. Com ele no
classpath, o DataSource injetado vira transaction-aware, e o int-pied — raw-SQL por design
(dataSource.getConnection() direto, "sem micronaut-data") — quebra em massa com "No current
connection present". Isso força uma migração do padrão de persistência. O padrão (comprovado
experimentalmente, 76 falhas → 0 no caminho crítico):
annotationProcessor("io.micronaut.data:micronaut-data-processor")— o fix-chave. Sem ele,@Transactionalfica não-processado (no-op): não há interceptor AOP → o acesso raw-SQL fora de transação quebra. É o que o int-pied nunca teve (nunca usou data).@Transactionalclass-level nos 13 repos raw-SQL — torna o app transaction-aware; o path atômico (marcarEnviando+enfileirar) compartilha a transação.- Nos TESTES, desembrulhar o
DataSource— o seed viadataSource.getConnection()direto é incompatível com o wrapper transaction-aware em ambos os modos de@MicronautTest(transactional=trueesconde o seed do controller;transactional=falsefaz o próprio seed falhar). O fix:while (ds instanceof DelegatingDataSource d) ds = d.getTargetDataSource();→ seed committed, visível ao app. (transactional=falseglobal, como os apps data-jdbc da casa, não basta aqui porque o seed é raw JDBC.)
Correção 2026-09-11: o "raw-SQL por design" acabou — a persistência inteira migrou para Micronaut Data (decisão 0022): os repositórios não abrem mais
dataSource.getConnection(), o SQL é@Querynativo com o mesmo texto. Seguem valendo a convergência da entrega ao outbox, oannotationProcessor(item 1) e o@Transactionalde classe nos repositórios (item 2, agora sobre a interface@JdbcRepository). O desembrulho doDataSource(item 3) continua só naBancoFixturede teste. SQL à mão ficou apenas na varredura em lote doNormalizadorServicee no advisory lock doPiedClusterLock, guardados peloFronteirasTest.
Status (2026-08-24)¶
Código completo e compilando. Gate sem-Docker verde (test unit + checkstyle +
compileIntegrationTest); WebhookControllerTest de integração passou isolado, provando o
unwrap. A suíte de integração completa não foi validada localmente — a máquina de dev degradou
após horas (worker JVM morre com EOFException), problema de ambiente, não de código. Validação
deferida ao CI (ci-java:25 limpo) ou a um check pós-reboot — decisão explícita (REGRA Nº3),
não silêncio.
Pendências declaradas¶
- Validação da suíte de integração no CI/ambiente limpo (o gate real).
- Gate de segurança
/admin/mensageria: a tela da lib nasce aberta (security.enabled=false); o gate@<dominio>fica pra quando a segurança ativar (deferida, gate de identidade TBD). - Blocking B2 (§6): o SPI
EnviadorMensagemdocumenta@Client-gerado-de-OpenAPI; o int-pied usa client à mão contra o legado/api/v1/xadm— divergência aceita; a lib deveria rotular como aceitável ou prover adaptador.
Alternativas descartadas¶
- Manter o outbox bespoke. Funciona, mas mantém dois motores na casa (débito de convergência).
@MicronautTest(transactional=false)global (padrão dos apps data-jdbc). Não resolve o seed raw-JDBC do int-pied — só o desembrulho do DataSource resolve.- Desembrulhar o DataSource na PRODUÇÃO. Quebraria a atomicidade do enfileiramento; o desembrulho é só nos testes, a produção fica transaction-aware.
0013 — Testes de integração sem SQL cru, sobre a fixture da casa¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-24 · Decidido em: 2026-08-24
Contexto¶
Os testes @MicronautTest provisionavam o Postgres pelo plugin io.micronaut.test-resources
(divergindo do padrão da casa, ADR 0019 / xadm-comum-teste, que a extração house-wide fixou como
PostgresTestResource) e misturavam escrita por repositório @Transactional com conexões JDBC
cruas no corpo do teste. Sob a tx-de-teste padrão do @MicronautTest (rollback por teste, ligado
pelo listener do micronaut-data), isso produzia duas classes de bug determinístico:
- Deadlock: um
inserirvia repo junta a tx-de-teste e fica não-commitado segurando o lock do índice único; uma 2ª conexão crua no mesmo teste tentando o conflito bloqueia nesse lock — e a 1ª não commita porque a thread está presa na 2ª. Semlock_timeout, trava eterna (o gate docker nunca completava — sempre pendurava noconstraintUnica). - Null-read: escrita via serviço/repo
@Transactional(não-commitada, conexão A) lida por uma conexão crua separada (B) que não enxerga o não-commitado →null.
Decisão¶
- Provisionamento pela casa: adotar
PostgresTestResource+PostgresTestPropertyProviderdoxadm-comum-teste(ADR 0019) — umpostgres:18-alpine(Testcontainers) compartilhado por JVM, injetado por classe (implements PostgresTestPropertyProvider+@TestInstance(PER_CLASS)). Remover o pluginio.micronaut.test-resourcese o blocotestResources{}. Bônus: como o banco passa a ser opt-in por classe, some a complexidade do split (o plugin configurava toda taskTest) — otest(POJO) nunca sobe Docker sem acoplamento a desfazer. @MicronautTest(transactional = false)em todo teste de integração: cada write commita pela sua conexão, então servidor embarcado e repositórios leem o dado semeado — mata as duas classes de bug na raiz.- Zero SQL cru no corpo dos testes: seed e leitura vão pelos repositórios de produção (mesmo
caminho de escrita do runtime). O de-baixo-nível que o repo não expõe — limpeza entre testes, seed
com timestamp no passado (poda por idade), contagem do outbox
mensagem_m2m, atalhos de FSM, soft-delete, getters ausentes — vive num único apoio de teste,support/BancoFixture(@Singletonsó do sourceSetintegrationTest). A tabelamensagem_m2m(dona =xadm-mensageria) é tocada só pela API publicada do repo (deleteAll()herdado +listarRecentes()+ filtro).
Alternativas descartadas¶
- Manter JDBC cru + só
transactional = false: resolve os bugs, mas deixa o padrão frágil (SQL espalhado em 20 arquivos, fácil reintroduzir a armadilha). - Métodos test-only nos repositórios de produção (
apagarTudo,forcarNaFila, backdaters, soft-delete): inflaria a API de prod com setters que burlam FSM/invariante — e exigiria mexer no commons compartilhado (xadm-mensageria). Rejeitado por over-engineering (constituição). ABancoFixtureconcentra isso no sourceSet de teste, sem tocar produção. - Manter o plugin
io.micronaut.test-resources: funciona, mas diverge do padrão house-wide (ADR 0019) sem ganho — oPostgresTestResourceé equivalente e é o que os apps da casa usam.
Consequência¶
Suite de integração roda e passa de ponta a ponta (antes o gate docker travava), 100% via API da casa
(xadm-comum-teste + repositórios), sem SQL cru fora da BancoFixture. Ver
guia-do-código §Rodar e testar.
0014 — Guarda de cluster (advisory lock) para os @Scheduled¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-09 · Decidido em: 2026-09-01
Contexto¶
Meta da casa 50/50 jar/native: todo app Micronaut roda a imagem jar e a native (GraalVM)
ao mesmo tempo, lado a lado, contra o mesmo banco (ganho duplo — metade da frota validando native
+ HA/failover). O maxsul-pied é scheduler/poller: com 2 instâncias ativas, todo
@Scheduled dispara nas duas → double-fire:
PollJob(1h) — poll REST da PIED dobrado;ReconciliacaoJob(1m) — GET Integrador dobrado + e-mail de alerta DUPLICADO (o dano mais visível);TransformJob(5m) — normaliza + enfileira dobrado;RetencaoJob(24h) — DELETE por idade (idempotente, 2× é no-op).
Os toggles pied.poll.habilitado / pied.integracao.habilitada / pied.retencao.habilitado são
config estática por-instância (ligados nas duas, disparam nas duas) — não servem de guarda
cross-instância.
Já safe 2× (fora do escopo): a entrega M2M (RelayM2m da lib xadm-mensageria) é
N-instâncias-safe por desenho (despacho at-least-once + dedup no receptor; cada relay reivindica só
seus tipos). (Errado — ver as duas revisões abaixo.)
Revisão (2026-09-08) — a entrega M2M NÃO era safe 2×; entrou no escopo. O
tiporeivindicado particiona a saída entre apps diferentes; as duas réplicas do mesmo app registram os mesmostipo, casam o mesmo predicado e drenam a mesma linha. Medido em produção: 92PUTpara 52 payloads distintos em 24h, com uma única linha no outbox por pedido. O upsert do Integrador absorvia a duplicata, então não era corrupção — o custo era um commit extra no espelho por entrega, e cada commit é mais uma janela para o PowerSync aterrissar no meio da coleta dointegrador-client. O app passou a desligar o agendador da lib (mensageria.relay.enabled=false) e reagendar sob um lock próprio (pied_relay), com o §6 registrado para o central.Revisão (2026-09-09) — a guarda voltou para a lib; o job local saiu. A
xadm-mensageria0.3.4 passou a eleger uma réplica por tick compg_try_advisory_lock(hashtext('<micronaut.application.name>_relay_m2m'))— a mesma mecânica, agora mantida pela casa (piso de versão no 0019: abaixo de 0.3.4 é defeito, não preferência). ORelayJoblocal e a constantepied_relayforam removidos, emensageria.relay.enabledvoltou atrue. A chave é distinta de propósito (maxsul-pied_relay_m2m×pied_<job>): lock igual elegeria a MESMA instância para o relay e para os jobs do app, serializando o que pode correr junto. Os cinco locks abaixo seguem sendo do app.
Decisão¶
Guarda advisory-lock por-job, molde do SweepClusterLock do webstorm-ecom.
PiedClusterLock(@Singleton, pacotecomum):comLock(String nome, Runnable work)fazpg_try_advisory_lock(hashtext(nome))(try-lock não-bloqueante); adquiriu → rodaworke solta nofinally(pg_advisory_unlock); não adquiriu → coalesce (não roda, devolvefalse, o próximo tick reencaixa). Lock, work e unlock na mesma sessão. Advisory lock é por-sessão → solta sozinho na queda da instância (HA/failover, sem lease/heartbeat).- Cópia por-app (não compartilhado): a classe vive local no int-pied. Zero churn de lib
publicada. Deferido: promover a componente reusável em
xadm-commons— hoje há 3 consumidores do mesmo padrão (webstorm + int-pied + int-sascar); avaliar quando a 3ª cópia confirmar o desenho. - Per-job locks: um nome estável por job (
pied_poll/pied_reconciliacao/pied_transform/pied_retencao) — jobs independentes, sem recurso single-connection, então nomes distintos dão paralelismo (o poll de uma instância não bloqueia o transform da outra). Os quatro jobs embrulham o corpo no lock após o check do toggle. Só o caminho@Scheduledé lockado — as ações manuais/console (reconciliar(String), clique na fila) não. - Runtime do
@Connectable: o@Schedulednão abre contexto de conexão do Micronaut Data;@Connectableabre um para o método. Isso exige a libmicronaut-data-jdbcem runtime — o int-pied só tinha omicronaut-data-processor(SQL direto, decisão 0012), então a dep foi adicionada (build.gradle.kts). Inerte fora do lock (sem@Repository).
Alternativas descartadas¶
- Confiar nos toggles como guarda: são por-instância, disparam nos dois. Não coordenam cluster.
- Núcleo Java-puro em
xadm-comum-util+ adapter por-app:xadm-comum-utiléjava-libraryde propósito (não arrastar a BOM do Micronaut); caberia um core puro(DataSource, nome, Runnable)com adapter@Connectablepor-app. Descartado agora junto com a promoção a compartilhado (ver Decisão 2) — mantém o escopo na guarda, sem tocar lib publicada. - Desembrulhar
DelegatingDataSource→Hikari (idioma test-only daBancoFixture) e dispensar@Connectable: funcionaria em prod e teste sem AOP, mas promoveria um atalho de teste a produção. Preferiu-se ficar fiel ao molde (@Connectable) + adicionar a dep de runtime. - Lock único cross-job: mataria o paralelismo à toa (não há recurso single-connection aqui — contraste com o int-sascar, que usará lock único pela trava "1 conexão por login" da Sascar).
Consequência¶
O maxsul-pied pode subir 2 instâncias (jar + native) no mesmo banco sem double-fire: cada
rodada de cada job executa uma vez só (PIED consultada 1×, e-mail de alerta 1×, fila drenada 1×); a
outra instância coalesce e assume no failover. Prova: PiedClusterLockIT (contenção do lock:
coalesce / roda / nomes distintos não contendem) + ReconciliacaoJobClusterIT (fim-a-fim: lock
ocupado ⇒ zero POST no Resend). Fora de escopo (follow-up de infra): portar build_native no
pipeline + criar o recurso Coolify native com split weighted jar/native.
0015 — CI/CD 100% GitHub Actions (adoção local do ADR central 0027)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-01 · Decidido em: 2026-09-01
Contexto¶
A decisão de mover o CI/CD da casa de Forgejo Actions para GitHub Actions 100% (workflow
pipeline.yml único, à la carte, deploy via control-plane) é da constituição X-Adm — ADR central
0027 (emenda 0003/0026). Não é decisão deste app; este RD é só o rastro local de que o
maxsul-pied migrou, para quem lê o histórico deste repo achar o quando e o ponteiro.
Decisão (adoção)¶
O maxsul-pied adotou o pipeline.yml do template central:
- Gate + docs + build + deploy num só workflow (
.github/workflows/pipeline.yml), disparado por push/PR (por path),workflow_dispatch(checkboxes) e tagv*(release completo). O runner Forgejo e osbuild-deploy*.ymlforam aposentados. - Deploy via control-plane (
POST /api/ci/deploy, ADR central 0026): o central resolve app→recurso Coolify por nome, sem uuid nem ponte SSH neste repo. - jar + native 50/50 (setembro/2026):
build.targets=['jar','native']noapp.json; o pipeline builda os dois flavors (build_jar+build_native) e deploya ambos (target=jar/target=native). O pré-requisito de código — a guarda de cluster que torna o app safe pra 2 instâncias no mesmo banco — é a decisão 0014. O recurso Coolify native em si é provisão de infra à parte (fora deste repo).
Referência¶
- ADR central 0027 (CI GitHub Actions), 0026 (deploy control-plane), 0003 (pipeline) —
na constituição X-Adm (
docs.xadm.biz). A regra viva mora lá; aqui é só o ponteiro de adoção.
0016 — Watchdog de silêncio de webhook¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-04 · Decidido em: 2026-09-04
Revisada pela 0023 (2026-09-21). O limite de 24h úteis decidido aqui não enxerga um apagão de poucas horas dentro do expediente — o de 18/09/2026 durou 2h44 no pico de uma sexta e custou quatro pedidos. A 0023 soma uma régua comercial (minutos, seg–sex 8h–18h) e baixa o intervalo do job de 1h para 5min; o limite de 24h úteis descrito abaixo continua valendo como rede do apagão que começa fora do horário. A causa provável também ficou mais larga: não só 404, timeout também desativa o webhook na PIED.
Contexto¶
O webhook da PIED (POST /webhook/pied) é a via tempo-real da captura. Há um modo de falha
silencioso e recorrente: quando o endpoint responde 404 (nossos servidores fora do ar num
deploy/reinício), a PIED desativa o webhook e para de enviar — e não volta sozinha. O
conserto é manual: alguém da Maxsul acessa o dashboard da PIED, exclui e recadastra o webhook
(runbook). Enquanto ninguém percebe, pedidos deixam de chegar em
tempo real (o poll REST é backstop, mas roda a cada 6h e não cobre tudo).
Não havia nada vigiando esse silêncio — a falha só aparecia quando alguém notava pedidos faltando.
Decisão¶
Um watchdog (watchdog/WatchdogJob, @Scheduled + guarda de cluster 0014)
que compara max(pied_webhook.recebido_em) com agora e alerta se o silêncio passar do limite.
- Tempo útil, não corrido. O gap conta só dias de semana (seg–sex; sáb/dom não contam —
watchdog/TempoUtil, fusoAmerica/Sao_Paulo). A Maxsul pode não trabalhar no fim de semana: 48h de silêncio que caem num sábado+domingo não são problema. Limite default 24h úteis, configurável (PIED_WATCHDOG_LIMITE_HORAS). - Canal: GlitchTip. O alerta é um
LOG.error(...)→ captado peloSentryAppender(nível ERROR). Sem e-mail (o 0005 já cobre erro de negócio por e-mail; aqui é saúde de infra, que vive no GlitchTip). - 1x por episódio. Alerta uma vez ao cruzar o limite e não repete até chegar um webhook novo
(o
max(recebido_em)avança e abre outro episódio). Estado persistido empied_watchdog_estado(singleton), para não re-alertar a cada rodada nem em restart. - Mensagem acionável. O texto do alerta já diz a causa provável (PIED desativou após 404) e a ação (excluir + recadastrar), apontando o runbook.
- Nasce ligado (
PIED_WATCHDOG_HABILITADO=true): o webhook nasce ligado, então vigiá-lo é o padrão esperado. Se o webhook está desabilitado (pied.webhook.habilitado=false), o watchdog não alerta (não há o que vigiar).
Alternativas consideradas¶
- Tempo corrido (24h absolutas): rejeitada — dispararia falso-positivo toda segunda de manhã depois de um fim de semana normal sem pedidos.
- Horário comercial (8–18h), não dia inteiro: rejeitada por ora — a Maxsul pediu o eixo semanal (dia útil), não o de horas; dia útil = 24h mantém o cálculo simples e sem config de expediente. Reabrir se surgir necessidade.
- Alerta por e-mail: preterido — é saúde de infra, não rejeição de pedido; GlitchTip é o lugar.
Fácil somar e-mail depois (o
AlertaServicejá existe) se quiserem. - Auto-recadastrar o webhook via API da PIED: fora de alcance — o cadastro do webhook é manual no dashboard da PIED (não há endpoint documentado). O laço é alerta → ação humana.
Consequências¶
- Silêncio de webhook vira visível (GlitchTip) em ~1 dia útil, com instrução de conserto no próprio alerta.
- Novo estado singleton
pied_watchdog_estado(migrationV10), novo lock de clusterpied_watchdog. - Ponto cego aceito: se nunca chegou webhook, o baseline é a subida do app (reinicia a cada restart) — só relevante num app que nunca recebeu webhook algum.
0017 — Enriquecimento sob demanda do pedido preso na fila¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-08 · Decidido em: 2026-09-04
Contexto¶
O pedido chega por duas vias: webhook (tempo real) e poll REST (backstop, a cada 6h). O payload do
webhook não traz o objeto invoice (aba Faturamento) — só o REST traz. E o invoice é o insumo
do cliente do Faturamento (0009), então o PushService tem um
guard de prontidão: sem invoice, o enfileiramento devolve PULADO — sem marcar ERRO, sem
mudar o status.
Resultado: pedido pago que chega por webhook fica em NA_FILA até o próximo poll — até ~6h
parado, sem erro, sem alerta e (antes da revisão do 0006) anunciado ao
cliente como "Enviando ao X-Adm". Aconteceu em produção no 1º pedido real: 260078922 chegou por
webhook às 14:17Z de 2026-09-04, ficou preso, e só saiu (ENVIADO 14:58Z, CONFIRMADO 15:00Z)
depois de alguém puxar a página do REST na mão.
Restrição do contrato da PIED: não existe busca de pedido por id. Só
GET /requests/order/{página}/{limite}?lastUpdateAfter=AAAA-MM-DD — ou seja, "buscar aquele pedido"
é sempre varrer a janela daquela data. E a PIED impõe dois tetos por hora (chamadas e tempo de
processamento), então varredura é um recurso escasso.
Decisão¶
O TransformJob abre cada rodada (5 min) chamando o novo integracao/EnriquecimentoService:
havendo NA_FILA sem invoice, ele busca a janela no REST, grava o raw em pied_rest e a
normalização da mesma rodada aplica o payload completo — o pedido destrava em ≤5 min em vez de
~6h.
- No job que já sabe quem está preso, não num job novo: o
TransformJobjá roda a cada 5 min, já tem guarda de cluster (0014) e já é o dono da fila. Zero superfície nova (sem toggle, sem lock, sem@Scheduledextra). - Antes da normalização, não depois: assim a página baixada é aproveitada na mesma rodada.
- Janela do preso mais antigo, menos 1 dia.
lastUpdateAftertem granularidade de dia e semântica after; usar a data exata arriscaria excluir o próprio pedido. Reprocessar um dia a mais é barato (upsert idempotente porcode). - Custo contido, por construção: nenhuma chamada quando não há preso (o caminho comum); para
assim que todos os presos apareceram numa página; teto de 5 páginas por rodada; e falha de
rede/cota (429) vira
WARNsem derrubar a rodada — é backstop, e o poll de 6h continua sendo a rede de segurança. - Só grava raw. Quem transforma em
pied_pedidoé a normalização, como no poll — o serviço não duplica regra de negócio nem avança o cursor do poll (é leitura lateral, como o "Puxar da PIED").
Alternativas consideradas¶
- Disparar no webhook (event-driven puro), ao detectar
order.*seminvoice: era o pedido literal ("assim que chegar o pagamento") e daria latência de segundos. Preterida: sem endpoint por id, cada disparo ainda varre páginas, e uma rajada de webhooks vira uma rajada de varreduras contra os tetos horários — precisaria de debounce e teto próprios para chegar onde o job já chega. Ganho real (5 min → segundos) não paga o risco de cota. - Job dedicado (
EnriquecimentoJob, intervalo e toggle próprios): preterida por over-engineering — mais um@Scheduled, mais uma config, mais um advisory lock e mais testes para fazer o que oTransformJobfaz na mesma cadência. - Deixar como estava (esperar o poll): rejeitada — 6h de atraso silencioso num pedido pago é o pior dos mundos: o cliente não vê erro, o operador não vê alerta e o X-Adm não recebe.
- Encurtar
PIED_POLL_INTERVALO: rejeitada — produtos/clientes não têm cursor incremental, então cada rodada re-varre tudo e inchapied_rest. Barateia o sintoma inflando o landing.
Consequências¶
- Espera do pedido preso cai de ~6h para ≤5 min; o caso "preso" deixa de depender de alguém perceber e clicar em "Puxar da PIED" (que continua existindo como atalho manual).
- Uma chamada REST a mais por rodada apenas quando há preso — o estado normal (fila pronta ou vazia) não gasta cota.
- Novo componente
EnriquecimentoService+PiedPedidoRepository.presosSemInvoice(...); oTransformJobganhou uma dependência. NA_FILAdeixa de ser um estado de espera longa e passa a ser transitório. Pedido preso além de uma rodada vira anomalia — as causas residuais estão abaixo.
Quando ainda prende (e como sair)¶
O enriquecimento encurta a espera, não a elimina. Preso por mais de uma rodada é uma destas:
| Causa | Sinal |
|---|---|
PIED_TOKEN vazio |
Enriquecimento: PIED_TOKEN vazio — pulado. |
PIED_INTEGRACAO_HABILITADA=false |
O TransformJob sai antes de tudo — nem enriquece nem drena. Pedido posto em NA_FILA pelo gate manual do /console fica parado |
| Cota (429), PIED fora do ar, timeout | Enriquecimento: falha ao buscar na PIED — … (retoma na próxima rodada). |
| Preso fora do teto de 5 páginas | … N ainda sem dado. rodada após rodada |
| Pedido sumiu da janela na PIED | Idem, e nunca resolve sozinho |
| Guard de recência recusando o REST | O enriquecimento acha o pedido (0 ainda sem dado.), o pied_rest tem o invoice, e mesmo assim o pied_pedido.payload nunca ganha o invoice — ver 0018 A.1 |
Diagnóstico:
-- na fila E sem invoice = ainda esperando dado
SELECT code, deal_status, payment_status, atualizado_em
FROM pied_pedido
WHERE status = 'NA_FILA'
AND jsonb_typeof(payload -> 'invoice') IS DISTINCT FROM 'object';
Se essa consulta acusa preso e o pied_rest já tem o invoice do mesmo code, o problema não
é captura: é o guard de recência recusando o snapshot do REST — nesse caso nem "Puxar da PIED"
nem "Re-normalizar" resolvem (ambos passam pelo mesmo upsert), e o conserto está em
0018 A.1. Comparar as duas fontes:
SELECT p.code, p.last_update AS no_pedido,
(i ->> 'lastUpdate') AS no_rest,
jsonb_typeof(i -> 'invoice') AS invoice_no_rest
FROM pied_pedido p
JOIN pied_rest r ON r.entidade = 'pedidos'
JOIN LATERAL jsonb_array_elements(r.payload -> 'data' -> 'items') i ON i ->> 'code' = p.code
WHERE p.status = 'NA_FILA'
ORDER BY r.buscado_em DESC LIMIT 20;
Conserto manual — o botão "Puxar da PIED" do /console (peek: grava o raw, não avança o cursor,
idempotente por code; n aceita 1 ou 5 e só normaliza os estados-venda):
curl -X POST "https://pied.maxsul.xadm.biz/console/puxar-pied?n=5" \
-H "Content-Type: application/x-www-form-urlencoded" --data ""
Depois disso o pedido sai na rodada seguinte (≤5 min), ou na hora pelo Enviar do detalhe
(POST /console/{code}/enviar). O CONFIRMADO + chave_xadm fecham pelo ReconciliacaoJob.
Relacionadas¶
- 0006 — Painel colaborador: a revisão de prontidão do bucket saiu do mesmo incidente — enquanto o pedido espera dado, o painel diz "Pedidos em aberto", não "Enviando ao X-Adm".
- 0009 — Cliente do Faturamento: por que o
invoiceé obrigatório. - Configuração:
PIED_POLL_INTERVALO, toggles e ações do/console.
0018 — Recência do evento e normalização incremental¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-08 · Decidido em: 2026-09-04
Contexto¶
Em 2026-09-04 o pedido 260065713 (VIVA+ SOLAR, R$ 9.054,96) foi importado no X-Adm às 18:23Z
(CodRetorno 006) e, três minutos depois, alarmou como "cancelado na PIED" — o alerta de
cancelamento pós-import (0010). A Maxsul confirmou: ninguém
cancelou nada.
O raw contava outra história. Ordem real dos eventos do pedido:
| quando (UTC) | fonte | payment.status |
|---|---|---|
| 13:19 | REST | requested |
| 14:24 | webhook | cancelled |
| 17:08 | REST | cancelled (mesmo snapshot das 14:24) |
| 18:13 | webhook | requested |
| 18:17 | webhook | received |
O cancelled das 14:24 é legítimo, porém velho: uma tentativa de pagamento no cartão que caiu,
quatro horas antes do import. O estado real na PIED era — e seguiu sendo — received.
A causa raiz estava no NormalizadorService: normalizarCronologico() reprocessava todo o raw
histórico a cada rodada (5 min), em ordem cronológica. Cada rodada refazia a história inteira do
pedido — requested → cancelled → requested → received. Enquanto o pedido era CAPTURADO isso era
inofensivo; depois do import não era mais: na primeira rodada após o CONFIRMADO, a reaplicação do
cancelled de 14:24 satisfez o guard de cancelado_pied_em (status = CONFIRMADO e
payment_status <> 'cancelled' e novo = 'cancelled') e disparou o e-mail. Falso positivo — e,
pior, set-once: o carimbo queimou o alerta de um cancelamento real futuro daquele pedido.
O mesmo replay tinha um irmão silencioso: pago_em era re-carimbado a cada rodada (a transição
cancelled → received era refeita toda vez), então a data que o painel mostra andava de 5 em 5
minutos. E o custo: 21 mil pedidos re-upsertados por rodada.
O replay era só o gatilho mais provável. O problema de fundo é que a normalização não tinha
noção de recência: qualquer snapshot atrasado — uma página REST que chega depois de um webhook mais
novo, um reprocesso manual — reaplica um payment.status vencido e mexe nos guards de transição.
Decisão¶
Duas defesas, em camadas diferentes e independentes.
A — Guard de recência no upsert de pied_pedido¶
O ON CONFLICT ... DO UPDATE ganhou um WHERE: o evento só é aplicado se não for mais antigo
que o já persistido.
WHERE pied_pedido.last_update IS NULL
OR EXCLUDED.last_update IS NULL
OR date_trunc('second', EXCLUDED.last_update)
>= date_trunc('second', pied_pedido.last_update)
- Empate aplica (
>=, não>), de propósito: o enriquecimento rebusca no REST o mesmo snapshot só para trazer oinvoice. Um>estrito prenderia o pedido na fila para sempre — a cura seria pior que a doença. - Sem
last_updatedos dois lados, aplica: sem informação de recência não há como julgar; é o comportamento anterior, preservado. last_updatepassou a ser gravado comCOALESCE(EXCLUDED.last_update, pied_pedido.last_update): um payload semlastUpdatenão zera a referência que arma o guard.upsertagora devolveboolean(linha afetada ou não). ONormalizadorServicesó chamaalertarCancelamentoPosImportquando o upsert de fato aplicou — sem isso o alerta dispararia a partir de um evento que o banco recusou, que é exatamente o bug com outro nome.
O guard cobre todos os efeitos de transição de uma vez: cancelado_pied_em, pago_em,
parcial_desde, o gate CAPTURADO → NA_FILA, deal_status_anterior e o payload.
A.1 — Correção 2026-09-08: comparar no segundo cheio, não no milésimo¶
A primeira versão do guard comparava last_update cru — e a cura virou a doença que ela mesma
previa. As duas fontes carimbam o mesmo instante com precisões diferentes:
| Fonte | lastUpdate do pedido 260074525 |
|---|---|
webhook (order.updated) |
2026-09-08T11:54:24.512Z |
REST (GET /requests/order/...) |
2026-09-08T08:54:24-03:00 (= 11:54:24.000Z) |
O milésimo do webhook põe a linha num futuro que o REST nunca alcança: .000 >= .512 é falso, o
enriquecimento buscava a página certa (com invoice) e era recusado toda rodada, e o pedido
morava em NA_FILA — sem invoice, sem cliente, sem erro e sem alerta. Cinco pedidos presos assim
em 2026-09-08 (260074525, 260079167, 260079176, 260079300, 260079309).
A comparação passou a truncar os dois lados no segundo (date_trunc('second', …)). O empate por
truncamento é o mesmo caso que o >= já cobria de propósito: o mesmo snapshot por outra fonte.
O que se perde é distinguir dois eventos reais dentro do mesmo segundo — a PIED não produz isso, e
empate já aplicava.
Destravar quem já está preso (o guard não reescreve o passado): truncar a referência das linhas paradas e deixar a rodada seguinte aplicar o REST.
UPDATE pied_pedido SET last_update = date_trunc('second', last_update) WHERE status = 'NA_FILA';
O "Re-normalizar" do console não serve para isso, e piora: ele reaplica o próprio payload
(que traz o lastUpdate com milésimos), então o COALESCE re-carimba a referência e re-arma o
guard contra o REST. Ele relê a linha, nunca o pied_rest.
B — Normalização incremental (pied_normalizacao_estado, migração V11)¶
A rodada passa a ler só o raw novo. Cursor singleton (id = 1, molde de
pied_integracao_estado/pied_watchdog_estado) guardando o max(recebido_em/buscado_em) do raw
processado — não now(), para não pular o que ainda não tinha commitado.
Duas escolhas de projeto ficam explícitas:
- Margem de segurança de 10 min. O timestamp do raw é o momento da captura, mas a linha só fica visível no commit: um webhook pode commitar depois de uma página REST mais nova e ficaria eternamente atrás do cursor — perdido, em silêncio. A rodada revisita os 10 minutos anteriores ao cursor. Reprocessar essa janela é barato e, sob o guard (A), inofensivo.
- O cursor nunca retrocede (
GREATESTnoavancar): nem duas instâncias, nem o reprocesso da margem podem puxá-lo para trás e ressuscitar o replay.
Índice novo idx_pied_rest_buscado_em: o recorte varre pied_rest só por buscado_em (todas as
entidades juntas, em ordem cronológica global) e o índice da V1 é (entidade, buscado_em).
Reprocesso completo (quando for preciso reconstruir a normalizada do zero) é uma linha:
UPDATE pied_normalizacao_estado SET ate_ts = NULL;
Deliberadamente sem botão no console: é operação rara, e com o guard (A) já não há a promessa mágica de "reprocessar conserta" — evento velho continua velho.
Consequências¶
- O falso alerta de cancelamento pós-import não volta, nem por replay nem por página REST atrasada.
pago_empara de andar; passa a ser o carimbo estável que o 0010 prometeu.- A rodada de 5 min deixa de re-upsertar toda a base — custo proporcional ao raw novo.
- Trade-off aceito: uma página REST estritamente mais antiga que traria um
invoiceinédito é descartada pelo guard. Na prática o enriquecimento devolve o snapshot corrente (empate, que passa), e o caso residual se resolve na rodada seguinte com dado mais fresco. - Fora de escopo:
pied_cliente/pied_produtonão têm noção de recência (não carregamlastUpdate) e seguem com upsert "último a escrever vence" — não há guard de transição nem alerta pendurado neles, então o risco é cosmético. - Dado sujo do incidente: o
cancelado_pied_emdo 260065713 ficou carimbado. Limpá-lo (para destravar o alerta real futuro) só faz sentido depois deste deploy — antes, a rodada seguinte re-carimbava em 5 minutos.
Alternativas descartadas¶
- Blindar só o alerta (checar
lastUpdatedentro dealertarCancelamentoPosImport): remendo no sintoma. Deixariapago_em, o gate e opayloadainda sujeitos ao evento retroativo. - Ordenar o replay por
lastUpdateem vez do timestamp de captura: não resolve — o replay reaplica a história inteira de qualquer jeito, elastUpdatepode faltar no payload. - Só (B), sem o guard: mataria o replay, mas não a página REST atrasada chegando depois de um webhook novo — o mesmo falso alerta por outro caminho. As duas defesas cobrem camadas distintas.
0019 — Adota o kit de UI X-Adm (JTE) — ponteiro do ADR central 0025¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-04 · Decidido em: 2026-08-26
Contexto¶
Este RD não decide nada novo: a escolha do motor de views e do layout padrão é da casa,
registrada no ADR central 0025 (views server-render em JTE, com o kit de UI X-Adm —
layout.jte/headerRight.jte + custom-theme.css). O que faltava aqui era o rastro local:
quando este app migrou, e o que ficou dele.
A /xadm-docs (passo 3c, tipo C) cobra exatamente isso — código que adotou uma migração de stack
decidida centralmente sem nenhum registro em docs/decisoes/ do próprio repo. Sem o ponteiro, quem
abre este repo não sabe se o src/main/jte/ é adoção da norma ou invenção local.
Decisão¶
O maxsul-pied adota o kit de UI X-Adm, conforme o ADR central 0025, desde 2026-08-26
(1b1c783, "feat(views): migra UI Thymeleaf→JTE e adota comum-web 0.6.1"). A migração saiu de
Thymeleaf e trouxe junto o xadm-comum-web 0.6.1 (0008).
O que vive no repo, e sob qual regime:
| arquivo | origem | regime |
|---|---|---|
src/main/jte/kit/layout.jte, kit/headerRight.jte |
templates/views-jte/kit/** |
verbatim do central — re-derivado pela /xadm-docs |
src/main/resources/public/css/custom-theme.css |
templates/public/** |
verbatim do central — re-derivado |
src/main/resources/public/css/app.css |
scaffold templates/app.css |
copy-once — é conteúdo deste app, nunca re-derivado |
src/main/jte/kit/navMenu.jte |
deste app | conteúdo local (menu do maxsul-pied) |
As telas do repo (painel/, console*, captura*, dados/, destinatarios/) chamam o layout do
kit; o painel do colaborador tem um layout próprio por decisão anterior — separar menu de
operador do que o cliente vê (0006 §"Reusar o layout/navbar de
operador nas telas do usuário: rejeitada"). Isso não é desvio do 0025: é o mesmo kit com dois
recortes de navegação.
Consequências¶
- A
/xadm-docsmantémkit/layout.jte,kit/headerRight.jteecustom-theme.cssem sincronia com o central (o kit é opcional no manifesto; instalado, sincroniza como qualquer template). app.cssenavMenu.jtesão deste app — não re-derive nem reporte defasagem deles.- Mudança de identidade visual da casa chega por re-derivação do kit, não por edição local.
- Este RD é ponteiro: divergência de mérito sobre o motor de views se resolve no ADR central 0025, não aqui.
0020 — Adota o smoke de produção pós-deploy — ponteiro do ADR central 0031¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-09 · Decidido em: 2026-09-09
Contexto¶
Este RD não decide nada novo: o smoke pós-deploy é norma da casa (constituição §9, ADR central 0031, página smoke-producao). O que faltava aqui era o rastro local — o que este app declarou como crítico, e por quê.
O problema que o smoke resolve alcança este repo em cheio: o POST /api/ci/deploy é assíncrono,
então o pipeline ficava verde antes de o container novo servir. E /health verde é liveness —
prova que a JVM subiu e o Flyway passou, não que as telas renderizam. O maxsul-pied é
justamente um app de telas (painel do colaborador, console, captura, dados, destinatários):
render quebrado com o processo saudável é o modo de falha típico daqui, e nenhum gate o pegava
depois do deploy.
Decisão¶
Adota o smoke nas quatro camadas do 0031, declarando o manifesto no docs/app.json:
"smoke": {
"bases": ["https://pied.maxsul.xadm.biz"],
"glitchtip": { "org": "x-adm", "project": "maxsul-pied" },
"routes": [ { "path": "/health", "class": "public", "status": 200 }, … ]
}
Três escolhas locais, com o porquê:
-
basestem UMA entrada — o domínio público. O app roda dois flavors lado a lado desde av0.7.0(rodízio 50/50 jar/native, 0014), e todo release sai comDeploy: jar,native. Os dois atendem empied.maxsul.xadm.biz, então uma base cobre a superfície pública. Se um dia o recurso native ganhar FQDN próprio, ele entra aqui —basesé a lista onde deploy parcial se esconde.Limite conhecido: dois flavors atrás de uma base
A camada 1 aceita a primeira resposta de
/healthcujocommitbate (até 40 tentativas). Com duas instâncias em rodízio na mesma base, uma resposta do flavor já atualizado satisfaz a camada mesmo que o outro esteja velho — o smoke prova "alguém no ar é desta entrega", não "os dois são". O deploy dispara os dois targets no mesmo run, então a janela é estreita; mas é uma janela, e está declarada em vez de suposta.- Toda rota é
class: public. O app roda commicronaut.security.enabled=false— "tudo aberto atrás da rede" (0004/0005) — logo não há credencial a apresentar nem seam de sessão a instalar. A norma é explícita:classé a credencial que a rota exige, não o público-alvo da tela. Ligar auth aqui um dia obriga reclassificar as rotas e repor os secretsSMOKE_TOKEN/SMOKE_M2M_TOKENno pipeline. - As seis rotas são as telas, não os endpoints internos.
/health(identidade),/(painel do colaborador — a tela que o cliente usa),/captura,/console,/dados/estadoe/destinatarios./webhook/piedfica de fora de propósito: éPOSTcom efeito colateral, e a sonda do smoke éGET. A mesma lista serve dois gates — o e2e do binário native afirma ≠404 antes do deploy; o smoke afirma o status depois.
- Toda rota é
A camada 3 (log-guarantee) fica declarada: nenhuma exceção nova no GlitchTip desde a entrega.
Consequências¶
GLITCHTIP_API_TOKEN(read-only) é obrigatório nos secrets do repo no GitHub. Declararsmoke.glitchtipsem o secret REPROVA o smoke — é defeito de configuração, não degradação: sem ele a camada 3 não roda e o job ficaria "verde provando 2 de 3".- O
commitdo/healthpassa a ser o discriminador de entrega:XADM_COMMITentra como build-arg nos dois Dockerfiles e viraENVna imagem; axadm-comum-web≥ 0.9.0 o expõe. Um identificador só atravessa pipeline, imagem,/health, tag imutável e release no GlitchTip. - Reprovar reverte para
<imagem>:<sha anterior>-<target>e confirma a reversão. Isso torna a tag imutável um ativo operacional: podar a tag que está no/healthde um recurso em produção transforma o rollback em "não confirmado" no pior momento. - Este RD é ponteiro: divergência de mérito sobre o mecanismo se resolve no ADR central 0031, não aqui. O que se decide localmente é o manifesto — quais rotas não podem quebrar.
0021 — Reconciliação por remessa e re-arme na ordem certa — ponteiro do manifesto¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-09 · Decidido em: 2026-09-09
Contexto¶
O X-Adm rejeita filho sem pai, de forma terminal: cod_retorno 1XX não volta sozinho, e o
cliente do ERP só coleta 000 ou 9XX. Quando o coletor leva o item de um pedido num ciclo e o
contrato no seguinte, o item morre invisível e o pedido entra no ERP sem conteúdo.
Medido em produção em 2026-09-09 — o mesmo defeito em três pontos da cadeia:
| pedido | linha rejeitada | mensagem do X-Adm | pai ausente |
|---|---|---|---|
260079421 |
contratos 120 |
"-Cliente CPF 072.508.149-07 não cadastrado." | cliente |
260079618 |
itensped 120 |
"-Pedido (10738.24) não cadastrado." | contrato |
260078864 |
itensped 120 |
"-Produto para o pedido (260078864) não cadastrado." | produto |
Onze pedidos, 76 linhas nesse estado, e nenhum aparecia como problema — porque este app
reconciliava olhando só o contratos. O 260079618 estava CONFIRMADO com o item em 120.
A causa não é deste repo: a atomicidade do ingest se perde na fronteira do PowerSync, e o coletor não distingue "a linha não chegou" de "a linha não existe". A correção é cross-repo e a decisão de mérito é de outros donos.
Decisão¶
Este RD é ponteiro. A unidade de entrega passa a ser declarada pelo Integrador — o manifesto de remessa — e este app consome esse contrato:
| repo | papel | spec |
|---|---|---|
integracao/integrador-server |
declara a remessa no apto do apply; deriva status, bloqueado, resolvido |
.ia/005-manifesto-de-remessa |
integracao/integrador-client |
só envia ao ERP o conjunto que uma remessa cobre inteira | .ia/003-coleta-por-remessa |
maxsul/powersync |
publica as tabelas do manifesto no bucket | — |
| este repo | enxerga o pedido travado e o destrava | .ia/009-reconciliacao-por-remessa-e-rearme |
O que muda aqui:
- Reconciliação pela remessa, não por tabela.
GET /api/v1/integracao/remessas, duas chamadas dirigidas por rodada.CONFIRMADO⟺ não há remessa viva e existe ao menos umaENTREGUE; havendo viva, ela decide (TRAVADA→ERRO_XADM,ABERTA→ segueENVIADO). Isso alcançaitenspedeformulas, que a rota antiga (/retorno/{tabela}) nunca cobriu — 64 das 76 linhas travadas são fórmulas. - A mensagem reportada é a do item não-
bloqueado. O Integrador marca como bloqueado o filho cujo pai está morto; reportar o colateral faria o operador ler "Pedido não cadastrado" quando a causa é "Cliente não cadastrado". - Pedido já
CONFIRMADOvolta à mesa quando a remessa dele estáTRAVADA— sem isso os onze travados nunca seriam reclassificados. - O re-arme migra para depois da entrega confirmada (
PushEnviador, apósmarcarEnviado), e só para pedido que carrega rejeição terminal. Antes, o botão do painel re-armava no clique enquanto oPUTsó sairia até 2 min depois pelo relay — reabrindo a linha no espelho com o dado velho, a ordem que o dono proíbe (api-integrador§6). - Re-import automático: dado novo da PIED sobre pedido em falha o devolve à fila sozinho.
(Desde 2026-09-10, só para
ERROde envio: oERRO_XADMse resolve à mão — revisão da 0006.) - Entrega única sob as duas instâncias: o relay da mensageria passa a rodar sob o advisory lock deste app.
Consequências¶
- O valor depende dos outros três repos. Sem o manifesto publicado e sem a coleta por remessa, a
reconciliação não acha remessa nenhuma e não muda status — degradação por desenho, mas os onze
pedidos seguem invisíveis. Ordem:
integrador-server→maxsul/powersync→integrador-client→ este repo → re-arme dos travados. - O flash do Reimportar mudou para "Enfileirado: a remessa reabre no X-Adm junto com a entrega" — o botão não chama mais o Integrador.
GET /retorno/contratosfoi aposentado neste app, com oRetornoXadmque o acompanhava. O campochave_xadmdeixa de ser preenchido — e não perde nada: estava nulo nas 21.326 linhas depied_pedido.mensageria.relay.enablednascefalse: quem agenda o relay é oRelayJoblocal. Reverter é devolver a property atruee apagar a classe. (Feito em 2026-09-09 — axadm-mensageria0.3.4 trouxe a eleição de réplica para dentro da lib; a property voltou atruee a classe saiu. Ver a revisão da decisão 0014.)- Divergência de mérito sobre o manifesto se resolve nas specs dos donos, não aqui.
0022 — Persistência por Micronaut Data: o critério lote × CRUD¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15 · Decidido em: 2026-09-11
Contexto¶
O int-pied nasceu com a persistência inteira em SQL cru: repositórios @Singleton abrindo
dataSource.getConnection() e montando o PreparedStatement à mão — 58 chamadas em 16 classes. A
decisão 0012 trouxe o micronaut-data-jdbc à força
(o outbox do xadm-mensageria é @JdbcRepository) e registrou o app como "raw-SQL por design": o
Data entrou para o @Transactional funcionar, e o SQL continuou à mão.
A norma da casa fechou essa porta depois
(engenharia/java-micronaut §Banco):
micronaut-data-jdbc é obrigatório para server que persiste, e SQL cru via
DataSource.getConnection() como camada de persistência é anti-padrão. O que segue legítimo à mão é
o trabalho de lote num @Singleton — o veto é à persistência inteira em SQL cru.
Decisão¶
Toda persistência é Micronaut Data; SQL à mão só onde é trabalho de conexão de verdade, numa allowlist guardada por teste. O critério por repositório:
| Tipo de acesso | Forma | Onde |
|---|---|---|
| CRUD puro | @JdbcRepository sobre CrudRepository, entidade @MappedEntity, métodos derivados + @Query pontual |
DestinatarioAlertaRepository, WatchdogEstadoRepository |
| Comando, upsert, projeção | @Query nativo com o SQL de antes, numa interface @JdbcRepository aninhada (Sql) |
PiedPedidoRepository, CursorRepository, NormalizacaoEstadoRepository, PiedClienteRepository, PiedProdutoRepository, PiedWebhookRepository, PiedRestRepository, PainelRepository, DadosRepository, CapturaRepository |
| Poda por idade | @Query nativo, um DELETE por tabela |
RetencaoRepository |
| Lote / sessão (manual) | DataSource.getConnection() |
NormalizadorService (varredura do raw), PiedClusterLock (advisory lock) |
- A classe do repositório fica; o SQL vai para a interface aninhada. Nos repositórios de comando e
projeção, a classe
@Singletonmantém a API de domínio (osbooleande "fez a transição?", os defaults de "sem linha", o envelope JSON do webhook, o pretty-print da captura), a transação REQUIRED por método (@Transactionalna classe, como antes) e o construtor que os fakes dos testes unitários usam (super(null)). OSqlé o@JdbcRepository. Repositório sem nada disso a preservar é a própria interface (RetencaoRepository, e os dois de CRUD). - Muda o transporte, não a semântica. O texto SQL é o mesmo:
?virou:nomee?::jsonbvirouCAST(:x AS jsonb). Nopied_pedidoisso inclui o upsert com os quatroCASEde transição e a guarda de recência, e as transições guardadas porstatusnoWHERE. Nuncasave()/update()de entidade nopied_pedido: oUPDATEgerado reescreveria as colunas da máquina de entrega e as guardas deixariam de existir. - A chave e os carimbos seguem com o banco. Insert por
@Queryonde osave()exigiria gerar ouuidno app;uuidv7(),criado_em/recebido_em/buscado_emeatualizado_em = now()como antes. Ojsonbcontinua gravado como texto convertido pelo banco — o mesmo dado. - Sai o código sem chamador:
IntegracaoEstadoRepository,PiedPedidoRepository.enfileirarElegiveis(o corte go-forward perdeu o papel na revisão do gate de 2026-08-07) ePiedClienteRepository.inscEstDe(lido só por teste; a leitura foi para aBancoFixture, decisão 0013). A tabelapied_integracao_estadofica: só o/dados/estadoa lê.
Correção (2026-09-15): a varredura do raw do
NormalizadorServicesai da allowlist — o motivo "lote / sessão" não corresponde ao código. OPreparedStatementda varredura não chamasetFetchSize, e sem ele o PgJDBC traz oResultSetinteiro para a memória antes da primeira linha: não há streaming a preservar, é umSELECTcomum aberto à mão. O texto SQL segue o mesmo, agora@Querynativo noRawCapturadoRepositorydevolvendo a projeção@Introspected RawLinha(ts,fonte,tipo,payload), com o recorte incremental num parâmetro nomeado único (CAST(:desde AS timestamptz), o mesmo valor testado contraNULLe comparado nas duas fontes). A allowlist fica só com oPiedClusterLock, cujo advisory lock de sessão precisa mesmo da conexão. O consumo de memória não muda de ordem: o recorte pelo cursor (V11) é que mantém a leitura pequena.
A trava¶
FronteirasTest.persistenciaSoPorMicronautData: nenhuma classe fora da allowlist chama
DataSource.getConnection() (casa por nome e por dono atribuível a DataSource, então pega também a
chamada por um subtipo). Provada com mutante: uma classe nova em dados chamando getConnection()
deixa o teste vermelho, apontando a linha; sem ela, verde. Pôr uma classe na allowlist é decisão, com o
motivo no comentário ao lado — não atalho.
O que o Micronaut Data 5.0.4 cobrou¶
Achados da migração, todos verificados no build:
- Todo
@JdbcRepositoryprecisa de entidade-raiz, mesmo só com@Query: semGenericRepository<E, ID>o processor falha com Persistent entity is required. Nos repositórios de SQL nativo a raiz é umrecord Raiz(@Id …)que mapeia só a chave. - O mapeador de projeção lê a propriedade homônima da raiz antes da do record. Por isso o
DadosRepositorye oCapturaRepository, cujas linhas têmidde tipos diversos (uuid como texto,intdo singleton), usam uma raiz chaveada porentidade, não porid. - Record de projeção precisa de
@Introspectede de@Nullableonde a coluna pode vir nula — sem a anotação o mapeador recusa o null ao construir o record. O mapeamento passou a ser por nome de coluna (antes era por posição): onde o rótulo diferia do componente, entrou um alias (documento_cliente AS documento). @Queryexige constante de compilação, e três leituras montavam o SQL em runtime. Viraram variantes finitas: no painel, um método por bucket com o predicado literal — e oStatusBucketTesttrava cada literal contra oStatusBucket.sqlFiltro(), para bucket novo não divergir em silêncio —; na timeline da captura, uma por fonte; na retenção, umDELETEpor tabela.- Parâmetro nomeado é só
[a-zA-Z0-9](sem_), e o::do Postgres saiu do texto (CAST(… AS …)) para o SQL não depender do parser de parâmetros. - Falha de banco chega como
DataAccessException, não mais como oIllegalStateExceptionque os repositórios embrulhavam. Os chamadores capturamRuntimeException/Exception, então o comportamento não muda. - Native: o Micronaut Data gera introspecção e consultas em compilação, sem reflexão — nenhum hint a acrescentar.
Alternativas descartadas¶
- Repositório como classe abstrata
@JdbcRepository(API pública concreta,@Queryabstrato). Funciona, mas os fakes dos testes unitários (PushEnviadorTest,PollServiceTest) herdam os repositórios e teriam de implementar cada@Queryabstrato — cerca de vinte só noPiedPedidoRepository, e mais um a cada consulta nova. - A interface
@JdbcRepositorycomo o próprio repositório, com a lógica em métodosdefault. O mesmo problema dos fakes. - Entidade completa +
save()/update(). Reescreveria o SQL que carrega regra de negócio (FSM guardada noWHERE,COALESCE/GREATEST,now()do banco): mudaria a semântica, não só o transporte.
Consequência¶
A decisão 0012 deixa de ser "raw-SQL por design" (ver a
emenda de 2026-09-11 nela). A suíte de testes atravessou as cinco fatias da migração sem mudança, salvo a
leitura do state_inscription (foi para a BancoFixture) e a API da retenção.
0023 — Apagão de webhook: watchdog comercial e resgate da 1ª captura já paga¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-21 · Decidido em: 2026-09-21
Contexto¶
Em 18/09/2026 (sexta) quatro pedidos pagos de manhã não chegaram ao X-Adm: 260082900,
260083184, 260083199, 260083202. O cliente percebeu na segunda e importou um à mão.
A reconstrução no banco e no log do container fecha a cadeia:
| hora (BRT) | evento | fonte |
|---|---|---|
| 07:59:26 | último webhook recebido | log WebhookController |
| 08:02:58 | Falha de rede/timeout (1/3): Read Timeout |
log IntegradorClient |
| 08:03:03 | Connect Error: int.maxsul.xadm.biz: Temporary failure in name resolution |
log |
| 08:08:13 | última falha de rede — a degradação durou ~5 min | log |
| 09:12 – 10:37 | os quatro pedidos viram order (orderCreated) e são pagos |
pied_rest |
| 10:43:44 | webhook volta — 2h44 de silêncio | log |
| 11:01:49 | poll REST de 6h captura os quatro — já received |
pied_rest |
A rede do servidor oscilou por cinco minutos; a PIED tomou timeout ao entregar e desativou o webhook. O apagão durou 2h44 — 33× a falha de rede — e só terminou quando alguém recadastrou o webhook no dashboard da PIED com a mesma URL. É o modo de falha da decisão 0016, disparado por timeout e não por 404.
O app não teve culpa nem participação: container StartedAt=2026-09-17T19:46:36Z, restarts=0, e o
NormalizadorService logando de cinco em cinco minutos durante todo o silêncio. Estava ouvindo; não
chegou nada.
Dois pontos cegos deixaram o estrago passar:
- O watchdog não viu. Ele media dia útil inteiro (24h úteis, seg–sex). Um apagão de 2h44 dentro do expediente nem se aproxima do limite — e é exatamente a janela em que o prejuízo acontece, porque é quando pedidos nascem.
- O backstop resgatou o dado e não conseguiu entregá-lo. O poll REST existe para cobrir buraco de
webhook e fez o seu papel: trouxe os quatro. Mas o gate de envio é a transição de pagamento
para
received, e quem só vê o pedido depois de pago não tem transição a testemunhar. Os quatro encalharam emCAPTURADO, invisíveis. O fallback e o gate se anulavam justamente no caso que o fallback existia para cobrir.
O ponto 2 já estava registrado como trade-off conhecido ("o furo da 1ª captura", pendencias.md P1) com o console como válvula manual. O apagão mostrou que a válvula manual não basta: ninguém olha o console sem saber que há o que olhar, e o ponto 1 garantia que ninguém soubesse.
Decisão¶
Fechar os dois pontos cegos. As duas metades são complementares — a primeira avisa, a segunda resgata sozinha — e nenhuma substitui a outra.
A — Watchdog com régua comercial¶
O WatchdogJob passa a alertar por duas réguas, a que vier primeiro:
- comercial — minutos de silêncio dentro do expediente da Maxsul (seg–sex, 8h–18h, informado
pelo cliente), limite 45 min (
PIED_WATCHDOG_LIMITE_MIN_COMERCIAL); - dia útil — as 24h úteis de hoje (
PIED_WATCHDOG_LIMITE_HORAS), mantida como rede do apagão que começa fora do horário e atravessa a noite ou o fim de semana.
O intervalo do job cai de 1h para 5min: varrer de hora em hora anularia uma régua medida em dezenas de minutos. O alerta segue 1x por episódio e as duas réguas dividem o mesmo episódio — a que estourar primeiro alerta, a outra se cala.
Os 45 min são medidos, não chutados. Em 6.935 intervalos entre webhooks dentro do comercial (set/2026), apenas dois passaram de 15 min: o apagão de 26h de 14–15/09 (falha real, que a régua antiga pegou) e um gap de 37 min no almoço de 16/09. Nenhum outro acima de 20 min. 45 não gera falso positivo e teria alertado o apagão de 18/09 por volta das 08:45 — antes de o primeiro dos quatro pedidos existir (09:12).
A mensagem do alerta passa a citar o timeout ao lado do 404 como causa provável, e a dizer que a PIED não reenvia o que se perdeu — o recadastro restabelece o fluxo, não recupera o buraco.
B — Resgate da 1ª captura já paga¶
Quando o pedido é visto pago já na primeira captura, o upsert passa a poder enfileirá-lo direto
(NA_FILA), sem transição testemunhada. O discriminador é o orderCreated da PIED, que o REST
traz (o webhook não): apagão produz pedido de horas atrás; backlog antigo, de meses.
A regra vive em GatePrimeiraCaptura (pura, testável) e é levada ao SQL como um booleano — o upsert
continua sendo quem sabe se houve transição. Vale tanto no INSERT quanto no DO UPDATE de um
CAPTURADO que nunca transicionou (pago_em IS NULL), porque o pedido pode ter entrado antes pelo
webhook (sem orderCreated) e só ganhar o campo numa passagem posterior do REST.
Duas cercas impedem despejo de backlog:
- piso (
PIED_GATE_PRIMEIRA_CAPTURA_DESDE,AAAA-MM-DD): só pedido criado depois da data de ativação. Vazio desliga o resgate, e vazio é o default — ligar é decisão de deploy. Sem o piso, subir esta versão enviaria na primeira rodada quatro pedidos legados (260081247,260081395,260081535,260082852) que já andaram no fluxo e podem ter sido digitados no ZIM à mão; como o X-Adm só fazINSERT, isso viraria pedido duplicado. - janela (
PIED_GATE_PRIMEIRA_CAPTURA_JANELA_DIAS, default 7): idade máxima doorderCreated. Cobre o apagão que atravessa um fim de semana e nunca alcança o backlog da PIED (21 mil pedidosCAPTURADO, alguns de 2025).
O Enfileirar manual do console continua existindo para o que ficar fora das cercas.
Consequências¶
- O apagão vira visível em menos de uma hora, dentro do expediente, com a ação no próprio alerta. Fora do expediente nada muda — ninguém agiria antes das 8h de qualquer forma.
- O backstop volta a ser um backstop de verdade: o poll REST que resgata o pedido perdido agora consegue entregá-lo. O par apagão-curto + poll-de-6h deixa de produzir pedido encalhado.
- O deploy é inerte por default. Sem
PIED_GATE_PRIMEIRA_CAPTURA_DESDE, B não muda nada; o comportamento é idêntico ao de antes. Ligar exige a data explícita. - O gate segue sendo a transição no caso normal. B não relaxa o gate: é uma exceção cercada para quem nunca teve transição a ser vista.
- Pedido sem
orderCreatedno payload nunca é resgatado (falha fechada) — cai no console, como hoje. - O watchdog roda 12× mais vezes; o custo é uma consulta de
max(recebido_em)por rodada, sob o mesmo lock de cluster (0014).
Alternativas consideradas¶
- Limite comercial de 30 min: rejeitado — o gap de 37 min do almoço de 16/09 viraria alerta, e um watchdog que cria ruído semanal é um watchdog que o operador aprende a ignorar. 45 dá margem e ainda pega o apagão bem antes do primeiro pedido.
- Trocar a régua de 24h úteis pela comercial: rejeitado — apagão iniciado às 18:01 de uma sexta ficaria sem nenhuma régua até segunda de manhã. Duas redes, custo quase zero.
- B sem piso, só com a janela de 7 dias: rejeitado — enviaria os quatro legados no primeiro ciclo, com risco de duplicata no ZIM que ninguém pediu para correr. O piso torna o deploy inerte.
- Janela de 2 dias em vez de 7: rejeitado — hoje pegaria zero legados, mas não cobre o apagão que começa sexta à tarde e só é notado na segunda, que é a forma mais provável do próximo incidente.
- Enfileirar todo
CAPTURADO+received: rejeitado pelo mesmo motivo de sempre (decisão de 2026-08-07) — despeja backlog antigo da PIED no ERP. - Detectar o apagão pelo lado da PIED (consultar se o webhook está ativo): fora de alcance, não há endpoint documentado; o cadastro é manual no dashboard (runbook).
Glossário do projeto¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-04
Vocabulário próprio desta integração. Termos gerais da plataforma estão no glossário da plataforma X-Adm.
- PIED
- Plataforma em que a empresa de painéis solares da Maxsul registra produtos, clientes e pedidos.
Expõe API REST (
Authorization: Bearer) e webhooks de eventos. - X-Adm
- ERP de mesa usado pela Maxsul para a operação fiscal — o destino final dos dados (fase 2).
- Caminho robusto (caminho 2)
- Arquitetura com servidor always-on (este app) + staging + PowerSync + cliente Java junto ao ERP,
em oposição ao caminho simples (CLI
poc-pied-simples). Ver decisão 0002. - Landing (captura raw)
- Tabelas
pied_webhookepied_rest: os payloads da PIED gravados crus (jsonb), antes de qualquer transformação. Fonte para análise da API e para o transform da fase 2. - Webhook
POST /webhook/pied— a PIED empurra eventos (order.created,budget.updated...) em tempo real. O cadastro do endpoint no painel da PIED é manual.- Poll
- Job agendado que consulta ativamente a API REST da PIED, paginando produtos, clientes e pedidos
e gravando cada página crua em
pied_rest. - Envelope
- Forma comum das respostas REST da PIED:
{error, data: {items, totalItems}}. Página além do fim retorna HTTP 200 comitemsvazio — a condição de parada da paginação. lastUpdateAfter- Parâmetro
?lastUpdateAfter=AAAA-MM-DDque filtra pedidos por data de atualização no servidor. Só funciona em pedidos — produtos e clientes são sempre varridos por inteiro. - Cursor
- Data persistida em
pied_cursorusada comolastUpdateAfterda próxima rodada de poll. Avança para a data do início da rodada apenas em caso de sucesso. - Toggle
- Chave liga/desliga por variável de ambiente (
PIED_WEBHOOK_HABILITADO,PIED_POLL_HABILITADO,PIED_INTEGRACAO_HABILITADA). Permitem entrar em produção por partes. - Fase 1 / Fase 2
- Fase 1 = este MVP (só captura raw). Fase 2 = transform para o formato X-Adm + entrega via PowerSync até o ERP. Ver decisão 0001.
- PowerSync
- Serviço que replica o Postgres de staging para o SQLite local do consumidor (offline-first, watch
reativo) — mecanismo de entrega da fase 2 (
ps.maxsul.xadm.biz). - Integrador
- Plataforma de integração da casa X-Adm (
int.maxsul.xadm.bizna instância Maxsul) — staging/API que recebe o estado desejado para o ERP na fase 2. - Console operador
- Tela única
/consolede operação da fase 2 (absorveu o antigo/inspetor+/fila). Dirige cada pedido pela máquina de estados real (pied_pedido.status) e expõe, por pedido, o de-para dry-run, o stepper e as ações liberadas pelo estado (puxar, enfileirar, enviar, atualizar, reenviar, re-normalizar) + sync de clientes. Interativo emSTAGING, monitor emPRODUCAO. - Máquina de estados (entrega)
- Coluna
pied_pedido.status— fonte única do progresso de um pedido na entrega ao X-Adm:CAPTURADO → NA_FILA → ENVIANDO → ENVIADO → CONFIRMADO, com os ramosERRO_XADM(rejeição do X-Adm;1XXterminal /9XXreprocessável) eERRO(falha de transporte, reenfileirável). - Dois eixos de status (integração × PIED)
- Um pedido tem dois status independentes, exibidos em colunas separadas na lista do painel — não confundir: Status Integração é o nosso lado (o pedido ainda não foi levado ao X-Adm = "Pedidos em aberto"); Pagamento PIED é o lado da PIED (lá o mesmo pedido já pode constar Pago). Um pedido "em aberto" (integração) e "Pago" (PIED) ao mesmo tempo é o caso normal — é justamente ele que os botões Importar/Dispensar tratam.
- Status amigável (bucket) — eixo integração (coluna "Status Integração")
- Tradução do
statustécnico da entrega para 4 rótulos voltados ao usuário final no painel (/,/pedidos/{code}). Fonte única:painel/StatusBucket. Mapa:
| Bucket (UI) | Estados internos |
|---|---|
| Pedidos em aberto | CAPTURADO, NA_FILA sem prontidão |
| Enviando ao X-Adm | NA_FILA pronto, ENVIANDO, ENVIADO, ERRO |
| Importados no X-Adm | CONFIRMADO, IMPORTADO_MANUAL (dispensado na home, ou "Importado manualmente" após uma falha) |
| Falha ao importar | ERRO_XADM |
Desconhecido/branco → Pedidos em aberto. ERRO (falha de transporte, transitória) cai em
Enviando ao X-Adm; só ERRO_XADM (rejeição do X-Adm, final) é Erro.
Prontidão (2026-09-04) — NA_FILA é o único status cujo bucket não sai só do nome: pedido
capturado por webhook nasce sem o invoice (Faturamento, que só vem no REST) e o PushService o
segura na fila até o enriquecimento sob demanda buscá-lo
(decisão 0017). Sem invoice ele volta para
Pedidos em aberto (está esperando dado); com invoice, é Enviando ao X-Adm (está a
caminho). Os botões Importar/Dispensar seguem só no CAPTURADO — o preso já está na fila. Ver
decisão 0006 (vocabulário revisado pela spec .ia/003).
- Pagamento PIED — eixo PIED (coluna "Pagamento PIED")
- Estado do pedido na PIED, ortogonal ao status de integração. Duas infos na mesma coluna:
o pagamento traduzido (
payment.status→painel/PagamentoPied) e o estágio cru (deal_status). Mapa do pagamento:
| Rótulo (UI) | payment.status cru |
|---|---|
| Pago | received |
| Solicitado | requested |
| Não solicitado | notRequested |
| Parcial | partial |
| Cancelado | cancelled |
| — | vazio / desconhecido |
received é o que dispara o Gate (pagamento recebido) (abaixo). O deal_status é o texto do estágio
do pedido na PIED (ex. "Finalizado"), exibido cru.
- Painel colaborador
- Telas abertas do usuário Maxsul, chrome limpo da marca XADM:
GET /(dashboard — tiles de status + lista de pedidos) eGET /pedidos/{code}(detalhe — timeline + dados + itens). Quase read-only (os botões Importar/Dispensar da home, para pedido em aberto já pago, e Importado manualmente — mais o Re-Importar, só quando parte do pedido ainda está pendente —, para pedido em Falha); as ações de operação ficam no console operador (/console). A home de diagnóstico (cards) vive em/homedev. - Gate (pagamento recebido)
- Regra que decide quando um pedido entra na fila de envio (Maxsul, 2026-08-07): a transição do
payment.statusparareceived— tínhamos o pedido não-pago e chegou um update que o marca pago. Detectada por evento no upsert depied_pedido(payment_status/payment_status_anterior). Pedido que aparece já-pago na 1ª captura NÃO dispara (pode ser antigo). Manual no console (Enfileirar) exigepayment_status='received'.dealStatusdeixou de ser o gate. - Resgate da 1ª captura já paga
- Exceção cercada ao Gate acima (decisão 0023, depois do apagão de webhook de 18/09/2026): quando o
pedido é visto já pago na primeira captura — webhook perdido, poll REST o alcança depois de pago —
não há transição a testemunhar e ele encalha em
CAPTURADO. Com o resgate ligado, o upsert enfileira direto, cercado peloorderCreatedda PIED: piso (PIED_GATE_PRIMEIRA_CAPTURA_DESDE, data de ativação; vazio = desligado, o default) e janela de novidade (7 dias). Regra emGatePrimeiraCaptura. Não é relaxamento do Gate: só cobre quem nunca teve transição a ser vista.