Pular para conteúdo

0013 — Fluxo de entrada PIED no espelho do X-Adm (chave dupla + write-back)

Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-15 · Decidido em: 2026-07-15

Atualização 2026-07-15 — identidade do produto. A identidade do produto no espelho é ChaveEst (e CodProd, versão virtual/substring do ChaveEst, usada na instância Thoms onde ChaveEst chega null) — confirmado com a equipe ERP. O codigo_alt (código do produto na PIED), adotado abaixo como chave da entrada, foi reenquadrado como transporte e renomeado para pied_codigo_alt (migração V19) — identidade nunca é vocabulário do parceiro. O estoque da Thoms vem do PUT de saída do X-Adm (não do fluxo de entrada PIED); o apply resolve a saída por ChaveEst ou, quando null, por CodProd. Onde o texto abaixo disser codigo_alt como chave, leia pied_codigo_alt (transporte) + identidade ChaveEst/CodProd. Fase 2 (V20) fará o mesmo rename em x_ped/itensped.codigo_alt. Ver .ia/007-identidade-produto-espelho-spec.md.

Atualização 2026-07-15 — sinal de mudança de estoque (feature 008). O tradutor WebStorm-ecom da Thoms (clientes/thoms/int-produtos-ecom) consome o estoque deste espelho. Duas entregas do integrador destravam o v1 dele (migração V21): (1) coluna estoque.updated_at (TIMESTAMPTZ) como chave-versão, mantida no Java por Micronaut Data (@DateUpdated) na entidade Estoque — auto-populada em toda escrita de entidade (save/update), por qualquer escritor que passe pelo repositório (apply XADM, tela admin EstoqueViewController, API REST, write-back PowerSync). Cobre os 4 escritores porque todos usam Micronaut Data; o @Query cru que só mexe em nome_prod_curto não passa por entidade e (de propósito) não bumpa. Sem dirty-check: bumpa também em reenvio idêntico do X-Adm e no write-back de cod_retorno/chave_est → o tradutor reenvia à WebStorm com mais frequência (inofensivo — WebStorm é idempotente, o tradutor coalesce). Alternativa preterida: trigger Postgres com IS DISTINCT FROM (dava o dirty-check, mas o time preferiu manter a lógica em Java, sem trigger). (2) Webhook de saída (poke): EstoqueMudouEvent (publicado pós-commit pelo XadmService quando o payload tocou estoque) → EstoqueWebhookListener (virtual thread fire-and-forget) → @Client POST vazio + Bearer ao tradutor, config-gated off (webstorm.webhook.enabled, só o deploy Thoms liga — env vars do Coolify em dev/application-config.md). Best-effort: falha do poke não derruba o XADM — o @Scheduled do tradutor é a rede de segurança (o updated_at já está persistido). Escritas via admin/API não pokam, mas bumpam updated_at → chegam pelo sweep. Ver .ia/008-sinal-estoque-tradutor-spec.md. Evolução aberta (novo prompt .ia/009): estender updated_at a todas as tabelas espelho do X-Adm (não só estoque).

Atualização 2026-08-11 — FORMULAS (fórmula/BOM do kit). Nova tabela-espelho de entrada (.ia/013): a fórmula de um pedido-kit PIED — uma linha por componente (pied_x_ped, pied_cod_prod, qtde). Distinta das demais tabelas de entrada em três pontos: (1) o id do X-Adm (pied_formula_id, sequencial) é gerado por nós (GENERATED ALWAYS AS IDENTITY, coluna DB não-mapeada na entidade — a réplica WAL do PowerSync a carrega), não pelo X-Adm — as outras tabelas recebem a identidade Chave* do X-Adm; (2) sem colunas Chave* — o X-Adm nunca devolve chave neste fluxo (confirmado no staging: 0/62 itensped PIED têm chave), só cod_retorno/msg_retorno (006); (3) sem DELETE — a PIED só envia pedido pago, imutável. Upsert single-key (sem dual-key). Contrato público em xadm-ingest; migração V25__formulas.sql.

Contexto. O Integrador é um espelho do ERP X-Adm — todas as tabelas são tabelas do X-Adm, num modelo único. Até aqui só havia o fluxo de saída: X-Adm → Integrador → PowerSync → apps mobile (projetos Sul Plata / OnPetro). O X-Adm é o produtor e empurra por /api/v1/xadm com a Chave* (identidade do ERP) já preenchida.

