Pular para conteúdo

Baixa de MDF-e por macro Sascar

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

O Integrador é a ponte entre quem detecta o fim da viagem (o int-sascar, via macro no rastreador Sascar) e quem encerra a MDF-e no ERP (o integrador-client, no ZIM). Ele espelha as tabelas de MDF-e do X-Adm, avisa o int-sascar quais MDF-e vigiar, recebe o callback de encerramento e grava um comando de baixa que o client executa e reporta de volta.

Tudo é aditivo ao ingest existente: mesmo endpoint PUT /api/v1/xadm, sem rota nova de entrada. As escolhas de desenho e o preterido estão na decisão 0015.

Fluxo ponta a ponta

X-Adm ──MDFE_PUT──▶ Integrador ──watch aberto──▶ int-sascar
                       (espelho mdf)                  │
                                                 (motorista fecha macro)
                                                      │
Integrador ◀──POST /api/sascar/encerramento───────────┘
   │ grava situacao_baixa=BAIXA, cod_retorno=000
   ▼
PowerSync ──▶ integrador-client (ZIM) executa ENCERRA_MDFE, escreve cod_retorno=006
   │
   ▼ (write-back PowerSync)
X-Adm ──MDFE_PUT com NFEEVENTO(110112,A)──▶ Integrador ──watch encerrado──▶ int-sascar

Passo final (watch encerrado) é obrigatório e temporal: o int-sascar alarma se não vier em ~6h (o callback 2xx só marca enviado; o mdfe_vigiado só fecha ao receber o encerrado).

Ingest do MDFE_PUT

Origem:PMDFE. O CNPJ/Razao_Social do emitente vêm da raiz do payload (não são colunas da mdf). O ingest é tipado por tabela (como o resto do XADM): cada tabela espelha por upsert de chave literal do ZIM (TRIM só na consulta). Tabelas espelhadas: mdf, mdfcompl, nfeevento, mdfitens, nfmdf, nfcomplmdf, fretes (municipio/propriedades reusadas). MOTORISTAS/ FRETESMOT não são espelhadas (fora de escopo).

Tabela ausente é tolerada: uma MDF-e sem NFEEVENTO(110112,'A') = aberta (situacao_baixa=ATIVO).

situacao_baixa (coluna própria do Integrador)

Na mdf, não vem do X-Adm. Valores:

valor significado
ATIVO vigiada; sem baixa solicitada
BAIXA encerramento solicitado (comando pendente) — cod_retorno=000
ERRO falha reportada pelo client

Só a transição ATIVO→BAIXA grava (idempotência do callback). O NFEEVENTO do espelho é fiel ao X-Adm e só leitura — o comando de baixa não o sobrecarrega. A confirmação do encerramento (NFEEVENTO 110112/A) é marcada em encerrado_em (não re-empurra o watch encerrado — idempotência).

O watch encerrado dispara na transição independente da situacao_baixa: o fluxo comum é baixar no X-Adm primeiro — a MDF-e é encerrada lá (fica ATIVO, sem passar pela macro Sascar) e o único efeito necessário é o int-sascar parar de vigiar a placa. Como o watch aberto vigia a placa de toda MDF-e aberta, todo encerramento (via Sascar ou direto no X-Adm) precisa avisá-lo a parar.

Tela /mdf (visualização)

A tela distingue dois eixos por MDF-e, em colunas próprias: Status — o documento no ERP, ENCERRADA quando encerrado_em está setado, ABERTA caso contrário (derivado, sem coluna nova) — e Situação baixa — o situacao_baixa (ATIVO/BAIXA/ERRO) do comando Sascar. Uma MDF-e encerrada direto no X-Adm (sem macro) aparece ENCERRADA + Situação baixa=ATIVO — antes lia só ATIVO e parecia aberta. Só apresentação (derivação inline na view JTE); o export XLSX segue SELECT * (leva encerrado_em cru, não o rótulo). Ver .ia/011.

cod_retorno (mesmo contrato do V18)

000 pendente (callback) · 006 executado (write-back do client) · 1XX/9XX erro. É o sinal que o fluxo ENCERRA_MDFE do integrador-client observa.

Watch de saída (Integrador → int-sascar)

POST {sascar.watch.url}/api/integrador/mdfe/watch, Bearer = inbound_token do cliente. Só dispara quando o PUT tocou MDF-e; não empurra MDF-e já encerrada.

