One of many things that rubbed me the wrong way with Tillys, a small point of sale system I wrote and used last year, was that changes to the menu mid-service initially broke changing existing ticket orders and later led to complicated front-end logic in an attempt - a poor one - to fix it. Granted, this was a rare occurrence, usually an "oh-no moment" right before service where you realize you made a boo-boo, but it hinted at a deeper underlying modeling issue.
My endeavor is too small to classify this as something worth spending time on, but part of building my own POS is similar to how somebody goes to the gym ... it's a workout for my brain.
This year I've tackled things differently, so follow along with some of my thinking and modeling on the subject. A menu contains menu options, a menu option potentially has variants. The sections (is it food or a drink) and categories (for food in my case: a pizza, pasta, salad, pastry; for drinks in my case: water, soft, hot, cocktail, beer, wine) are strongly typed because I don't have to cater to the millions of menus in the world, just mine. So you naturally get a pizza having a name, toppings, a shape (round, slice, small, ...), a price with an amount and both a table service and take away tax rate (included in the amount), whereas a wine has a name, a brand, the years I've got in stock, the style (sparkling or still) and color (red, white, rose), a region (including country), an alcohol % and variants such as by the glass or bottle, both with a respective volume (15cl vs 75cl) and obviously a price (same as before: amount, tax rates). A perk of this strong typing is that it helped come up with the dialogs for the menu editor.
type Subject =
{ Id: string
FullName: string
EmailAddress: string }
type TaxRate =
| TwentyOnePercent
| TwelvePercent
| SixPercent
| ZeroPercent
type PriceOption =
{ Amount: decimal
TableTaxRate: TaxRate
TakeAwayTaxRate: TaxRate }
type PizzaShape =
| Tonda = 0
| AlTaglio = 1
| Pizzetta = 2
type PizzaOption =
{ Name: string
Toppings: string list
Shape: PizzaShape
Price: PriceOption }
type SandwichOption =
{ Name: string
Toppings: string list
Price: PriceOption }
type SaladOption =
{ Name: string
Ingredients: string list
Price: PriceOption }
type PastryOption =
{ Name: string
Ingredients: string list
Price: PriceOption }
type PastaOption =
{ Name: string
Ingredients: string list
Price: PriceOption }
type FoodOptions =
| PizzaOption of PizzaOption
| SandwichOption of SandwichOption
| SaladOption of SaladOption
| PastaOption of PastaOption
| PastryOption of PastryOption
type VolumeMeasure =
| Centiliter = 0
| Liter = 1
type Volume =
{ Amount: decimal; Unit: VolumeMeasure }
type Variant = { Volume: Volume; Price: PriceOption }
type WaterOption =
{ Name: string
Brand: string option
Variants: Variant list }
type SoftDrinkOption =
{ Name: string
Brand: string option
Variants: Variant list }
type BeerOption =
{ Name: string
Brand: string option
Variants: Variant list
AlcoholPercentage: decimal }
type CocktailOption =
{ Name: string
Portion: Volume
Price: PriceOption
Ingredients: string list
AlcoholPercentage: decimal }
type WineColor =
| Red
| White
| Rose
type WineStyle =
| Still of WineColor
| Sparkling of WineColor
type WineVariant =
| Glass of Variant
| Bottle of Variant
type WineOption =
{ Name: string
Brand: string option
Years: int list
Style: WineStyle
Variants: WineVariant list
Region: string
AlcoholPercentage: decimal }
type HotDrinkOption =
{ Name: string
Brand: string option
Variants: Variant list }
type DrinkOptions =
| WaterOption of WaterOption
| SoftDrinkOption of SoftDrinkOption
| BeerOption of BeerOption
| CocktailOption of CocktailOption
| WineOption of WineOption
| HotDrinkOption of HotDrinkOption
type MenuOption =
| FoodOption of FoodOptions
| DrinkOption of DrinkOptions
type Menu = { Options: MenuOption list }
type MenuVersion =
{ Options: MenuOption list
Version: int }
type PlanTableMenu = { Menu: Menu; By: Subject }
type PublishTableMenu = { Menu: Menu; By: Subject }
type TableMenuPlanned =
{ Menu: MenuVersion
By: Subject
At: Instant }
type TableMenuPublished =
{ Menu: MenuVersion
By: Subject
At: Instant }
The order in which these options appear in the menu, on a technical level, is not important. That does become important once you get to displaying them in print, where you group options in the same section, then in each category within a section, listing categories in a certain order, and options either by price or alphabetically or some other heuristic, variants shown inline rather than as separate lines. It's also important that display order is stable during order taking because you naturally develop muscle memory of where buttons are. I've relied on bespoke sorting functions to keep it relatively stable.
Ideally food would be linked to recipes and ingredients used in those recipes, which in turn would inform one about the possible food allergies they might trigger. I've been tinkering with cooklang, automatic invoice reading (finally a good Peppol use case), keeping track of food preparations, doing stock management for this purpose, but nowhere near something that I'd deem production worthy ...that, I'm afraid, will be for another year, eventually we'll get there, or not, who really cares 😃 For now, given the limited amount of ingredients I use, it's easy enough to remember all the allergens mentioned on the packaging.
Now, menus are either planned or published, that's it. Each planned or published version is essentially an immutable list of menu options. That's the key insight ... it makes deriving a key composed of the menu version, the index of the option in that version, and the optional index of a variant within that option dead easy. Turn that into a string and you get a key like "v7:d5.0" saying "we've looked at menu v7, picked the sixth drink in there and the first variant". The joy of being off-by-one and microformats.
Only published menus are used during order taking. I don't have any menu scheduling needs, that is, planning what I'd be serving during a given service and have it magically come into effect right before that service. No, I just publish whatever I'm gonna be using now or in the near future. That means the order taking part, the counter, simply loads the latest menu, chops it up into sections and categories as something you progressively navigate through to get to the option or option - variant combo of your choice. Clicking menu options implies adding them to a table ticket. At this point a menu option becomes a menu item, part of a table ticket line item. Given that each menu option has a (derivable) key, figuring out whether we're increasing the quantity on an existing ticket line item vs adding a genuinely new ticket line item vs adding an item which happens to come from a different menu version becomes as easy as matching on the key. If the menu version the option came from happens to be different, I do not mind seeing 2 lines with the same menu item name - if anything, it makes the situation I ran into explicit. But ... there's always a butt, just ask Beavis.
type MenuOptionKey =
{ OptionIndex: int
VariantIndex: int option }
type MenuItemId =
{ MenuVersion: int; Key: MenuOptionKey }
type ServiceType =
| TableService = 0
| TakeAway = 1
type TicketLineItemId =
{ MenuItemId: MenuItemId
ServiceType: ServiceType }
type Price = { Amount: decimal; TaxRate: TaxRate }
type TicketMenuItem = // mirrors MenuOption
| FoodItem of FoodItem // ommitted for brevity
| DrinkItem of DrinkItem // ommitted for brevity
type TicketLineItem =
{ ItemId: TicketLineItemId
Item: TicketMenuItem
Quantity: int
Price: Price
TotalAmount: decimal }
type TableTicketOrderTaken =
{ Date: LocalDate // essentially an integer
Ticket: TicketNumber // essentially an integer
Table: TableNumber // essentially an integer
Items: TicketLineItem list
TicketLabel: string option
TotalAmount: decimal
TotalAmountDue: decimal
Revision: int
At: Instant // essentially an integer
By: Subject }
On a technical level this sounds reasonable and plausible, but there is an elephant in the room. How happy would you - as a customer - be if the same (menu) item came at a different price or with different toppings compared to the print copy you just ordered it from? Why? Because I just happened to update the menu in between taking and changing your order ... pretty damn illegal, if you ask me. Nothing like taking that fluffy beautiful model you've just dreamed up and punch it into mush. The in-between solution I've come up with is "you're stuck with the menu version you started this ticket out with, that is, the version used when taking the order for the first time". It balances the ability to fix boo-boos before or even during service (with an obligatory "you better print those f* menus again" and, yes, I do use paper menus, no QR code scanning here, old-skool), with delivering on what-you-see-is-what-you-get-and-pay-for. Now, I've got very crude tools I could use to "move" a ticket to a new version of the menu but they imply closing and cancelling the ticket and entering it all again as a new ticket - that's on purpose - this sort of undesirable behavior should not be made easy.
So what good comes out of all this? Making the implicit explicit, progressive insight in what seemed like a technical problem at first, giving me a frame of reference and some basic constructs to reason about all this without it being embedded in front-end spaghetti. This sort of thing is not constrained to my silly example here ... any place where you "use inventory" you will run into this problem yet the solutions will be very different: e-commerce and product catalogs, libraries and the material they lend, a rental service and the things they rent, etc.