{"openapi":"3.1.0","info":{"title":"balcaria — API de integração de catálogo","version":"1.0.0","summary":"Como um sistema de retaguarda (ERP/PDV) publica catálogo e preços numa loja da balcaria.","description":"Esta API recebe o **retrato completo** do seu catálogo e o publica na vitrine e no balcão de uma loja.\n\n**O sentido é de entrada.** Hoje ela recebe catálogo, estoque e preços. Ela **não** entrega pedidos de\nvolta para o seu sistema — não existe rota de leitura de pedido. **Isso é escopo decidido, não\nesquecimento.** O atendente lança o pedido manualmente na retaguarda ao faturar, e é assim que o\nlojista-piloto opera hoje. Se algum dia fizer sentido automatizar isso, será um trabalho próprio,\ndesenhado com quem for consumir — não um endereço a mais nesta lista.\n\n**O protocolo tem três passos, e a ordem é o contrato:**\n\n1. `POST /erp/sincronizacao` — abre, declarando `totalEsperado`.\n2. `POST /erp/sincronizacao/{id}/lote` — quantas vezes precisar.\n3. `POST /erp/sincronizacao/{id}/concluir` — fecha.\n\n**Só o passo 3 arquiva alguma coisa, e só quando a contagem bate exatamente.** Um retrato que abriu e\nnunca fechou expira sozinho e não arquiva nada. É essa trava que impede uma queda de rede no meio do\nenvio de esvaziar o catálogo de uma loja inteira — e é a razão de existir um passo de abertura em vez de\num único POST com o catálogo todo.\n\n**As linhas vão cruas.** Não normalize caixa, acento nem sentinela; o servidor faz isso. Assim uma regra\nde normalização nova é um deploy nosso, e não uma atualização do seu programa em cada loja.","contact":{"name":"balcaria"}},"servers":[{"url":"https://{loja}.{dominio}/api","description":"Cada loja tem o próprio host, e a credencial vale SÓ nele. O host é a autoridade de tenant: um token válido apresentado no host de outra loja é recusado com 401.","variables":{"loja":{"default":"sualoja","description":"O subdomínio da loja, definido no cadastro dela."},"dominio":{"default":"balcaria.com.br","description":"O domínio base da plataforma."}}}],"tags":[{"name":"Sincronização de catálogo","description":"O protocolo de três passos. A ordem não é cerimônia: sem um total declarado na abertura não há como distinguir um catálogo que encolheu de um agente que caiu no meio."}],"security":[{"tokenDaConexao":[]}],"paths":{"/erp/sincronizacao":{"post":{"tags":["Sincronização de catálogo"],"operationId":"abrirSincronizacao","summary":"Passo 1 — abre um retrato, declarando quantos produtos virão","description":"`totalEsperado` é o número de produtos que você vai enviar neste retrato, e `concluir` vai cobrá-lo por IGUALDADE. Um retrato anterior ainda aberto é marcado como expirado aqui, não recusado: um agente que reiniciou depois de uma queda recomeça sozinho, sem intervenção humana.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CorpoDeAbertura"},"example":{"totalEsperado":1712,"agenteVersao":"0.1.0"}}}},"responses":{"201":{"description":"Retrato aberto.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AberturaOk"},"example":{"sincronizacaoId":"9f1c2b7a-4d3e-4f61-9a8b-0c1d2e3f4a5b"}}}},"400":{"description":"Duas famílias, e o que fazer com elas é OPOSTO. (a) **Corpo inválido** — `message` diz qual campo e por quê; repetir não resolve, corrija o programa. (b) **Retrato encerrado ou expirado** — `\"Esta sincronização expirou. Abra uma nova.\"` ou `\"Esta sincronização já foi encerrada (...)\"`; aí repetir a MESMA chamada nunca vai funcionar, mas abrir um retrato novo e refazer o ciclo sim. Um `sincronizacaoId` que não é UUID também cai aqui, antes do controller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Erro"},"example":{"statusCode":400,"error":"Bad Request","message":"`totalEsperado` precisa ser um número inteiro não negativo."}}}},"401":{"$ref":"#/components/responses/NaoAutorizado"},"403":{"$ref":"#/components/responses/Proibido"},"429":{"$ref":"#/components/responses/Excesso"}}}},"/erp/sincronizacao/{sincronizacaoId}/lote":{"post":{"tags":["Sincronização de catálogo"],"operationId":"enviarLote","summary":"Passo 2 — empurra um pedaço do retrato","description":"As linhas vão **cruas**, como o `SELECT` na view as devolveu: sem normalizar caixa, acento, sentinela nem tipo. Toda a normalização roda no servidor da balcaria — é isso que faz corrigir uma regra ser um deploy nosso em vez de uma reinstalação na sua loja.\n\nTeto de **1000 linhas por campo, por requisição**. Repita a chamada quantas vezes precisar; reenviar o mesmo lote não duplica **dado** — produto e preço são regravados no lugar, e `produtosRecebidos` é recontado do banco. Mas `precosRecebidos` e `precosRecusados` são ACUMULADORES: reenviar soma de novo neles, então não os use para reconciliar.\n\n**`recusas` numa resposta de SUCESSO (201) não é falha.** Linhas que o servidor não conseguiu aproveitar voltam nomeadas, com motivo legível, e as demais entraram. Tratar isso como erro e reenviar o retrato em laço é o engano mais provável de quem integra sem ler esta linha.","parameters":[{"name":"sincronizacaoId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"O `sincronizacaoId` devolvido pela abertura."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CorpoDeLote"},"examples":{"loteAceito":{"summary":"Duas linhas aproveitáveis","value":{"produtos":[{"id_produto":"10432","descricao":"QUEIJO MUSSARELA PECA","observacao":"Peça inteira, maturação de 45 dias.","categoria":"FRIOS E LATICINIOS","marca":"TIROLEZ","unidade":"KG","estoque":37},{"id_produto":"10433","descricao":"REFRIGERANTE COLA 2L","observacao":null,"categoria":"BEBIDAS","marca":"Se Marca","unidade":"UN","estoque":240}],"precos":[{"id_produto":"10432","tabela":"A - VAREJO","preco":"44.90","unidade":"KG"},{"id_produto":"10432","tabela":"Ataca/cartão","preco":"41.90","unidade":"KG"},{"id_produto":"10432","tabela":"Ataca/pix","preco":"39.90","unidade":"KG"},{"id_produto":"10432","tabela":"Ataca/dinheiro","preco":"39.50","unidade":"KG"},{"id_produto":"10433","tabela":"A - VAREJO","preco":"8.99","unidade":"UN"}]}},"loteComRecusas":{"summary":"Linhas que voltam recusadas, sem derrubar o lote","value":{"produtos":[{"id_produto":null,"descricao":"LINHA ORFA","categoria":null,"marca":null,"unidade":"UN","estoque":0},{"id_produto":"10999","descricao":null,"categoria":"MERCEARIA","marca":null,"unidade":"UN","estoque":5}],"precos":[]}}}}}},"responses":{"201":{"description":"Lote processado. Veja `recusas`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResultadoDoLote"},"example":{"sincronizacaoId":"9f1c2b7a-4d3e-4f61-9a8b-0c1d2e3f4a5b","produtosNoLote":2,"produtosRecebidos":2,"recusas":[]}}}},"400":{"description":"Duas famílias, e o que fazer com elas é OPOSTO. (a) **Corpo inválido** — `message` diz qual campo e por quê; repetir não resolve, corrija o programa. (b) **Retrato encerrado ou expirado** — `\"Esta sincronização expirou. Abra uma nova.\"` ou `\"Esta sincronização já foi encerrada (...)\"`; aí repetir a MESMA chamada nunca vai funcionar, mas abrir um retrato novo e refazer o ciclo sim. Um `sincronizacaoId` que não é UUID também cai aqui, antes do controller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Erro"},"example":{"statusCode":400,"error":"Bad Request","message":"`produtos` passou do limite de 1000 linhas por lote."}}}},"401":{"$ref":"#/components/responses/NaoAutorizado"},"403":{"$ref":"#/components/responses/Proibido"},"404":{"$ref":"#/components/responses/NaoEncontrado"},"429":{"$ref":"#/components/responses/Excesso"}}}},"/erp/sincronizacao/{sincronizacaoId}/concluir":{"post":{"tags":["Sincronização de catálogo"],"operationId":"concluirSincronizacao","summary":"Passo 3 — fecha o retrato. É o ÚNICO ponto que arquiva produto","description":"O servidor reconta do banco quantos produtos carregam o carimbo deste retrato e compara com o `totalEsperado` declarado na abertura. **A comparação é IGUALDADE, não `>=`** — receber mais do que se anunciou também é um retrato que não bate.\n\nBatendo: os produtos deste vendor que não foram carimbados neste retrato passam a `arquivado` — é assim que um item retirado do seu sistema some da vitrine.\n\nNão batendo: a resposta ainda é de SUCESSO (201), com `status: \"falhou\"` e o motivo carregando os dois números, e **nada é arquivado**. Essa é a diferença entre arquivar o que sumiu e esvaziar a loja de um lojista por causa de uma queda de rede.","parameters":[{"name":"sincronizacaoId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"O `sincronizacaoId` devolvido pela abertura."}],"responses":{"201":{"description":"Retrato encerrado. Confira `status`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResumoDaSincronizacao"},"examples":{"concluida":{"summary":"Os números bateram; 6 produtos foram arquivados","value":{"sincronizacaoId":"9f1c2b7a-4d3e-4f61-9a8b-0c1d2e3f4a5b","status":"concluida","totalEsperado":1712,"produtosRecebidos":1712,"produtosArquivados":6,"precosRecebidos":6712,"precosRecusados":0,"motivo":null}},"incompleta":{"summary":"Os números não bateram; NADA foi arquivado","value":{"sincronizacaoId":"9f1c2b7a-4d3e-4f61-9a8b-0c1d2e3f4a5b","status":"falhou","totalEsperado":1712,"produtosRecebidos":1690,"produtosArquivados":0,"precosRecebidos":6600,"precosRecusados":0,"motivo":"O retrato veio incompleto: o agente anunciou 1712 produtos e enviou 1690. Nenhum produto foi arquivado."}}}}}},"400":{"description":"Duas famílias, e o que fazer com elas é OPOSTO. (a) **Corpo inválido** — `message` diz qual campo e por quê; repetir não resolve, corrija o programa. (b) **Retrato encerrado ou expirado** — `\"Esta sincronização expirou. Abra uma nova.\"` ou `\"Esta sincronização já foi encerrada (...)\"`; aí repetir a MESMA chamada nunca vai funcionar, mas abrir um retrato novo e refazer o ciclo sim. Um `sincronizacaoId` que não é UUID também cai aqui, antes do controller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Erro"},"example":{"statusCode":400,"error":"Bad Request","message":"Esta sincronização já foi encerrada (concluida)."}}}},"401":{"$ref":"#/components/responses/NaoAutorizado"},"403":{"$ref":"#/components/responses/Proibido"},"404":{"$ref":"#/components/responses/NaoEncontrado"},"429":{"$ref":"#/components/responses/Excesso"}}}},"/erp/sincronizacao/{sincronizacaoId}/falhar":{"post":{"tags":["Sincronização de catálogo"],"operationId":"falharSincronizacao","summary":"Você desistiu deste retrato — avise, em vez de sumir","description":"Um retrato abandonado expira sozinho em 6 horas e também não arquiva nada. Avisar mesmo assim vale porque a tela do lojista distingue *o agente disse que deu errado* de *o agente sumiu no meio* — e as duas frases mandam o lojista fazer coisas diferentes.","parameters":[{"name":"sincronizacaoId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"O `sincronizacaoId` devolvido pela abertura."}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CorpoDeFalha"},"example":{"motivo":"Falha ao ler a view: conexão com o Firebird recusada."}}}},"responses":{"201":{"description":"Retrato marcado como falho. Nada foi arquivado.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResumoDaSincronizacao"}}}},"400":{"description":"Duas famílias, e o que fazer com elas é OPOSTO. (a) **Corpo inválido** — `message` diz qual campo e por quê; repetir não resolve, corrija o programa. (b) **Retrato encerrado ou expirado** — `\"Esta sincronização expirou. Abra uma nova.\"` ou `\"Esta sincronização já foi encerrada (...)\"`; aí repetir a MESMA chamada nunca vai funcionar, mas abrir um retrato novo e refazer o ciclo sim. Um `sincronizacaoId` que não é UUID também cai aqui, antes do controller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Erro"},"example":{"statusCode":400,"error":"Bad Request","message":"Esta sincronização expirou. Abra uma nova."}}}},"401":{"$ref":"#/components/responses/NaoAutorizado"},"403":{"$ref":"#/components/responses/Proibido"},"404":{"$ref":"#/components/responses/NaoEncontrado"},"429":{"$ref":"#/components/responses/Excesso"}}}}},"components":{"securitySchemes":{"tokenDaConexao":{"type":"http","scheme":"bearer","description":"`Authorization: Bearer <connectionId>.<segredo>`. O token é emitido pelo Admin da loja, na tela de Integrações do painel, e aparece **uma única vez** — do nosso lado fica só um hash SHA-256, então não há como recuperá-lo depois; perdeu, emite outro. Ele não é um usuário: não tem papel, não troca senha, e é revogável sozinho, sem mexer em mais nada."}},"schemas":{"CorpoDeAbertura":{"type":"object","required":["totalEsperado"],"properties":{"totalEsperado":{"type":"integer","minimum":0,"description":"Quantos produtos este retrato vai enviar. Inteiro — um `COUNT(*)` que passou por cálculo de ponto flutuante (`3.5`) ou virou texto (`\"1712\"`) é recusado com 400."},"agenteVersao":{"type":["string","null"],"maxLength":40,"description":"A versão do seu agente. Aparece na tela do lojista; ajuda a responder 'por que parou?'."}}},"AberturaOk":{"type":"object","required":["sincronizacaoId"],"properties":{"sincronizacaoId":{"type":"string","format":"uuid"}}},"CorpoDeLote":{"type":"object","description":"Os dois campos são opcionais, mas **NÃO são independentes**: uma linha de preço só é gravada se a linha de produto com o mesmo `id_produto` vier na MESMA requisição. Um lote só de preços é aceito com 201 e não grava nada — sem recusa e sem contagem. Pelo mesmo motivo, um produto recusado leva junto as linhas de preço dele, e a recusa reportada nomeia só o produto. Monte cada lote com o produto e os preços dele juntos.","properties":{"produtos":{"type":"array","maxItems":1000,"items":{"$ref":"#/components/schemas/LinhaDeProduto"}},"precos":{"type":"array","maxItems":1000,"items":{"$ref":"#/components/schemas/LinhaDePreco"}}}},"LinhaDeProduto":{"type":"object","description":"Uma linha crua da view de produtos. Sem `id_produto` ou sem `descricao` a linha é recusada com motivo — nunca vira um produto pela metade.","properties":{"id_produto":{"type":"string","description":"O identificador do SEU sistema, e é ele que casa o produto entre um retrato e o seguinte. **Não use código de barras**: um produto pode ter vários, e casar por código criaria produtos-fantasma a cada sincronização."},"descricao":{"type":"string","description":"Vira o **nome** do produto na vitrine. Linha sem ela é recusada."},"observacao":{"type":["string","null"],"description":"Vira a **descrição** do produto. Sem ela o produto entra na vitrine sem texto descritivo — o que é aceitável, mas raramente é o que o lojista quer."},"categoria":{"type":["string","null"],"description":"Texto livre. A categoria é criada se ainda não existir; a hierarquia do menu é nossa."},"marca":{"type":["string","null"],"description":"Sentinelas do vendor (ex.: `Se Marca`) viram ausência."},"unidade":{"type":"string","description":"**LIDA E DESCARTADA — a unidade que vale é a da linha de PREÇO.** Não existe unidade no cadastro de produto do nosso lado; ela é atributo do preço. Declarar `KG` aqui e omitir nas linhas de preço grava preço por quilo como preço por `UN`, sem erro nenhum. Mandar mesmo assim é inofensivo; confiar nela não é."},"estoque":{"type":"number","description":"Saldo. Negativo vira zero, e **fracionário é guardado com até 3 casas** (2,7 fica 2,7; 0,4 fica 0,4 — o item a granel continua disponível); da 4ª casa em diante, arredonda. Valor ilegível ou ausente vira zero, em silêncio. Aceita número ou texto numérico, com ponto ou vírgula."}}},"LinhaDePreco":{"type":"object","description":"Uma linha da tabela-filha de preços — uma por (produto × tabela). O nome da tabela é traduzido para faixa e forma de pagamento; **nome desconhecido é RECUSADO com motivo, nunca adivinhado**, porque adivinhar erra para o lado de dar desconto sem dar erro. Atenção: **nome de tabela desconhecido é a ÚNICA recusa de preço que volta reportada** — linha com `preco` fora do formato, sem `tabela` ou sem `id_produto` é descartada em silêncio.","properties":{"id_produto":{"type":"string"},"tabela":{"type":"string","examples":["A - VAREJO","Ataca/cartão","Ataca/pix","Ataca/dinheiro"]},"preco":{"type":"string","description":"Decimal em TEXTO — nenhum ponto flutuante binário no caminho. Formato: até 8 dígitos inteiros e **no máximo 2 casas decimais**, maior que zero (a vírgula decimal é aceita). **Fora disso a linha é DESCARTADA EM SILÊNCIO** — não aparece em `recusas[]` nem em `precosRecusados`. Um preço com três casas some sem deixar rastro; confira do seu lado antes de enviar."},"unidade":{"type":"string","description":"**É esta a unidade que persiste**, não a da linha de produto. Ausente, vira `UN` — o que para um item vendido por quilo troca o significado do preço sem dar erro. Sempre envie."}}},"CorpoDeFalha":{"type":"object","properties":{"motivo":{"type":"string","maxLength":500}}},"ResultadoDoLote":{"type":"object","properties":{"sincronizacaoId":{"type":"string","format":"uuid"},"produtosNoLote":{"type":"integer","description":"Linhas aproveitadas NESTA requisição."},"produtosRecebidos":{"type":"integer","description":"Acumulado do retrato inteiro, recontado do banco."},"recusas":{"type":"array","items":{"type":"object","properties":{"erpId":{"type":["string","null"]},"motivo":{"type":"string"}}}}}},"ResumoDaSincronizacao":{"type":"object","properties":{"sincronizacaoId":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["em_andamento","concluida","falhou","expirada"]},"totalEsperado":{"type":"integer"},"produtosRecebidos":{"type":"integer"},"produtosArquivados":{"type":"integer","description":"Só é diferente de zero num `concluida`."},"precosRecebidos":{"type":"integer"},"precosRecusados":{"type":"integer"},"motivo":{"type":["string","null"]}}},"Erro":{"type":"object","properties":{"statusCode":{"type":"integer"},"error":{"type":"string"},"message":{"type":"string"}}}},"responses":{"NaoAutorizado":{"description":"Token ausente, malformado, desconhecido, ou apresentado no host de OUTRA loja. Trate como 'preciso de um token novo' — e é por isso que ele não se confunde com o 403. **As quatro causas são indistinguíveis pelo corpo**, de propósito: a resposta é o 401 padrão, sem código próprio. Distinguir 'token errado' de 'token de outra loja' diria a quem varre qual das duas coisas acertar em seguida.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Erro"}}}},"Proibido":{"description":"Seu token está bom; a loja é que não aceita a chamada agora. Quatro causas: integração desativada pelo lojista, plano sem integração de ERP, conexão fora do modo de conector local — e **a loja estar suspensa**, que vem do middleware antes do controller. **Reemitir o token não resolve nenhuma das quatro.** As três primeiras o lojista resolve no painel dele; a suspensão, não — essa é entre a loja e a plataforma.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Erro"}}}},"NaoEncontrado":{"description":"Não existe retrato com esse id **para esta conexão** — um id de outra conexão da mesma loja também dá 404. E há um 404 que vem antes do controller, com corpo diferente (`\"Loja nao encontrada.\"`): o host chamado não corresponde a loja nenhuma. Se você receber esse, o problema é o endereço, não o retrato.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Erro"}}}},"Excesso":{"description":"Teto de uso: ~60 requisições por minuto por conexão. A resposta traz o cabeçalho `Retry-After` em segundos, e o mesmo número em `retryAfter` no corpo. **Espere o que ele diz** em vez de repetir — repetir só empurra a janela para a frente.","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Segundos até a janela liberar."}},"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/Erro"},{"type":"object","properties":{"retryAfter":{"type":"integer"}}}]},"example":{"statusCode":429,"error":"Too Many Requests","message":"Tentativas demais. Tente de novo em 37 segundos.","retryAfter":37}}}}}}}