Entrega garantida (desde .ia/012 — decisão 0016). O watch deixou de ser best-effort: agora é enfileirado no outbox (xadm-mensageria) — no caminho do ingest, dentro da transação do apply (rollback dropa a linha); no writeback powersync, pós-commit via o MdfeWatchListener. O RelayM2m da lib entrega (retry durável, PAUSADO + alarme no teto); o operador reenvia/cancela em /admin/mensageria. O corpo destilado (abaixo) é salvo como payload; o WatchEnviador chama o @Client (transporte provisório até o contrato 006). Idempotente (o receptor deduplica por chave_nota).

Corpo destilado (o int-sascar nunca vê JSON cru do ZIM):

campo origem
slug sascar.slug (slug canônico do cliente na central; o int-sascar dá 400 se divergir do inbound_token)
chave_nota mdf.chave_nota (trimada)
cnpj raiz do payload
filial mdf.vinc_filial
placa_cavalo mdfcompl.docto
finalidade mdfitens.finalidade do 1º item (M se os itens divergirem)
status aberto / encerrado
destino_cidade / destino_uf discriminante NFe×CTe (abaixo)

Destino por nfmdf.modelo: NFe (55) → nfcomplmdf.loc_entrega → propriedades.cod_mun → municipio; CTe (57) → fretes.mun_dest → municipio.

Callback de encerramento (int-sascar → Integrador)

POST /api/sascar/encerramento (path literal, não /api/v1/). Auth pelo ApiBearerSecurityRule (rota sob /api/**): o int-sascar envia o Bearer da casa — o outbound_token dele é o INTEGRADOR_API_TOKEN deste app (a casa tem um Bearer único de /api). Bearer inválido → 401.

Corpo: chave_nota, cnpj, filial, placa, data_pacote (ISO-8601 local sem offset), id_pacote, cidade, uf, motivo (macro|geo). Localiza a mdf por chave_nota (TRIM), grava situacao_baixa=BAIXA + cod_retorno=000 + proveniência (id_pacote/data_pacote/motivo).

Sempre 2xx (o int-sascar é dono do retry e trata não-2xx como transitório):

resultado quando
BAIXA comando gravado (transição ATIVO→BAIXA)
NOOP idempotente — já em BAIXA/ERRO (reenvio não regride)
NAO_ENCONTRADO chave não ingerida ainda (não 404 — 404 giraria o retry infinito)

O callback recebido é auditado como ENTRADA/RECEBIDO no outbox (registrarEntrada, .ia/012) — best-effort, não altera o sempre-2xx nem a idempotência de negócio; visível em /admin/mensageria.

Segredos e configuração (ENV)

Nunca versionados — só ENV no Coolify (constituição, Duas fontes da verdade):

ENV uso
SASCAR_WATCH_ENABLED liga o watch (off por default)
SASCAR_WATCH_URL base do int-sascar (ex. https://sascar.xadm.biz)
SASCAR_INBOUND_TOKEN Bearer que o Integrador envia no watch (segredo)
SASCAR_SLUG slug canônico do cliente (fallback CLIENTE)
INTEGRADOR_API_TOKEN Bearer que o int-sascar envia no callback (= Bearer da casa /api)

PowerSync do cliente (entrega do comando)

O comando de baixa chega ao integrador-client pelo canal PowerSync existente (bucket global, sem bucket novo). Recorte mínimo: só a mdf — ela carrega chave_nota, vinc_filial, situacao_baixa, cod_retorno, msg_retorno, tudo que o ENCERRA_MDFE lê para executar e reportar. O SchemaTabelas do client segue este recorte (o espelho define o client).

No config.yaml do cliente, sob sync_rules.global.data:

          - SELECT * FROM mdf WHERE deleted = false

Não sincronizar as demais tabelas MDF-e (contexto interno do espelho), MOTORISTAS/FRETESMOT, nem criar bucket novo. A publicação Postgres powersync é FOR ALL TABLES no caminho padrão (a mdf entra sozinha); se o cliente usa FOR TABLE ..., rodar ALTER PUBLICATION powersync ADD TABLE mdf;. O write-back do cod_retorno/msg_retorno sobe pelo POST /api/v1/powersync já existente (sem canal novo; client_auth inalterado). Ver atualizar-syncrules.

Cobertura de teste

Os 3 JSON reais (docs/anexos/privado/MDFE_PUT_2026072*.json) cobrem só o estado ATIVO (16433017 sem NFEEVENTO; 17053300/17294274 com NFEEVENTO(110112,'I')). O ramo encerrado/ reconciliação exige o fixture sintético MDFE_PUT_SINTETICO-encerrado.json (SitEvento I→A). Integração em Postgres real: MdfeEspelhoIngestIntegrationTest; destilação: MdfeWatchDistillerTest.