sh4.tech

Deep dive 8 min de leitura Conciliação · mídia digital out-of-home

Conciliar o que foi vendido com o que foi ao ar

O Comercial pediu uma tela de ocupação. Por baixo, três motores discordavam e os dois sistemas só compartilhavam id em parte dos casos. Como refiz o motor, casei pedidos com programação e validei por máquina-dia e contra o financeiro.

Quando aconteceu
Publicado
Medido
, produção, comparação só leitura entre motor e origem
Stack
PHP (CodeIgniter 4), MySQL, ClickHouse, Apache NiFi, ExcelJS no navegador
Também em
English

O pedido

A rede vende tempo de anúncio em cerca de 15.400 monitores15.444 monitores nas faixas de ocupação, relatório de 2026-10-01 em condomínios e shoppings. O Comercial fecha campanhas num sistema: o pedido, o valor e quanto disso é pago, bonificado ou permuta. A operação programa as campanhas em outro: qual conteúdo passa em qual tela, em quais dias. São dois mundos com dois ids diferentes.

A tela de ocupação que o Comercial usava foi uma das primeiras que a gente criou, anos atrás, e nunca tinha sido atualizada. Mostrava ocupação por publicação, sem o número do pedido de venda, sem valor, e casar uma publicação com a sua venda era trabalho manual. O pedido formal tinha seis itens: id do pedido, valor, a composição pago / bonificado / permuta, exportação para Excel, casamento automático entre publicação e pedido, e as datas de veiculação. Quando mostrei o primeiro wireframe, a gerente comercial disse que esperava aquela tela havia três anos.

Primeiro o número de baixo

Para responder "quanto desta rede está vendido" você precisa da ocupação por tela e por dia. A gente tinha três motores calculando isso: um por ambiente, e um terceiro no ambiente de shoppings que era uma procedure que só existia dentro do banco, fora do controle de versão. Eles não concordavam entre si. Uma tela nova em cima disso seria uma vista bonita de um número em que ninguém confia, então os primeiros dias foram para o motor, e a tela veio depois.

Um motor só para os dois ambientes, com as mesmas regras. Duas definições eu precisei combinar com o negócio, porque o código não decide isso: ocupação é o que foi vendido, com ou sem o material já no ar; e bonificado não ocupa, ele preenche o que sobra.

Depois, a validação. Comparei o motor novo com o sistema de origem para cada máquina em cada dia de uma janela de 30 dias:

AmbienteMáquinas-diaDiferenças
Condomínios429.5400
Shoppings31.15118
Total460.69118

Janela de 2026-09-28 a 2026-10-28, comparada em 2026-09-28. As 18 diferenças caem todas em três dias no fim da janela, e eu ainda não fechei a causa delas.

Comparo por máquina e por dia de propósito. Total esconde erro que se compensa: uma tela contada duas vezes e outra faltando dão a soma certa e duas telas erradas. O formato da consulta, com nomes genéricos:

motor vs origem, por máquina-dia
SELECT
    count()                                                      AS maquinas_dia,
    countIf(isNull(e.slots) OR isNull(s.slots) OR e.slots != s.slots) AS diferentes
FROM engine_occupancy AS e
FULL OUTER JOIN source_occupancy AS s USING (machine_id, day)
WHERE day >= '2026-09-28' AND day <= '2026-10-28'
SETTINGS join_use_nulls = 1;

O join_use_nulls importa aqui. Sem ele, o ClickHouse preenche o lado que falta com zero, e uma máquina que só existe de um lado parece uma máquina com ocupação zero, o que pode bater por acidente.

Hoje o motor tem 5.392.734 máquinas-dia em 20265.292.528 em condomínios, 100.871 em shoppings; consulta só leitura, 2026-10-12, e 1.234.304 delas estão no futuro: programação já vendida e ainda não exibida. A tabela vai até abril de 2027.

Casar venda com programação quando o id só às vezes é o mesmo

Existe uma tabela de integração que liga um item de venda às suas publicações. Quando ela existe, o casamento está feito. Ela cobria 82% do que vinha do sistema de vendas. Para o resto, o vínculo morava num campo de texto livre da publicação, onde as pessoas digitam o pedido: algo como ID VENDA: 1234 - Home - 5678.

