PRD — Bookability Diagnosis for Admin Inventories Page
Status: Approved for build (grilled & aligned)
Branch: perf/hhserver-vendor-availability-major
Tracking: EPIC: Bookability root-cause diagnosis #8316 - tracked on PR #8274
Owner: Product team lead (this repo)
Page: GET /admin/restaurants/:id/inventories
1. Problem
Almost every week, internal CS/ops staff send engineering the same two questions:
- “Why is this restaurant not bookable?”
- “Why is this restaurant_package not bookable?”
They ask after they believe they configured the data correctly. The answer is almost always one of two root causes:
- (A) Cache bug — the find-available-date/time cache disagrees with the underlying DB truth. The system should say bookable but the cached payload says otherwise.
- (B) Data-entry mistake — a field across the package object graph is actually wrong (inactive, wrong dates, wrong visibility, missing pricing, broken agenda, etc.), but the person who entered it believes it is correct.
The current /admin/restaurants/:id/inventories page is a developer debug dump (raw
JSON.pretty_generate, Redis cache keys, rand() in the view). It surfaces the leaves
of the availability computation without the decision tree, so CS cannot self-diagnose
and must escalate to engineering.
2. Goal
Turn the page into a root-cause attribution tool: for a given restaurant (and package, date, party size, time), tell CS in plain language whether the problem is a cache bug or a config mistake — and if config, exactly which field in which record is wrong, with a direct edit link so they can fix it themselves.
Success = the weekly “why not bookable?” tickets are answered by CS on this page instead of escalated to engineering.
3. Primary user & entry point
- Primary user: CS / CS-ops staff with admin access (semi-technical).
- Entry point: They work from the restaurants list (
/admin/restaurants) daily; each row links to that restaurant’s inventories page. They arrive with a specific restaurant in mind and a specific complaint (“I set up package X, it’s still not bookable on date D for N people”). - Secondary user: Engineers, who use the
?advanced=1raw output for the long tail.
4. Root-cause model (the core of the feature)
The codebase exposes both a fresh/DB-truth compute and cached reads:
calc_find_available_date(date, slug)— fresh truth, recomputed from the DB. Its helper_date_inventoriesis explicitly never cached (“causing data inconsistency”). This is the parity target / source of truth.find_available_date(date, slug)— legacy cached read (computes only on miss).VendorAvailability::ReadService/BulkPrecomputer— the new Redis-precompute cache path added on this branch.
Attribution algorithm:
fresh = calc_find_available_date(date, slug) # DB truth
cached = find_available_date(date, slug) # legacy cache
cached2 = ReadService payload for (date, slug) # new cache
if fresh.bookable? and (cached not bookable OR cached2 not bookable):
=> CACHE BUG (offer "refresh cache" / force recompute; flag which cache diverged)
elif not fresh.bookable?:
=> CONFIG MISTAKE (walk the gating-field checklist; flag the wrong field(s) + edit link)
else:
=> BOOKABLE (all green)
Parity is defined against calc_find_available_date (fresh). The caches have no
independent gating fields — they merely store the fresh result — so they are diagnosed
by comparison against fresh, not by independent field-parity. Both the legacy cache
and the new ReadService cache are covered by the divergence check.
5. The 7 CS questions, mapped
| # | Question | Resolved by |
|---|---|---|
| Q1 | Is the restaurant bookable at all? | restaurant-level gates: active, allow_booking/allow_booking_without_package?, expiry_date, has ≥1 valid package |
| Q2 | Which dates is it bookable? | per-date calc_find_available_date scan, party-filtered → green |
| Q3 | Which dates is it NOT bookable? | same scan → red, each tagged with first failing tier/field |
| Q4 | Why not bookable (restaurant level)? | cache-vs-config attribution + gating checklist |
| Q5 | Which packages bookable on date D? | per-package three-tier evaluation on D |
| Q6 | Why a package not bookable on D? | package object-graph field checklist (see §6) |
| Q7 | Why a package not bookable on D at time T? | slot/time tier: seat_lefts[T], duration fit, agenda open at T |
6. Exhaustive field parity (acceptance-critical)
Bar: exhaustive parity before ship. The diagnosis must mirror every field the fresh availability algorithm reads, derived from the code path (not hand-guessed), across the full object graph the feature reads from:
restaurant_package(attributes):active,active_for_third_party_only,is_visible,is_visible_for_staff,deleted_at,start_date,end_date,slugrestaurant_package.packagerestaurant_package.package.package_attr:min_seat,max_seat,minimum_booking_time_in_advancerestaurant_package.package.agendas:start_time,end_time,mon..sun,all_day, single/exception occurrences — clipped to packagestart_date/end_daterestaurant_package.package.pricings: missing/empty pricing ⇒ NOT bookable (gating)restaurant_package.restaurant:active,allow_booking,expiry_date,days_in_advance,min_party_size,largest_table,time_zone,res_durationrestaurant_package.restaurant.inventories:quantity_available,total_booked_seat,start_time,date- (possibly more — the engineer must trace the algorithm to confirm completeness)
The engineer traces calc_find_available_date → res_durations_is_enough? →
seat_lefts → package_bookable_implementation → agenda occurrence generation →
pricing and enumerates every field each step reads. The curated checklist is the
output of that trace, owned as an explicit, versioned constant.
Three gating tiers
- Date tier —
calc_find_available_date: every field it reads must, when broken, flip the date payload and be flagged by the diagnosis. - Party tier —
min_seat/max_seatbounds. Party size is a required input (defaulted to 2, always overridable) so CS reproduces “customer wanted N”. - Time tier —
seat_lefts[start_time], duration fit, agenda-open-at-T: the slot breakdown flags the specific failing time.
7. Must show the misconfigured records
The page must load packages unfiltered — all_restaurant_packages without
valid_to_have_agendas — because the most common config mistake (inactive / soft-deleted
/ expired / hidden package) is exactly what that scope filters out. Excluded packages
are rendered as the diagnosis (“🔴 Inactive — [toggle]”, “🔴 Ended 15 Jun — before the
date you checked”, “🔴 Deleted”), grouped separately from live packages so CS isn’t
confused. Hard-deleted (row gone) is an accepted gap.
8. UX principles
- Answer-first: lead with a plain-language verdict (Bookable / Not bookable + cache-or-config + reason), drill-down second.
- Self-service fix: config problems show expected-vs-actual per field with an edit link to the restaurant/package.
- Cache problems offer a refresh/force-recompute action and name which cache diverged.
- Party + date + (optional) time inputs drive the whole page.
?advanced=1preserves the raw service output / cache keys for engineers.- Read-only by default (except the existing supplier force-update and cache refresh).
9. Acceptance criteria
- For a restaurant, CS sees Q1 verdict (bookable at all?) with reason, no JSON.
- Calendar colors each of the next N days bookable/not-bookable for the entered party size; clicking a day gives the reason.
- For a not-bookable date, the page states cache bug vs config mistake correctly, proven by the fresh-vs-cache comparison.
- For a config mistake, the exact failing field(s) across the object graph are flagged with actual value + edit link.
- Misconfigured/excluded packages (inactive/hidden/deleted/expired/no-pricing) appear with their reason — never a blank list.
- Party tier: a date green at party=2 but red at party=6 is shown correctly per party.
- Time tier: per-slot breakdown flags the specific failing time (sold out / duration / agenda).
- Parity test passes: for every enumerated gating field, flipping it to a bad value
makes (a) real
calc_find_available_date-based availability flip to false and (b) the diagnosis flag that exact field. A field that can break availability but stays green in the diagnosis is a release blocker. - No regression to existing inventories behavior (supplier sync, third-party sources).
- Page is read-only except sanctioned actions; no N+1 explosion (see §11).
10. Non-goals (v1)
- No changes to the availability algorithm itself.
- No changes to auth / who can access admin.
- No editing of inventory rows inline (link out to existing edit screens).
- No customer-facing changes.
- Hard-deleted package recovery.
11. Risks
- Performance: per-date × party × slot probing can N+1. Must batch (reuse
seat_lefts/find_durationsbulk methods, cap calendar window, lazy per-day reason). - Drift: the gating-field list must stay in parity with the algorithm — enforced by the QA parity test, owned as a versioned constant.
- Unfiltered package query: larger result set; must stay scoped to one restaurant.
- Cache mutation: “refresh cache” writes Redis — must be explicit, audited, safe.
12. Source of truth (file map)
- Fresh compute:
app/services/inventory/inv_checker_hungry_hub_service/find_available_dates.rb(calc_find_available_date,find_available_date,set_find_available_date) - New cache path:
app/services/vendor_availability/{read_service,bulk_precomputer}.rb - Boolean checks + reasons:
app/services/inventory/inv_checker_hungry_hub_service.rb(bookable?,package_bookable?,check_unavailability_reason,error_message) - Models:
app/models/restaurant.rb,app/models/hh_package/restaurant_package.rb,lib/model_ext/restaurants/instance_methods.rb - Controller/views:
app/controllers/admin/restaurants_controller.rb#inventories,app/views/admin/restaurants/inventories/* - Draft scaffolding (reuse only where it survives):
app/presenters/admin/bookability_presenter.rb