A integração PIED (Maxsul) introduz o fluxo de entrada: PIED → INT-PIED → Integrador → PowerSync → X-Adm. Agora uma fonte externa (PIED, pedidos de venda) é o produtor. As mesmas tabelas do espelho passam a receber mais colunas (as mesmas tabelas do ERP, com o subconjunto de colunas que a Sul Plata não usava). A Chave* chega vazia — quem a gera é o X-Adm, que a devolve com o status pelo write-back. Schema autoritativo: documento MAXSUL2025_610110 (repo clientes/maxsul/int-pied); o dono do Flyway é este repo.

Decisão. Trabalho aditivo ao espelho compartilhado (mesmo modelo, sem schema por cliente, sem tabelas paralelas). Quatro escolhas centrais:

  1. Chave dupla no upsert (rastro L12). O upsert*/softDelete* resolvem a linha pela Chave* do X-Adm quando o produtor a fornece (fluxo de saída, comportamento intacto) e caem para a chave natural da fonte externa quando ela chega vazia (fluxo de entrada) — contratos→pied_x_ped, estoque→pied_codigo_alt (ou cod_prod na saída Thoms), itensped→(pied_x_ped,pied_codigo_alt), propriedades/fones→cgc_cpf (nomes pied_* após V19/V20 — ver nota de atualização no topo). Materializado no finder de cada método (o EscritaPorChaveNatural não muda). Alternativa preterida: métodos de upsert paralelos por fluxo — mais código, mesma lógica.
  2. Colunas de retorno são preenchidas só pelo write-back (L12). No fluxo de entrada, a fonte (PIED) é dona das colunas de dado; o X-Adm é dono das colunas de retorno (Chave*, CodRetorno, MsgRetorno). Elas são read-only no caminho de entrada e chegam pelo write-back (POST /api/v1/powersync). No copiaCampos do ramo de entrada elas são preservadas (não sobrescritas com o branco que a fonte envia) — senão o reenvio idempotente apagaria o retorno recém-gravado. Mesmo padrão do nomePropCurto. As telas admin mostram essas colunas read-only.
  3. NOT NULL relaxado + crítica em runtime (L15). O fluxo de entrada deixa vazias tanto colunas de dado do fluxo de saída (seq_veic, cod_mun, nome_prod…) quanto as colunas-chave (chave_cp/chave_est/chave_prop/chave_item, NOT NULL antes — o X-Adm só as gera depois). A migração V18 relaxa todas para nullable; a garantia de cada fluxo passa à crítica na camada de apply (rejeita registro sem chave natural resolvível). Alternativa preterida: CHECK condicional no schema — acopla o banco à noção de fluxo.
  4. Write-back reusa o PowerSync (L16), sem endpoint novo. O transporte (insert/update local → uploadData → POST /api/v1/powersync) já existia. Só a aplicação de campos do PowersyncController foi estendida: somar contratos/itensped/fones ao switch e cada patchX (inclusive patchPropriedades/patchEstoque, que só aplicavam nome-curto) gravar cod_retorno/ msg_retorno/chave_*. fones resolve a linha só por id (tabela nova, sem old_id).

Princípio arquitetural — colunas de retorno do X-Adm

Toda tabela que volta ao X-Adm (fluxo de entrada) carrega CodRetorno + MsgRetorno — princípio universal, sem exceção. O X-Adm atualiza essas duas colunas com o status do processamento de cada linha que recebe (junto com as Chave* que ele gera). Códigos (doc-mãe MAXSUL2025_610110, ver clientes/maxsul/int-pied/docs/projeto/mapeamento.md): 006 = gravado no X-Adm (sucesso); 000/00X = novo/em processamento (pendente, ainda não cadastrado); 1XX = erro terminal; 9XX = erro de processamento. Consequências:

  • Fluxo de saída (ex. Sul Plata): as tabelas não voltam ao X-Adm — CodRetorno/MsgRetorno existem no schema compartilhado mas ficam sempre null.
  • Fluxo de entrada (ex. PIED): CodRetorno/MsgRetorno são preenchidas pelo write-back e seguem a especificação do fluxo (clientes/maxsul/int-pied). fones também carrega CodRetorno (o princípio é universal) — isso diverge da doc-mãe MAXSUL2025_610110, que especificou fones só com MsgRetorno; a divergência é intencional e deve ser reportada à equipe X-Adm / maxsul-pied.

Leitura do retorno — reconciliação (fecha o loop)