Ler id de dentro de texto é perigoso, porque gente digita errado. Então o texto só vale quando passa por uma dupla checagem: o item citado precisa existir no sistema de vendas, e precisa pertencer ao pedido citado. Se uma das duas falha, a publicação fica sem casamento e aparece como alerta.

casamento pelo texto, simplificado
private const RE_ITEM = '/ID\s*VENDA\s*:\s*(\d+)\s*-\s*(?:\w+\s*-\s*)?(\d+)/i';

foreach ($publications as $p) {
    $key = $p['env'] . '|' . $p['pub_id'];
    if (isset($linked[$key])) {
        continue;                                    // a integração já casou
    }
    if (!preg_match(self::RE_ITEM, (string) $p['notes'], $m)) {
        continue;
    }
    $item = $items[$p['env'] . '|' . $m[2]] ?? null; // o item tem que existir em vendas
    if ($item && (int) $item['order_id'] === (int) $m[1]) {   // e ser daquele pedido
        $linked[$key] = $item + ['via' => 'texto'];
    }
}

Cada casamento leva um campo via até a tela, então qualquer pessoa vê se o pedido foi casado pela integração ou pelo texto, e isso muda o quanto você confia nele.

Com os dois caminhos, 100% do que veio do sistema de vendas ficou vinculado. Os valores seguem a mesma regra do pipeline financeiro, rateados entre os itens do pedido com uma window function no SQL. Conferi contra o financeiro antes de alguém usar: o valor líquido bateu em 1.277 de 1.277 itens nos shoppings e em 382 de 382 pedidos nos condomínios. No caminho, 291 pedidos que apareciam como "cliente não identificado" voltaram a ter nome, porque a cópia da tabela de anunciantes estava atrasada.

Um retrato, não uma consulta

Nada na tela consulta os bancos ao vivo. A cada 15 minutos, um comando agendado lê as views uma vez, cruza tudo em PHP e grava um retrato JSON compactado em formato posicional, para a rede inteira caber em cerca de 1 MB. O navegador baixa o retrato e calcula o período escolhido, inclusive o Excel, com o ExcelJS. Nas primeiras rodadas o retrato tinha 552 KB com o mês anterior incluído e levava 8,8 segundos para montar. Antes da mudança, só repetir a view de serviços em cada consulta custava 90 segundos.

Dois detalhes deixaram seguro rodar isso a cada 15 minutos:

gravação do retrato
private const MIN_PUBLICATIONS = 500;   // menos que isso = fonte vazia, mantém o anterior

private static function write(string $file, string $json): void
{
    $tmp = $file . '.tmp';
    file_put_contents($tmp, gzencode($json, 6));
    rename($tmp, $file);                // atômico: quem lê pega o arquivo velho ou o novo
}

Como o rename é atômico, quem estiver baixando no meio de uma rodada recebe o retrato anterior inteiro, nunca um pedaço. A trava do mínimo é para o dia em que uma fonte vier vazia: o comando se recusa a trocar um retrato bom por um quase vazio. O servidor responde com um ETag feito da data e do tamanho do arquivo, e o navegador recebe 304 quando nada mudou:

ETag
$etag = '"' . filemtime($file) . '-' . filesize($file) . '"';
$this->response->setHeader('Cache-Control', 'private, no-cache')->setHeader('ETag', $etag);
if (trim($this->request->getHeaderLine('If-None-Match')) === $etag) {
    return $this->response->setStatusCode(304);
}

Esse desenho foi testado já no primeiro dia em produção. Quando acrescentei o mês anterior, o retrato dobrou e o comando estourou o limite de 128 MB de memória do PHP CLI. A montagem falhou, o arquivo antigo ficou, e a tela continuou servindo ele enquanto eu subia o limite, no mesmo dia. Depois os meses passados viraram um arquivo cada, perto de 0,8 MB, carregado só quando alguém navega até ele.

As divergências são o produto

