From 1d07272aea54403e6986e9663dc62f1dc9a278b5 Mon Sep 17 00:00:00 2001 From: austin Date: Mon, 27 Jul 2026 12:58:20 -0500 Subject: [PATCH] Prices with costs behind them, and five columns that promised more than they held The catering tables were priced before anyone put a cost against them. Working back from the bakery's own goods-plus-labour figures gives a model that reproduces every one of them to the cent -- $15.00/hr, mini $0.4167, cupcake $0.6667, a 12x17 pan $18.00, cakes $7.00/$10.50/$12.50 -- and the 8-inch solves to $10.50 from the parties table and independently to $10.50 as the wedding cake, which is the check that made the rest trustworthy. Two facts from the bakery did most of the damage. A pan cuts 48, so portions had been judged on the cupcakes alone -- barely a third of the food. And the bride & groom cake is ceremonial, so it feeds nobody. Against 1.25 servings a guest, five of the nine live columns promised more than they held: all three weddings (the 200 by 37 servings) and the two smaller parties. The fix is deliberately asymmetric. The weddings get more food so the familiar 125/200/250 can stand, because that is the number a customer says out loud. The parties get a corrected label, because feeding the middle one properly would have taken its cupcakes to two dozen -- the same as the tier above -- leaving the two $2 apart in cost with only cake size between them. Prices follow the costs. Weddings were assuming four hours of labour; six is honest, and it reconciles with the three a Gatherings order takes. At six the old prices returned $11.92 an hour on the 125 -- less than a Large office box -- so all three go up 20%, which still asks only $1.48-$1.91 a serving. The entry party goes to $57, not to make it pay but to hold it at $4.00 an hour, the same deliberate floor as the Small office box. The middle party stays at $76: at $88 it was a column nobody could rationally buy, since the $98 beside it feeds fourteen more people. Office prices do not move at all -- at 1.25 a head those boxes are already conservative. New Gatherings table, because the page went from "30-40 people" to "125" with nothing between, and the bakery confirmed those enquiries come in. Sized from the pan rather than guessed, labels rounded down. Delivery and a wedding deposit were never stated anywhere; both are now. Co-Authored-By: Claude Opus 5 (1M context) --- .../db/migration/V8__pricing_and_portions.sql | 217 ++++++++++++++++++ .../com/itsthevine/web/CateringMenuTest.java | 73 ++++-- .../itsthevine/web/SiteControllerTest.java | 5 +- 3 files changed, 278 insertions(+), 17 deletions(-) create mode 100644 src/main/resources/db/migration/V8__pricing_and_portions.sql diff --git a/src/main/resources/db/migration/V8__pricing_and_portions.sql b/src/main/resources/db/migration/V8__pricing_and_portions.sql new file mode 100644 index 0000000..079db75 --- /dev/null +++ b/src/main/resources/db/migration/V8__pricing_and_portions.sql @@ -0,0 +1,217 @@ +-- The prices and portions the bakery settled on after costing every package properly. +-- +-- V4 seeded the spreadsheet. This is the first migration that changes what things COST rather than how +-- they read, and it exists because the tables were priced before anyone put a cost against them. Working +-- back from the bakery's own goods-plus-labour figures gave a model that reproduces every one of them to +-- the cent -- $15.00 an hour, mini pastry $0.4167, cupcake $0.6667, a 12x17 pan $18.00, cakes $7.00 / +-- $10.50 / $12.50 -- and that model is what the numbers below come out of. +-- +-- Two facts from the bakery drive nearly all of it: +-- +-- 1. A PAN CUTS 48. Portions were being judged on the cupcakes alone, which are barely a third of the +-- food. Counting the pans is what showed the weddings were short. +-- 2. THE BRIDE & GROOM CAKE IS CEREMONIAL. It is cut for the couple, not served to the room, so it +-- feeds nobody -- it stays in every wedding package and contributes zero portions. +-- +-- Against a planning rate of 1.25 servings a guest, five of the nine live columns promised more than they +-- held. All three weddings were short (the 200 by 37 servings), and the two smaller parties were over- +-- labelled. The fix is deliberately asymmetric: the WEDDINGS get more food so the familiar 125 / 200 / 250 +-- can stand, because that is the number a customer says out loud; the PARTIES get a corrected label, +-- because giving the middle one more food would have taken its cupcakes to two dozen -- the same as the +-- tier above it -- leaving the two $2 apart in cost with only cake size between them. +-- +-- Guarded on the exact text and prices V4-V7 left, as every migration here has been: anything the bakery +-- has already changed in the admin is theirs and is left alone. Their wording and their prices win. + +-- --- office boxes ------------------------------------------------------------------------------------ +-- +-- No price moves. At 1.25 a head these boxes feed 9.6 / 14.4 / 19.2, so the "About 6-8 people" figures +-- are conservative rather than optimistic and are left as they are. What was missing is that the ladder +-- has a rule -- $24 for a dozen, then a flat $8 for each extra six -- which the three cards never said. + +insert into catering_package_note (package_id, position, body) +select p.id, 2, 'A dozen is $24, and every extra six is $8.' + from catering_package p + where p.name = 'Office boxes' + and not exists (select 1 from catering_package_note n where n.package_id = p.id and n.position = 2); + +-- --- parties ----------------------------------------------------------------------------------------- +-- +-- The 15-20 was labelled for twenty and held eighteen servings. Half a dozen more cupcakes fixes that for +-- $4.00 of goods, and the price goes to $57 -- which is not an attempt to make it pay. It is a deliberate +-- loss leader at $4.00 an hour of labour, the same floor as the Small office box, and 40% would have been +-- $81.67. The 20-30 keeps its food and its $76 and is simply relabelled to the 27 it actually feeds; +-- at $88 it would have been a column nobody could rationally buy, since the $98 beside it feeds fourteen +-- more people. The 30-40 already fed 42 and is untouched. + +update catering_tier t set price_cents = 5700, updated_at = now() + from catering_package p + where p.id = t.package_id and p.name = 'Parties' + and t.label = '15–20 people' and t.price_cents = 5400; + +update catering_row_value v set value = 'Eighteen, your choice' + from catering_row r + join catering_package p on p.id = r.package_id + where r.id = v.row_id and p.name = 'Parties' + and r.label = 'Cupcakes or sugar cookies' + and v.position = 0 and v.value = 'A dozen, either one'; + +update catering_tier t set label = '20–27 people', updated_at = now() + from catering_package p + where p.id = t.package_id and p.name = 'Parties' + and t.label = '20–30 people'; + +-- --- weddings ---------------------------------------------------------------------------------------- +-- +-- Every column gains food and every column gains price, and the headcounts do not move. +-- +-- PORTIONS. With the bride & groom cake feeding nobody, the columns held 144 / 204 / 264 servings against +-- the 156 / 250 / 313 that 1.25 a guest asks for. A dozen onto the first and a pan onto the other two +-- lands them at 156 / 252 / 312 -- 1.248 / 1.260 / 1.248 a head, which is as even as this table has ever +-- been. +-- +-- PRICE. The old figures assumed four hours of labour; six is the honest number, and it reconciles with +-- the three hours a Gatherings order takes (the 125 wedding is the same two pans, two and a half times +-- the cupcakes, and a decorated cake, for twice the hours). At six hours the old prices returned $11.92 +-- an hour on the 125 -- less than a Large office box. A flat 20% across all three restores 40.8 / 42.9 / +-- 48.5% and still asks only $1.48-$1.91 a serving, which is about half the low end of what wedding +-- dessert usually costs. The ladder's slope was already right and is left alone: markup is a steady +-- 1.69-1.94x on cost per serving, because six fixed hours and one $10.50 cake spread over more servings. + +update catering_row_value v set value = 'Five dozen' + from catering_row r + join catering_package p on p.id = r.package_id + where r.id = v.row_id and p.name = 'Weddings' + and r.label = 'Cupcakes or sugar cookies' + and v.position = 0 and v.value = 'Four dozen'; + +-- Position is in the guard as well as the value: after the first of these runs, two columns read +-- "Four pans", and only the column index tells them apart. +update catering_row_value v set value = 'Five pans' + from catering_row r + join catering_package p on p.id = r.package_id + where r.id = v.row_id and p.name = 'Weddings' + and r.label = 'Sheet cakes or 12×17 bars' + and v.position = 2 and v.value = 'Four pans'; + +update catering_row_value v set value = 'Four pans' + from catering_row r + join catering_package p on p.id = r.package_id + where r.id = v.row_id and p.name = 'Weddings' + and r.label = 'Sheet cakes or 12×17 bars' + and v.position = 1 and v.value = 'Three pans'; + +update catering_tier t set price_cents = 29800, updated_at = now() + from catering_package p + where p.id = t.package_id and p.name = 'Weddings' + and t.label = '125 people' and t.price_cents = 23600; + +update catering_tier t set price_cents = 37200, updated_at = now() + from catering_package p + where p.id = t.package_id and p.name = 'Weddings' + and t.label = '200 people' and t.price_cents = 31000; + +update catering_tier t set price_cents = 46300, updated_at = now() + from catering_package p + where p.id = t.package_id and p.name = 'Weddings' + and t.label = '250 people' and t.price_cents = 38600; + +-- A wedding is booked months out and is the largest order the shop takes; it was the only one with no +-- deposit at all. +insert into catering_package_note (package_id, position, body) +select p.id, 2, 'A $50 deposit books the date, and the balance is due on delivery.' + from catering_package p + where p.name = 'Weddings' + and not exists (select 1 from catering_package_note n where n.package_id = p.id and n.position = 2); + +-- The old wedding note predates there being a delivery policy at all, so it promised a conversation the +-- page can now just answer. +update catering_package_note + set body = 'Delivery is $10 in Princeville and $30 elsewhere in Peoria County. Setup is charged separately.' + where body = 'The delivery fee depends on where the wedding is, and setup is charged separately.'; + +-- --- gatherings -------------------------------------------------------------------------------------- +-- +-- A new table, because the page went from "30-40 people" straight to "125" and a sixty-person graduation +-- or a hundred-person church supper found nothing on it. The bakery confirmed those enquiries do come in. +-- +-- It is the wedding table's shape without the bride & groom cake, and it is sized from the 48-unit pan +-- rather than guessed: 72 and 120 servings, which at 1.25 a head is 57.6 and 96.0 people. The labels +-- round DOWN to 55 and 95 -- with a number nobody has promised before, under is the safe direction. +-- Two hours of labour on the first and three on the second, which is what the larger one really takes. + +update catering_package set position = 4, updated_at = now() + where name = 'Weddings' and position = 3; + +insert into catering_package (name, blurb, position, created_at) +select 'Gatherings', 'Dessert for a graduation, a shower or a supper — sized by the head count.', 3, now() + where not exists (select 1 from catering_package where name = 'Gatherings'); + +insert into catering_tier (package_id, label, price_cents, position, created_at) +select p.id, 'About 55 people', 11000, 1, now() + from catering_package p + where p.name = 'Gatherings' + and not exists (select 1 from catering_tier t where t.package_id = p.id and t.position = 1); + +insert into catering_tier (package_id, label, price_cents, position, created_at) +select p.id, 'About 95 people', 17000, 2, now() + from catering_package p + where p.name = 'Gatherings' + and not exists (select 1 from catering_tier t where t.package_id = p.id and t.position = 2); + +insert into catering_row (package_id, label, position, created_at) +select p.id, 'Sheet cakes or 12×17 bars', 1, now() + from catering_package p + where p.name = 'Gatherings' + and not exists (select 1 from catering_row r where r.package_id = p.id and r.position = 1); + +insert into catering_row (package_id, label, position, created_at) +select p.id, 'Cupcakes or sugar cookies', 2, now() + from catering_package p + where p.name = 'Gatherings' + and not exists (select 1 from catering_row r where r.package_id = p.id and r.position = 2); + +insert into catering_row_value (row_id, position, value) +select r.id, 0, 'One pan' + from catering_row r + join catering_package p on p.id = r.package_id + where p.name = 'Gatherings' and r.label = 'Sheet cakes or 12×17 bars' + and not exists (select 1 from catering_row_value v where v.row_id = r.id and v.position = 0); + +insert into catering_row_value (row_id, position, value) +select r.id, 1, 'Two pans' + from catering_row r + join catering_package p on p.id = r.package_id + where p.name = 'Gatherings' and r.label = 'Sheet cakes or 12×17 bars' + and not exists (select 1 from catering_row_value v where v.row_id = r.id and v.position = 1); + +insert into catering_row_value (row_id, position, value) +select r.id, 0, 'Two dozen, your choice' + from catering_row r + join catering_package p on p.id = r.package_id + where p.name = 'Gatherings' and r.label = 'Cupcakes or sugar cookies' + and not exists (select 1 from catering_row_value v where v.row_id = r.id and v.position = 0); + +insert into catering_row_value (row_id, position, value) +select r.id, 1, 'Two dozen, your choice' + from catering_row r + join catering_package p on p.id = r.package_id + where p.name = 'Gatherings' and r.label = 'Cupcakes or sugar cookies' + and not exists (select 1 from catering_row_value v where v.row_id = r.id and v.position = 1); + +insert into catering_package_note (package_id, position, body) +select p.id, 0, 'Bars travel better than cake, so they are what we suggest for anything outdoors.' + from catering_package p + where p.name = 'Gatherings' + and not exists (select 1 from catering_package_note n where n.package_id = p.id and n.position = 0); + +-- --- the page's own terms ---------------------------------------------------------------------------- +-- +-- Delivery has never been stated anywhere on this page for anything but weddings, and for a box bought +-- FOR an office that is close to the whole product. The $40 floor is there because the hour of labour is +-- charged per order rather than per box: a single Small collected returns $4.00 for that hour, while two +-- on one order return $23.00. + +insert into catering_note (body, position, created_at) +select 'Delivery is $10 in Princeville and $30 anywhere else in Peoria County, on orders of $40 or more. We do not deliver outside the county, but you are always welcome to collect.', 3, now() + where not exists (select 1 from catering_note where position = 3); diff --git a/src/test/java/com/itsthevine/web/CateringMenuTest.java b/src/test/java/com/itsthevine/web/CateringMenuTest.java index 27f929b..f3fe772 100644 --- a/src/test/java/com/itsthevine/web/CateringMenuTest.java +++ b/src/test/java/com/itsthevine/web/CateringMenuTest.java @@ -62,7 +62,7 @@ class CateringMenuTest { List tables = catering.menu().packages(); assertThat(tables).extracting(CateringMenu.PackageView::name) - .containsExactly("Office boxes", "Parties", "Weddings"); + .containsExactly("Office boxes", "Parties", "Gatherings", "Weddings"); CateringMenu.PackageView office = tables.get(0); assertThat(office.tiers()).extracting(CateringMenu.TierView::label) @@ -82,12 +82,17 @@ class CateringMenuTest { assertThat(office.blurb()).contains("morning meeting"); assertThat(office.notes()).anySatisfy(note -> assertThat(note).contains("Baked in sixes")); + // V8 relabelled the middle column to the 27 it actually feeds, and put the entry price at $57 — + // still a loss leader, but a deliberate one rather than an accident of the spreadsheet. assertThat(tables.get(1).tiers()).extracting(CateringMenu.TierView::label) - .containsExactly("15–20 people", "20–30 people", "30–40 people"); - assertThat(tables.get(2).tiers()).extracting(CateringMenu.TierView::price) - .containsExactly("$236", "$310", "$386"); + .containsExactly("15–20 people", "20–27 people", "30–40 people"); + assertThat(tables.get(1).tiers()).extracting(CateringMenu.TierView::price) + .containsExactly("$57", "$76", "$98"); + assertThat(tables.get(3).tiers()).extracting(CateringMenu.TierView::price) + .containsExactly("$298", "$372", "$463"); // Wedding delivery terms belong to the wedding table, not to the page. - assertThat(tables.get(2).notes()).anySatisfy(note -> assertThat(note).contains("delivery fee")); + assertThat(tables.get(3).notes()).anySatisfy(note -> assertThat(note).contains("$30 elsewhere")); + assertThat(tables.get(3).notes()).anySatisfy(note -> assertThat(note).contains("$50 deposit")); // No cell anywhere still speaks in shorthand. assertThat(tables).allSatisfy(table -> assertThat(table.rows()).allSatisfy(row -> assertThat(row.values()).noneMatch(value -> value.matches(".*\\b(dz|B&G|in cake)\\b.*")))); @@ -99,13 +104,16 @@ class CateringMenuTest { // over literally, which left two lines with no quantity in any column: a row of dashes on the // page. V5 reads it as a baker would (a 12x17 pan is the sheet pan; "cc or sc" is what the dozens // count) and the guards in it mean an edit made in the admin since would have survived instead. - CateringMenu.PackageView weddings = catering.menu().packages().get(2); + CateringMenu.PackageView weddings = catering.menu().packages().get(3); assertThat(weddings.rows()).extracting(CateringMenu.RowView::label) .containsExactly("Bride & groom cake", "Sheet cakes or 12×17 bars", "Cupcakes or sugar cookies"); - assertThat(weddings.rows().get(1).values()).containsExactly("Two pans", "Three pans", "Four pans"); - assertThat(weddings.rows().get(2).values()).containsExactly("Four dozen", "Five dozen", "Six dozen"); + // V8 added a pan to the two larger columns and a dozen to the smallest. A pan cuts 48, and the + // bride & groom cake is ceremonial, so before that the columns held 144/204/264 servings against + // the 156/250/313 that 1.25 a guest asks for — every one of them short of its own head count. + assertThat(weddings.rows().get(1).values()).containsExactly("Two pans", "Four pans", "Five pans"); + assertThat(weddings.rows().get(2).values()).containsExactly("Five dozen", "Five dozen", "Six dozen"); // Nothing left that is blank the whole way across. assertThat(weddings.rows()) .noneMatch(row -> row.values().stream().allMatch(String::isEmpty)); @@ -120,10 +128,37 @@ class CateringMenuTest { .containsExactly("Cake", "Cupcakes or sugar cookies"); assertThat(parties.rows().get(0).values()) .containsExactly("A 6-inch cake", "An 8-inch cake", "A 10-inch cake"); + // The smallest column gained half a dozen in V8: it was labelled for twenty and held eighteen + // servings. Its cupcake count now matches the column above it, and only the cake size differs. + assertThat(parties.rows().get(1).values()) + .containsExactly("Eighteen, your choice", "Eighteen, your choice", "Two dozen, your choice"); assertThat(parties.rows()) .noneMatch(row -> row.values().stream().allMatch(String::isEmpty)); } + @Test + void theGatheringsTableFillsTheGapBetweenAPartyAndAWedding() { + // The page went from "30–40 people" straight to "125" and had nothing for a sixty-person + // graduation. V8 sizes this one from the pan rather than guessing: a pan cuts 48, so 72 and 120 + // servings are 57.6 and 96 people at 1.25 a head. The labels round DOWN — with a number nobody + // has been promised before, under is the safe direction. + CateringMenu.PackageView gatherings = catering.menu().packages().get(2); + + assertThat(gatherings.name()).isEqualTo("Gatherings"); + assertThat(gatherings.tiers()).extracting(CateringMenu.TierView::label) + .containsExactly("About 55 people", "About 95 people"); + assertThat(gatherings.tiers()).extracting(CateringMenu.TierView::price) + .containsExactly("$110", "$170"); + // The column already says the head count, so `serves` would only repeat it. + assertThat(gatherings.tiers()).extracting(CateringMenu.TierView::serves).containsOnlyNulls(); + // The wedding table's shape without the bride & groom cake. + assertThat(gatherings.rows()).extracting(CateringMenu.RowView::label) + .containsExactly("Sheet cakes or 12×17 bars", "Cupcakes or sugar cookies"); + assertThat(gatherings.rows().get(0).values()).containsExactly("One pan", "Two pans"); + assertThat(gatherings.rows().get(1).values()) + .containsExactly("Two dozen, your choice", "Two dozen, your choice"); + } + @Test void everyLineCarriesOneEntryPerColumn() { // The invariant the whole aggregate exists to hold: if these ever fall out of step, a box is @@ -150,8 +185,12 @@ class CateringMenuTest { @Test void statesThePageWideTermsSeparatelyFromAnyOneTable() { - assertThat(catering.menu().notes()).hasSize(2); + assertThat(catering.menu().notes()).hasSize(3); assertThat(catering.menu().notes().get(0)).contains("price may change"); + // Delivery belongs to the page rather than any one table: it was stated only on the weddings + // before V8, and a box bought FOR an office is exactly the case that needed it. + assertThat(catering.menu().notes()).anySatisfy(note -> + assertThat(note).contains("$10 in Princeville").contains("Peoria County")); } @Test @@ -240,7 +279,7 @@ class CateringMenuTest { CateringMenu.PackageView fresh = catering.add("Holiday boxes"); assertThat(catering.everything().packages()).extracting(CateringMenu.PackageView::name) - .containsExactly("Office boxes", "Parties", "Weddings", "Holiday boxes"); + .containsExactly("Office boxes", "Parties", "Gatherings", "Weddings", "Holiday boxes"); assertThat(catering.menu().packages()).extracting(CateringMenu.PackageView::name) .doesNotContain("Holiday boxes"); @@ -255,7 +294,7 @@ class CateringMenuTest { @Test void deletingATableTakesItsColumnsLinesAndCellsWithIt() { - CateringMenu.PackageView weddings = catering.everything().packages().get(2); + CateringMenu.PackageView weddings = catering.everything().packages().get(3); catering.remove(weddings.id()); // Flushed on purpose: a delete that leaves its children behind fails against the real foreign @@ -264,20 +303,22 @@ class CateringMenuTest { entityManager.clear(); assertThat(catering.everything().packages()).extracting(CateringMenu.PackageView::name) - .containsExactly("Office boxes", "Parties"); + .containsExactly("Office boxes", "Parties", "Gatherings"); // The page's own terms outlive any one table. - assertThat(catering.menu().notes()).hasSize(2); + assertThat(catering.menu().notes()).hasSize(3); } @Test void putsTheTablesWhereTheEditorLeftThem() { List tables = catering.everything().packages(); - List weddingsFirst = List.of(tables.get(2).id(), tables.get(0).id(), tables.get(1).id()); + List weddingsFirst = List.of(tables.get(3).id(), tables.get(0).id(), tables.get(1).id()); + // Gatherings is deliberately left out of the list: anything omitted keeps its relative place + // after the ones that were named. assertThat(catering.reorder(weddingsFirst)).extracting(CateringMenu.PackageView::name) - .containsExactly("Weddings", "Office boxes", "Parties"); + .containsExactly("Weddings", "Office boxes", "Parties", "Gatherings"); assertThat(catering.menu().packages()).extracting(CateringMenu.PackageView::name) - .containsExactly("Weddings", "Office boxes", "Parties"); + .containsExactly("Weddings", "Office boxes", "Parties", "Gatherings"); } @Test diff --git a/src/test/java/com/itsthevine/web/SiteControllerTest.java b/src/test/java/com/itsthevine/web/SiteControllerTest.java index e958d0e..1ed50bd 100644 --- a/src/test/java/com/itsthevine/web/SiteControllerTest.java +++ b/src/test/java/com/itsthevine/web/SiteControllerTest.java @@ -103,9 +103,12 @@ class SiteControllerTest { mvc.perform(get("/catering")).andExpect(status().isOk()) .andExpect(content().string(containsString("Office boxes"))) .andExpect(content().string(containsString("Weddings"))) + // V8's new table, filling the gap between a forty-person party and a wedding. + .andExpect(content().string(containsString("Gatherings"))) // Prices as the server writes them — the page never formats money. .andExpect(content().string(containsString("$24"))) - .andExpect(content().string(containsString("$236"))) + .andExpect(content().string(containsString("$298"))) + .andExpect(content().string(containsString("$110"))) // A cell and a note, in the wording a customer reads rather than the spreadsheet's. // The office box is a total mixed in sixes, not three separate things. .andExpect(content().string(containsString("Any mix of mini muffins"))) -- 2.54.0