O problema
O stock de um restaurante vive num quadro branco ou na cabeça do chefe. Sabe-se quanto frango há, mais ou menos. Não se sabe qual dos três lotes expira amanhã, e quando alguém abre a câmara tira do que está mais à mão, que é o que chegou ontem. O de anteontem estraga-se ao fundo. O desperdício não se mede porque nunca foi registado, por isso parece que não existe.
Modelo de dados
Ingredientes, fornecedores, preços e receitas são dados de referência, partilhados com as compras e o food cost. Os lotes pertencem a uma sala: cada um sabe quanto entrou, quanto resta, a que custo, a validade e onde está. E cada alteração a um lote é uma linha na tabela de movimentos. A quantidade do lote é um número derivado; o histórico é a verdade.
| Tabela | Para que serve |
|---|---|
| demo_ingredients | O que a casa compra: nome, unidade e categoria. Partilhado com as compras e o food cost. |
| demo_suppliers | Quem fornece, com prazo de entrega e encomenda mínima. Partilhado com as compras. |
| demo_recipes | As fichas técnicas: doses, preço de venda e as linhas de ingredientes. Partilhado com o food cost. |
| demo_stock_batches | Um lote por entrada: quanto entrou, quanto resta, custo, validade e local. A quantidade é derivada dos movimentos. |
| demo_stock_movements | Cada alteração ao stock, com tipo, quantidade, motivo e referência. Nunca se apaga. |
-- A quantidade do lote nunca se escreve à mão.
-- Cada alteração é uma linha aqui; o lote é a soma.
create table demo_stock_movements (
batch_id uuid references demo_stock_batches(id),
kind demo_movement_kind not null, -- entrada, consumo, desperdicio, ajuste
qty numeric(9,3) not null check (qty > 0),
reason demo_waste_reason,
ref text, -- a receita, a guia, o motivo
created_at timestamptz default now(),
-- Desperdício sem motivo não é desperdício, é um buraco no stock.
constraint demo_waste_reason_needed
check (kind <> 'desperdicio' or reason is not null)
);O consumo escolhe o lote
Quando a cozinha pede seis quilos de frango, o browser não diz de onde tirar. A função percorre os lotes desse ingrediente por validade, do que expira primeiro para o que expira depois, e tira de cada um até perfazer. Se o primeiro lote só tem quatro, tira quatro dele e dois do seguinte, e escreve um movimento por cada. Um lock por sala e ingrediente impede dois pedidos simultâneos de tirar o mesmo quilo. Lotes sem validade vão para o fim: é o FIFO dentro do FEFO.
-- O browser não diz de onde tirar. A função percorre os lotes
-- por validade, do que expira primeiro para o que expira depois.
perform pg_advisory_xact_lock(hashtext(p_room || '|stock|' || p_ingredient));
for v_batch in
select * from demo_stock_batches
where room_code = p_room and ingredient_id = p_ingredient and qty > 0
order by expiry asc nulls last, received_at asc
for update
loop
exit when v_left <= 0;
v_take := least(v_batch.qty, v_left);
update demo_stock_batches set qty = qty - v_take where id = v_batch.id;
insert into demo_stock_movements (batch_id, kind, qty, ref)
values (v_batch.id, 'consumo', v_take, p_ref);
v_alloc := v_alloc || jsonb_build_object(
'batch', v_batch.code, 'qty', v_take, 'left', v_batch.qty - v_take);
v_left := v_left - v_take;
end loop;
return v_alloc; -- exactamente de que lote saiu cada quantidadeCozinhar é consumir por receita
Cozinhar vinte doses de frango assado converte-se nos ingredientes da receita, escalados pelas doses, e cada um passa pela função de consumo. É tudo ou nada: se faltar azeite, não se tira o frango. A função devolve exactamente de que lote saiu cada quantidade, e é isso que o ecrã mostra, para ninguém ter de acreditar na palavra do sistema.
-- Cozinhar vinte doses converte-se nos ingredientes da receita,
-- escalados pelas doses, cada um pela função de consumo.
for v_line in select * from demo_recipe_lines where recipe_id = p_recipe
loop
v_need := round(v_line.qty * p_portions / v_recipe.portions, 3);
v_out := v_out || jsonb_build_object(
'ingredient', v_line.name_pt, 'needed', v_need,
'allocation', demo_stock_consume(p_room, v_line.ingredient_id, v_need, v_recipe.name_pt));
end loop;
-- Tudo ou nada: a função corre numa transacção. Se o azeite faltar
-- na terceira linha, o frango da primeira volta atrás.Desperdício com motivo
Registar desperdício sem motivo é recusado por uma restrição na tabela, não por um aviso no ecrã. Sem motivo, o número não serve para nada: não se sabe se o problema é a validade, a preparação ou a compra a mais. Com motivo, o painel mostra onde está a perder dinheiro, em euros, ao custo real do lote.
O que faria diferente num cliente real
Leitor de código de barras na recepção, para o lote e a validade entrarem sem escrever. Ligação ao KDS, para o consumo acontecer quando o prato sai e não quando alguém se lembra. Inventário físico periódico com ajustes assinados, porque a realidade nunca bate certo com o sistema. E autenticação: aqui qualquer visitante recebe e deita fora, porque é uma demonstração.