Quando você põe vendido e programado lado a lado, as diferenças saltam, e cada uma é uma decisão para alguém. A aba de alertas tem cinco tipos:

  • cancelado em vendas e ainda no ar;
  • datas em vendas diferentes das datas no ar;
  • bonificado em vendas contado como vendido;
  • item de venda não encontrado (vínculo só pelo texto);
  • desconto mais bonificação acima da tabela de preço.

E pedido vendido sem nenhuma publicação aparece na visão por pedido, que é o clássico "vendido e não foi ao ar".

Uma visão a mais nasceu de uma conversa depois do lançamento. Muitos contratos com shoppings são de divisão de receita com mínimo garantido mensal e sem carry-over: um mês ótimo não cobre um mês fraco. Por shopping e por mês, a tela mostra onde o mínimo não foi atingido e onde há inventário ocioso, para a parte flexível da verba ir para onde rende.

Onde os agentes ajudaram, e onde não decidiram

Usei muito o Claude Code nisso, e a divisão foi clara. A investigação do motor rodou como quatro frentes paralelas, só leitura (modelo-alvo, eficiência, quem mais lê o banco e o diagnóstico de shoppings). Cinco conceitos de interface foram gerados em paralelo; rejeitei as duas primeiras versões e terminei num híbrido de dois conceitos. Um subagente revisou o PR como se fosse de outra pessoa e achou quatro pontos importantes, nenhum bloqueante. O wireframe rodou com dados reais e foi testado com simulações de clique de 33 e depois 45 passos.

As decisões ficaram comigo, e foram elas que definiram o produto: colocar dentro do sistema de vendas, com tela própria; levar o dado pelo NiFi; as mesmas regras para shopping e condomínio; ocupação igual a vendido; bonificado não ocupa; o pedido de venda como visão principal; franqueados de fora, porque a tela mostra valor de contrato da rede inteira.

O que aconteceu depois

Em 27/09/2026 o motor unificado entrou em produção. O primeiro commit da tela foi em 30/09 às 15h55, e às 22h49 do mesmo dia ela estava no ar: a parte difícil estava embaixo, já pronta. Foram onze commits até o dia seguinte e mais 25 entre 2 e 5 de outubro, todos meus, muitos deles pedidos feitos pelos usuários em reunião, alguns mergeados com a reunião ainda acontecendo.

Antes de tudo isso, o diretor comercial tinha oferecido o próprio orçamento para contratar uma equipe de fora. Na revisão, ele disse que aquilo era mais que um sprint: "a Ferrari tá parada aqui na garagem". Quatro dias depois, tendo trabalhado com a tela no fim de semana, ele disse ao time: "Isso aqui tem que ser usado todo dia, porque se a gente não colocar ritmo, a gente não fecha esse trimestre."

O que eu reusaria em qualquer lugar

  • Conserte o número antes da tela. O pedido descreve o sintoma.
  • Valide por unidade (máquina-dia, pedido, item) contra a origem. Total esconde erro que se anula.
  • Case pelo id primeiro e pelo texto depois, com dupla checagem, e mostre ao usuário por qual caminho cada casamento veio.
  • Pré-calcule um retrato, grave de forma atômica, trave contra fonte vazia e sirva com ETag.
  • Mostre as divergências. Uma conciliação que só mostra o que bateu não diz a ninguém o que corrigir.
Se for levar uma coisa sóAntes de montar um relatório em cima de um número, compare esse número com a origem, unidade por unidade. Aqui foram 460.691 máquinas-dia e 18 diferenças, e foi essa checagem que fez as pessoas confiarem na tela.

Arquivado pelo problema que resolve

Luciano Serra
Luciano Serra Founder-engineer · cofundador e CTO, construiu do zero uma rede de 15.000 telas, 2015–2024 · São Paulo, remoto

Desenho sistemas de produção do zero e assumo os que já estão rodando. Nos dois casos eles precisam aguentar quando o negócio cresce. Cofundei uma empresa de mídia out-of-home e construí a tecnologia dela por dez anos; meus sócios captaram o dinheiro e cuidaram do comercial.

Seu negócio está crescendo mais rápido que os sistemas? Vamos conversar →