Reaproveitar código de produto quebra o estoque: a regra de ouro dos identificadores imutáveis
Introdução
Tem um tipo de bug que não dá stack trace, não estoura exceção e não acende alarme nenhum. Ele só aparece semanas depois, quando alguém olha um relatório de estoque, coça a cabeça e pergunta: “por que o sistema acha que temos 300 unidades de um energético que a gente nem vende mais?”.
Esse artigo nasceu de um caso real exatamente assim. Num sistema de PDV multiloja, o estoque começou a divergir depois de uma atualização. Investigando, a causa raiz não era um bug de código — era de modelagem: um operador reaproveitou o código de um produto antigo para cadastrar um produto novo. O sistema fez o que mandaram. E o estoque virou ficção.
A lição aqui é uma das mais subestimadas do backend: identificador é para a vida toda. Reaproveitar um é abrir um portal pro caos. Bora entender por quê.
Contexto: o código de produto não é só um rótulo
Num sistema multiloja, você tem um catálogo central que sincroniza com várias lojas. Cada produto tem um código — o famoso SKU. E na cabeça de quem usa o sistema, esse código é só um nome bonitinho pra achar o item na busca.
Só que, pro backend, esse código quase sempre vira chave. É por ele que o sistema:
- casa o produto entre o catálogo central e cada loja;
- amarra o histórico de movimentações de estoque (entradas, saídas, vendas);
- reconcilia inventário durante uma importação ou atualização.
Ou seja: o código não identifica “um nome”. Ele identifica uma coisa no mundo, com um histórico inteiro pendurado nele. E aí mora o perigo.
O problema real: a colisão de identidade
Imagina a sequência. O produto ENER-001 era um energético que saiu de linha. Ficou lá, parado, com seu histórico de movimentações. Um dia, alguém precisa cadastrar um energético novo e pensa: “ah, o ENER-001 está sobrando, vou reusar”.
Do ponto de vista humano, faz todo sentido. Do ponto de vista do banco de dados, dois produtos diferentes agora têm a mesma identidade. O histórico do energético velho — todas as entradas e saídas — continua amarrado naquele código. Quando a sincronização multiloja roda, ela faz o que sempre fez: pega o ENER-001, junta o histórico que existe e calcula o saldo.
Resultado: o estoque do produto novo herda o passado do produto morto. Não é “bug de importação”. É uma colisão de identidade — duas entidades distintas brigando pela mesma chave. E o pior: ninguém percebe na hora, porque o sistema não tem como saber que o ENER-001 de ontem e o de hoje são coisas diferentes. Pra ele, é o mesmo cara.
Como o código costuma piorar a situação
O vilão silencioso quase sempre é o upsert ingênuo na importação:
-- "Se já existe o código, atualiza; senão, insere."
INSERT INTO produtos (codigo, nome, categoria)
VALUES ('ENER-001', 'Energético Novo 473ml', 'Bebidas')
ON DUPLICATE KEY UPDATE
nome = VALUES(nome),
categoria = VALUES(categoria);
Esse ON DUPLICATE KEY UPDATE parece esperto e econômico. Mas ele tem uma suposição embutida e perigosa: “mesmo código = mesmo produto”. Quando essa suposição quebra (e uma hora ela quebra), o banco silenciosamente funde duas entidades diferentes — mantendo o histórico antigo colado no registro novo. Sem erro. Sem aviso. Só o estoque mentindo mais tarde.
A solução: separar identidade de rótulo
A correção de verdade não é “proibir o usuário de reusar código” no treinamento (boa sorte com isso). É mudar a modelagem pra que o sistema não dependa de um campo mutável e digitado por humano como identidade.
1. Identidade interna imutável (surrogate key)
A identidade real do produto deve ser um ID interno que ninguém edita e que nunca se repete — um UUID ou um auto-incremento. O código de negócio (SKU) vira só um atributo, não a chave de tudo.
Schema::create('produtos', function (Blueprint $table) {
$table->uuid('id')->primary(); // identidade real, imutável, do sistema
$table->string('codigo')->unique(); // SKU: rótulo de negócio, único, mas NÃO é a identidade
$table->string('nome');
$table->string('categoria');
$table->timestamps();
$table->softDeletes(); // produto "morto" não some — fica inativo
});
Com isso, o histórico de estoque aponta pro id (imutável), não pro codigo. Se o SKU for reusado, ele no máximo gera um conflito de unique — não funde dois produtos.
2. Tratar reúso de código como erro explícito, não como update
Na importação, em vez de fundir cegamente, valide a intenção:
public function importarProduto(array $linha): void
{
$existente = Produto::where('codigo', $linha['codigo'])->first();
if ($existente && $this->pareceProdutoDiferente($existente, $linha)) {
// Mesmo código, mas é claramente outro produto (nome/categoria mudaram).
// Falhe alto em vez de corromper o estoque silenciosamente.
throw new ColisaoDeCodigoException(
"Código {$linha['codigo']} já pertence a '{$existente->nome}'. " .
"Cadastre um código novo para o produto."
);
}
// ... segue o fluxo normal de criar/atualizar
}
Sim, isso “incomoda” o operador. Mas incomodar na hora do cadastro é infinitamente mais barato do que descobrir o problema num balanço de estoque três semanas depois.
3. Estoque como livro-razão (ledger), não como número solto
Esse é o pulo do gato que salva qualquer operação de estoque. Em vez de guardar um campo saldo que você fica somando e subtraindo (e rezando pra não dessincronizar entre lojas), guarde as movimentações num histórico append-only e calcule o saldo a partir delas:
Schema::create('movimentacoes_estoque', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('produto_id'); // aponta pra identidade imutável
$table->uuid('loja_id');
$table->integer('quantidade'); // positivo = entrada, negativo = saída
$table->string('origem'); // 'venda', 'importacao', 'ajuste'...
$table->string('referencia')->nullable(); // id idempotente da operação de origem
$table->timestamp('ocorrido_em');
});
// Saldo = soma das movimentações. Auditável, reconciliável, à prova de divergência.
$saldo = MovimentacaoEstoque::where('produto_id', $produtoId)
->where('loja_id', $lojaId)
->sum('quantidade');
Com ledger, “divergência de estoque” deixa de ser um mistério: você consegue rastrear cada unidade até a movimentação que a gerou. E o campo referencia te dá idempotência de brinde — a mesma importação rodando duas vezes não duplica entrada, porque você ignora movimentações com referência já registrada.
Impacto técnico
O caso do código reusado parece bobo — “é só não reusar”. Mas ele é um exemplo perfeito de um princípio que vale pra qualquer sistema sério: identidade e rótulo são coisas diferentes, e misturar os dois é dívida técnica esperando pra cobrar juros.
O mesmo erro aparece disfarçado em mil lugares: reusar o id de um usuário deletado, reciclar número de pedido, reaproveitar um e-mail que mudou de dono. Sempre que um identificador com histórico é reapontado pra uma entidade nova, o passado de um vira o presente do outro — e em estoque, “passado virando presente” tem nome: prejuízo.
Vale o contraponto honesto pra não soar dogmático: nem todo sistema precisa de ledger e UUID desde o dia um. Pra um CRUD simples, é overkill. Mas no minuto em que tem dinheiro, estoque ou histórico que precisa fechar, identidade imutável deixa de ser luxo e vira requisito.
A regra de ouro cabe numa frase: identificador nasce, vive e morre com uma única entidade — nunca é herdado. Quando você separa a identidade interna (imutável, do sistema) do rótulo de negócio (o SKU, que humanos digitam e às vezes querem reusar), o reúso de código deixa de ser uma bomba-relógio e passa a ser, no máximo, um errinho barrado no cadastro.
E se você trata estoque como livro-razão em vez de um número que vai sendo somado no escuro, ganha de brinde a coisa mais valiosa de todas quando o bug aparecer: a capacidade de olhar o histórico e dizer, com certeza, onde a conta começou a mentir. Que é exatamente o que faltava no dia em que o energético fantasma apareceu no relatório.


