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.