A sales import is one outlet's sales for one business day, and menu mapping is how each POS button learns which recipe it is and which shelf it empties; together they produce the "recipes say" side of the Variance report.
What an import is, and what it isn't
An import is a day of sales for one outlet, row by row, with a record of what happened to every row. It never touches the stock ledger. Each mapped sale explodes through its recipe into the leaf items it should have used, and those quantities are stored as theoretical usage for the report to compare against. Nothing depletes; nothing shows up in Movements or on Stock on Hand. If sales posted to the ledger, the ledger would agree with the recipes by construction and the variance would sit at zero forever.
There is one active import per outlet per day, unless the day is filed shift by shift (below). Try to import a day that already has a whole-day file and the server refuses with the covering import's code: "2026-08-14 has already been imported for this outlet as SIN-000012. Void that import first if you need to replace it." Content hashing can't protect this, because a corrected file legitimately differs from the original, and importing the good rows twice would double the day's theoretical usage.
You'll find the page under Inventory as Sales imports. Reading needs view_sales or import_sales; uploading, entering by hand and voiding need import_sales. Pointing menu items at recipes needs manage_recipes, for the reason given further down.
A whole day, or a night in shifts
A club runs two or three bar shifts on one business date, and a shift's variance needs that shift's sales. Only the till can split a night, by printing a Z-report per shift or running a report for a time range, so the import carries an optional Shift picker on the upload and hand-entry forms alongside the outlet and the date.
The picker appears only where it can mean something: the company holds the Bar Suite, and the outlet has shifts that overlap that business date. Overlapping rather than "on that date", because a late shift opens at 21:00 on the 14th and closes at 04:00 on the 15th, and matching on the calendar day would hide exactly the shift you are looking for. Leave it on The whole day and the file behaves as it always has.
One rule holds the whole thing together, and the picker's own hint says it: a day is filed whole or shift by shift, never both. Both directions are refused, naming the import already there:
- "2026-08-14 already has a whole-day import for this outlet (SIN-000012), so a shift file cannot be added to it."
- "2026-08-14 is already imported shift by shift for this outlet (SIN-000013, SIN-000014). A day is either whole-day or per-shift, never both."
The reason is arithmetic. The daily Variance report sums every active import on the date, which is correct under either regime and catastrophically wrong when both are present: the day would be counted twice, theoretical usage would double, and a bar that poured exactly what it sold would report a surplus the size of a night's trading. To switch a day from one regime to the other, void what is there first.
Two smaller rules. A shift file has to name a shift at the same outlet, and a shift's file can be imported for seven days after it closes, since the Z-report gets walked to the office on Monday: after that, "SHF-000123 closed more than 7 days ago, so its sales file can no longer be filed against it. Import the day as a whole-day file instead." The point of the window is that it is generous for real paperwork and useless for backfilling last month's shifts to move a variance off somebody's name.
Three ways in
A CSV export from the till
The file needs four headers: date, item, quantity, gross_value. Two more are read when present and ignored when not: pos_number (the PLU) and revenue_centre (which till rang it). A file without those still imports; it just identifies items by name and can't say which bar sold what.
Dates are read day-first (14/08/2026) or as ISO (2026-08-14). American month-first is never accepted, because 03/04/2026 is the 3rd of April in Kenya and no heuristic could safely tell otherwise. The business day defaults to yesterday, since a day's sales are imported after close and today is still ringing up.
Row problems are row-level, never silent. A file with 200 good rows and 3 bad ones imports 200 and keeps the 3 with their original text and a reason: "No item name", "Row has 5 columns, expected 6", "Row is dated 2026-08-13, but this import is for 2026-08-14", "Gross value cannot be negative". A quantity at or below zero is rejected too; net your refunds inside the POS before exporting. A gross value of exactly zero is fine and meant: staff meals import as zero-value lines so the kitchen's theoretical usage accounts for them.
A few things refuse the whole file instead, because the wrong thing was uploaded: an empty file ("That file is empty."), a missing header ("That file is missing the date column. Expected headers: date, item, quantity, gross_value."), a duplicated header, anything over 1 MB, or more than 5,000 rows ("Split the export by day.").
By hand, from the Z-report
Most small bars have no export at all, and without sales every drop of consumption reads as loss. Press Enter sales by hand, pick the outlet and day, and add lines: choose what sold from the outlet's menu items, a quantity, and gross KES. Record the day saves it. Rows with no item or a zero quantity are simply left out; a negative value is rejected on the server.
One thing to know: hand-entered lines are matched to menu items by name, without a PLU. If your menu items arrived from a Micros report and carry item numbers, a hand-entered day can land on the worklist as an unnumbered copy of something you've already mapped.
A Micros MI_R102 PDF
Oracle Micros prints a report called "Menu Item Sales, Subtotal by Family Group" (MI_R102). Export it to PDF and upload it in the same file box. The server recognises a PDF by its first bytes, not its extension, and it reads exactly that report and nothing else. Hand it a different report and it says so, and suggests you run that one or upload a CSV instead. A scanned or photographed copy is refused: "No text could be read from that PDF."
Three things make this path different from a CSV:
- The report sets its own business date. The date picker goes grey with the hint "Taken from the report's own period", and the import files under the report's period end. A monthly report therefore lands on the last day of the month, inside any variance period that covers that month.
- The report is checked against its own printed totals. Micros prints a subtotal for every family group, a total for every till and a grand total. Every row the parser reads is added back up against all of them, and if any one disagrees the entire file is refused: "That report does not add up to its own printed totals, so it has not been imported. This usually means the report layout has changed." No partial import. A CSV with a missing header fails loudly; a PDF whose layout has shifted fails quietly, with plausible numbers on the wrong rows, and that's the one failure this product exists to catch in other people's spreadsheets.
- Item numbers and tills come through, so a KES 7,000 bottle and a KES 500 tot with the same name stay apart, and a drink can deplete the bar that actually sold it.
Once you've chosen a PDF, a Check it first button appears. It runs the identical parse and reconciliation without writing anything, and tells you the row count, the period, the date it'd be filed under, how many family subtotals and till totals agreed, and the quantity and KES per till. Lines the report shows as sold-nothing are flagged as future rejects ("The report shows no quantity sold for this item."). When it reads cleanly, Import the report does it for real. The file must be under 2 MB; a month of one venue's menu is about 75 KB.
What happens to each row
Open any import and the header counts rows by outcome: Exploded, Awaiting mapping (with a "map them" link), Not stocked, Rejected. Problems sort to the top of the table, rejected first, then needs mapping, each pile in file order. Rejected rows keep their raw text so the reason can be argued against the row itself, a year later.
An unknown POS item is never rejected. It's accepted, a menu item is stubbed for it, and the row waits as Needs mapping with its quantity and value intact. The operator needs a worklist of dishes to map, ranked by how much sold, not a list of rows the system disliked. A row can also wait for a second reason even when its item is mapped: 'Till "SOHO ISLAND" is not pointed at a storage location yet.' A guessed shelf would be a variance against a bar that never sold the thing, so the row waits and says why.
Retrying the same upload replays the same import rather than colliding with itself, so a double click can't create two days.
Correcting a day
There's no edit. Press Void this import, give a reason (the server insists: "Voiding an import must say why."), and re-import. The rows and their usage stay as evidence; the variance engine reads active imports only, so a voided day contributes nothing without anything being deleted. The import's badge flips from Active to Voided and the reason is shown on the page.
Menu mapping
Every unmapped item is sales already recorded whose theoretical usage is currently zero. That makes the variance look worse, in the direction that looks like theft. The Menu mapping page (titled "Menu-item mapping") is the worklist, and it's ranked by quantity waiting, then rows waiting, so the dish a hundred covers are waiting on comes first. The Still needs a decision box is ticked by default; untick it to see everything.
Identity is the item number
Where the export carries a PLU, that number is the item's identity and the name is a label. One real till reuses a single name for a bottle and a tot fifty times over. A file without numbers keeps its own world: unnumbered rows only ever match unnumbered items. When two PLUs at an outlet share a name, the row says "shares this name with 1 other" so they don't quietly get mapped differently.
Three states, not two
An item is unmapped, mapped or not_stocked. The third one is an answer, not a queue. "NO CHILLIES", an event ticket, a button nobody named: no recipe can describe these, and left as unmapped they'd sit on the list forever, hiding the dish that matters. Marking them not stocked settles their waiting rows immediately: revenue is kept, nothing depletes, and they leave the worklist.
Mapping an item
Press Map (or Re-map). The dialog asks for two things and won't save with one:
- Recipe. A name match may be prefilled as a suggestion; it never maps by itself.
- Depletes stock at. The bar that sold it resolves each sale through the till that rang it, and it's the only right answer for a drink sold at more than one bar; pinned to one bar, a Tusker sold at both reads as a surplus at one and an identical loss at the other. Always the same place names a location outright, which is right for food: a burger empties the kitchen whichever till took the order. A fixed mapping without a location is refused ("A fixed mapping needs the location it depletes."), and so is a location outside the item's outlet.
A List price, KES is optional and is only the denominator for costing; actual revenue comes from the file. Waiting rows explode the moment the mapping saves. Rows that already exploded keep the recipe they went through; re-pointing a mapping never restates history.
Mapping sits behind manage_recipes because it decides what a sale depletes and where. An importer who could re-point a mapping could relabel a loss. Without the grant you still see the worklist, just no buttons.
Tills
Tills appear on their own: the first import naming one creates it, with no location. On POS setup, under Tills, press Point at a location and choose the shelf it draws on. Rows waiting on that till explode as soon as you save. Till codes match case-insensitively, so SOHO Island and SOHO ISLAND are the same till. A till's location must be inside its own outlet.
The till's word and yours
Rename sets what people see in Bohari; the POS spelling is kept beside it as "till: …" and reports keep matching it. Setting the display name to the till's own spelling stores nothing. The two are separate fields on purpose: rename detection compares what the till sent last time with what it sent today, and if people were editing that field the signal would fire on their own tidy-ups.
When the till renames something
If an import brings a different name for a PLU that's already mapped, the row says "The till renamed this" and shows the old name. A rename is harmless; a recycled item number (deleted, then reissued for a different product) is not, and from here the two look the same. Sales keep flowing through the existing mapping while a person looks. If it's the same product, press Same product and the flag clears. If it isn't, Re-map it. Confirming can't change the recipe, deliberately.
Not-stocked rules
On POS setup, under Not-stocked rules, you can teach the system which buttons are never stock. A rule matches on one of four things: the button has no name, the name contains a word, the name starts with text, or the PLU starts with digits. Every company starts with three: a blank name, a name containing "test", and a name starting with "_". Word matching is bounded, so "test" pre-ticks "Shandy Test" and "Water Tests" but leaves "Testarossa Red" alone. A reason is required ("Say why this rule means an item is not stock.") because it's shown next to every tick the rule makes.
Rules only suggest. A match arrives pre-ticked on the worklist with its reason; nothing changes until someone presses Mark not stocked. The asymmetry is the point: a missed suggestion costs one tick, a wrong one silently erases a product's theoretical usage. That's also why there's no "only ever sold at zero" rule on offer: nineteen bottles of Martell given to hosts at KES 0 still left a shelf.
The tick bar tells you how many items are ticked and, since rules pre-tick across the whole list, how many of those aren't on the page you're looking at. A mapped item can't be ticked over to not stocked; the server refuses with '"Nyama Choma" is mapped to a recipe. Clear that mapping first, so marking it as not stocked is a deliberate act rather than a slip.' Rules are retired, never deleted; deleting the last one would quietly bring the defaults back.
FAQ
Why was the whole PDF refused when only one line looked wrong? Because the parser can't tell which line. A report that disagrees with its own printed totals has been laid out differently from the one the adapter was written against, and rows that happen to add up can't be trusted individually. Run Check it first, read the mismatch list, and if the report layout has genuinely changed, fall back to a CSV.
Why doesn't a sale show in Movements? Sales never post to the ledger. Theoretical usage is a comparison stored beside the import, not a stock movement. What you'll see instead is the "recipes say" column on the variance report moving.
I mapped the item. Why are some of its rows still waiting?
Look at the reason on the row. Under "the bar that sold it", each row also needs its till pointed at a location, or a revenue_centre column in the export. Fix the till on POS setup and the rows explode on their own.
Can I put a not-stocked item back? Yes. Untick Still needs a decision, tick the item, and press Put back on the list. Its settled rows return to needs-mapping.
I uploaded the wrong day's file. Now what? Rows dated for another day were rejected individually, and the import for the day you named exists. Void it with a reason, then import the right file.