A Restaurant Inventory System Built on a Movement Log
A Tallinn food-service business keeps stock in two places: a warehouse and a food trailer. Goods are bought into the warehouse, the kitchen prepares some of them, the trailer takes a load out in the morning and sells through it during the day. "How much do we actually have?" should not depend on someone's memory or a hand-kept spreadsheet.
We built them a restaurant inventory system as a pilot. This post covers how it works, the decisions we made, and why.
The problem: stock should not be a number anyone can overwrite
Most simple stock sheets keep one cell per product: "Cola, warehouse, 48". When someone takes some, they change the number. That works until someone makes a mistake. Then the number is wrong, and nobody knows when or why it went wrong.
We started from the opposite end: stock levels are not stored at all. Only movements are:
- purchase (goods arrived at the warehouse, or straight at the trailer)
- transfer (warehouse to trailer)
- kitchen production (raw ingredient into a prepared product)
- write-off
- stock-count correction
- sale, from the till
The stock of each product at each location is computed as the sum of those movements. The log is append-only: existing rows are never overwritten.
Why a movement log beats an editable number
For an owner, this means three things.
You can look behind every number. If the overview says 12 crates in the warehouse, the log shows exactly which purchases, transfers and sales add up to that, who entered them and when.
Mistakes get fixed without erasing the trail. A wrong entry is voided and, if needed, replaced with a new one. A void must carry three facts together: who, when and why. The database refuses a void without a reason. The original row stays, just struck through.
Stock can never go negative. You cannot take meat to the trailer that was never bought. If one entry has ten lines and one of them would push a shelf below zero, the whole entry is rejected, not just that line. Nothing is ever half-saved.
There is one deliberate exception: a till sale may take stock negative. The till is not stating an intention, it is reporting what already happened. If a sale and the log disagree, someone skipped logging a transfer, and the honest move is to show that, not to refuse the day's sales.
What staff do on their phones
Every entry form is built phone-first. If stock-keeping takes too long, people stop doing it. The screens:
- Purchase. Date, products and quantities, and where the goods arrived. No prices or suppliers: this system tracks what is on the shelf, not what it cost.
- Take to trailer. Warehouse to trailer, with big buttons and quantity steppers. The target is to finish an entry in under 20 seconds.
- Kitchen. Marinate a raw ingredient, or write something off. Write-offs ask for confirmation first, because they destroy a quantity nobody can get back.
- Count. A stock count at the shelf you are standing at. The difference between computed and counted stock is saved as a correction, not as a new stock level.
Quick-add buttons are set per product and per direction. Drinks arrive by the crate but leave one at a time, so the purchase form offers different steps than the transfer form. Every screen lists products in the same order, which the owner arranges himself, so a product is always in the same place.
The kitchen produces, it does not only consume
The kitchen does not just take stock out; it makes new stock from it. Raw meat becomes marinated meat, which is real inventory: it moves to the trailer, gets counted and gets written off.
Marination is not one-to-one. Meat is trimmed before it goes into the marinade, so the weight drops. The system therefore records two weights: raw used and marinated made. The difference shows as loss. It is not logged as a separate write-off, because the raw side has already come down by the full weight, and a write-off would count the loss twice.
The same idea applies to the marinade mix, made from six spices to a recipe. One batch is a single act across seven products. Void the batch and all of its rows are voided together.
Connecting the till to stock
The till emails four reports every day. The app reads the mailbox itself and imports them, so nobody moves files around by hand. Only the per-item sales report affects stock; the others feed daily takings into a statistics view.
This is where a common restaurant problem shows up: the till sells dishes, the shelf holds ingredients. The till knows three plates and two drinks were sold; the shelf loses meat, onion and packaging. So each till item has a mapping to what it takes off the shelf. Drinks use the same mechanism, with a one-line recipe of quantity 1.
A few decisions made that dependable:
- Items are matched by till code, not by name. The till has typos in item names, and fixing one must not break the mapping.
- The sales date comes from the file name. The date in the report header is when it was generated, which is the next morning.
- Importing the same day twice is safe. Takings are replaced; stock sales are written only the first time.
- An unknown item is never dropped silently. When a new drink appears on the till without a mapping, a warning stays on the home screen until the mapping is added.
Some stock is spent by being carried, not sold
Nobody at the trailer window is going to tick off every fork, glove or spoonful of sauce. In an early version, such items came down both when they were carried out and again with every dish sold. For one product that put the trailer deep in the negative while dozens of packs sat in the warehouse.
The fix was a single per-product flag: the item counts as used the moment it leaves the warehouse for the trailer, and till sales no longer touch it. For these products, reordering is judged on warehouse stock. The owner can switch the flag on or off per product himself.
Reorder flags instead of gut feel
The home screen shows each product's stock in the warehouse, on the trailer and in total. When a product drops below a level the owner has set, it gets a "reorder" badge. There is also a higher, yellow warning level that flags a product running low before it is critical.
In the first version every product had a threshold we had guessed. They flagged products that were fine. Now thresholds ship empty, and a flag appears only once someone who knows the business has set one. An alert that cries wolf teaches people to ignore alerts.
The trailer can also have a target level per product. The take-along list compares it with what is actually on the truck and suggests what to bring.
The owner also gets a desktop view: a stock table, what needs reordering, and recent activity. The same derived numbers can be downloaded as a CSV file.
The technical side, briefly
We kept it small, because it is a pilot:
- FastAPI on the backend, SQLite as the database
- server-rendered pages with Jinja2 and HTMX, no build step
- stock is computed in exactly one module, which has automated tests
- schema changes are numbered SQL migrations
- login and permissions: owner, kitchen and trailer accounts see only the screens they are allowed to
- the app reads the till mailbox itself, over IMAP
A foundation for automated ordering
Once stock can be trusted, automation can sit on top of it. The app already has an API for machines: it returns derived stock with reorder levels and accepts purchases, transfers and counts through the same rules as the staff screens. Through the API, too, stock cannot go negative and nothing the kitchen makes can be "bought".
What we deliberately did not build: prices and costs on the stock side, automatic invoice reading, or splitting sauces per dish. The till cannot tell garlic sauce from chilli, and a guess presented as a measurement is worse than the gap the weekly count already covers.
Does your stock need the same?
If your restaurant, café or food-service business keeps stock in more than one place, your till does not talk to your stock, and ordering runs on gut feel, let's talk. We start with how your goods actually move and build only as much as you need.
Have a workflow that should work better?
START A CONVERSATION →