O int-pied precisa saber o resultado real do X-Adm para reconciliar o próprio estado. O Integrador expõe GET /api/v1/xadm/retorno/{tabela} (por chave natural do PIED: contratos?xPed, estoque?codigoAlt, itensped?xPed+codigoAlt, propriedades/fones?cgcCpf; {tabela} case-insensitive; ?origem= opcional/ignorado). Devolve um DTO enxuto { tabela, status, codRetorno, msgRetorno, chaveXadm } — HTTP 200 sempre (poll não toma 404; 400 só para tabela/chave inválida; Bearer herdado do ApiBearerSecurityRule). O status é derivado dos códigos do X-Adm: 006→CONFIRMADO, 000/00X(≠006) ou branco→PENDENTE, 1XX/9XX→REJEITADO, linha ausente (ou soft-deleted)→NAO_ENCONTRADO; o codRetorno cru também volta (para distinguir 1XX×9XX). Read-only — não grava em request (poll de alta frequência). Mantém o acoplamento na API (o int-pied não lê o Postgres direto). Código: ingest/XadmRetornoController + StatusRetorno.derive.

Tipos/nomes (L13/L14/L17): convenção da casa (JSON PascalCase — com o pedido literalmente xPed, x minúsculo — → DB snake_case → Java camelCase); tipos casando com o repo (CHAR/VARCHAR/NUMERIC/DATE). fones é 1 contato por cliente (cgc_cpf UNIQUE). chave_prop ampliado a CHAR(11). Índices unique das chaves da fonte são parciais (WHERE … IS NOT NULL) — não colidem com as linhas do fluxo de saída. INT-PIED entra em XadmService.KNOWN_ORIGENS.

Conformidade à largura fixa do espelho (o espelho é refém do X-Adm)

As colunas do espelho têm a largura fixa do X-Adm real — nunca são alargadas (o valor não caberia no X-Adm no sync). Então a fonte se ajusta ao espelho, não o contrário (achados do e2e local):

  • Chave = código, não ObjectId. A chave natural da entrada (xPed, CodigoAlt) é o data.code do pedido PIED (número, ~9 chars, cabe no CHAR(15)), não o data.id (ObjectId de 24, que estoura). O de-para do int-pied e a reconciliação (GET /retorno) usam o code dos dois lados. No kit, o itensped.CodigoAlt = o mesmo data.code do estoque (o item referencia o produto; o apply resolve itensped por (xPed, CodigoAlt) — vazio não resolve).
  • Texto trunca no ingest; chave erra. O EscritaPorChaveNatural trunca cada String ao @Size(max) declarado na própria entidade (fonte única = o domain, lido por introspection) antes de persistir, com log.warn. As chaves cabem por construção (code≤15, cgcCpf=14) e, se estourassem, erram no persist — chave que não cabe é violação de contrato, não texto a cortar.

Consequências / trade-offs.

  • O fluxo de saída segue idêntico (o ramo Chave* do upsert não mudou); a garantia de chave saiu do schema (NOT NULL) e virou crítica em runtime — inclusive o CRUD admin perdeu a validação de chave em branco (o admin não passa pela crítica do ingest). Aceito: admin é operador confiável.
  • Cobertura de teste: o round-trip real-DB (idempotência, preservação do retorno, write-back) roda via fones (repo real); a chave dupla das demais tabelas fica em unit (Mockito). Motivo: os @Factory @Replaces sem @Requires em PushContextBuilderIntegrationTest/PowersyncControllerTest mockam esses repositórios globalmente em todo @MicronautTest. Guardá-los com @Requires é um follow-up de test-infra.
  • V17-fantasma (resolvido): a migração nova é V18 (pula V17). O V17__request_identity_sequence_sync foi adicionado e removido do classpath; com validate-on-migrate: true, se aplicado em produção o boot do Flyway falharia. Endereçado por flyway...ignore-migration-patterns: ["*:missing"] no application.yml (tolera migração aplicada-mas-ausente; no-op em banco novo). V18 vingou e as migrações seguiram por cima até V25 em produção — o número está consolidado.
  • chave_prop=11 (confirmado): validado com o PIED rodando em staging sem erro de persist de chave — a largura 11 cabe a chave real da fonte. Pendência de aval encerrada.
  • Fora de escopo: a fase 2 do maxsul-pied (produtor real dos campos); a config de down-sync do PowerSync (carregar as colunas novas ao cliente/X-Adm); múltiplos endereços por cliente (assumido 1).

Detalhe de mecânica vive no código (ingest/XadmApplyService, ingest/XadmPayloadParser, powersync/PowersyncController, migração V18__fluxo_entrada_pied.sql) — aqui fica só o durável.