PRD — Missing-Inventory / Auto-Extend Diagnosis, Tracking & On-Demand Trigger
Status: Drafted from grilling; ready for review
Branch: continues perf/hhserver-vendor-availability-major
Tracking: EPIC: Missing-inventory / auto-extend diagnosis #8318 - tracked on PR #8274
Audience: HungryHub Engineering + CS-Ops
Relationship to prior work: Extends the bookability diagnosis
(docs/PRD-bookability-diagnosis.md) and reuses the cache-warming indicator’s
Availability::WarmStatus + polling-banner pattern
(docs/PRD-availability-cache-warming-indicator.md).
1. Problem
On /admin/restaurants/:id/inventories, future dates show “Not bookable — No
inventory (allotment) rows exist for this date.” The current diagnosis treats
this flatly as a config problem. But a missing future date is usually not a
misconfiguration — it is a date the nightly inventory-generation job hasn’t
filled yet, and it will self-heal on the next run. Occasionally it is a real
problem. Today CS/ops can’t tell which, and there is no data to debug with
(the job records nothing durable about when it last ran or what it did).
2. Grounded reality (the actual generation mechanism)
- Nightly job:
ScheduleWorkers::AutoExtendInventoryMainWorkerruns at 02:30 Asia/Bangkok, dispatching oneScheduleWorkers::AutoExtendInventorySubWorker.perform_async(restaurant_id)per restaurant in thewith_auto_extend_inventoriesscope (not_expired.bookable). - What the sub-worker does (
#perform, one pass per restaurant, covers dine-in + delivery): computesexpected_dates = (today..end_date)whereend_date = min(today + days_in_advance, expiry_date); finds all missing dates in that window; generates each only if anUpdatedInventorytemplate (start_date <= date <= end_date, matching service_type) covers it. It is idempotent and non-destructive (only inserts missing keys). It skips restaurants that are inactive & not bookable, or notbookable_and_not_expired?. - Therefore a missing future date within
[today, today+days_in_advance]means one of three states:- Covered & self-healing — restaurant in scope AND a template covers the date → the nightly job will fill it (or already should have).
- No template — restaurant in scope BUT no
UpdatedInventorytemplate covers the date → the job runs but silently generates nothing for it → real problem (needs a template/allotment). - Out of scope — restaurant expired or booking off → won’t auto-generate at all → real problem (or intentional).
- No last-run tracking exists. The worker writes only
BUSINESS_LOGGERlines;RestaurantInfohas no timestamp. So “did the job run / when / with what outcome” is unknowable today. - An on-demand generation primitive already exists: the owner dashboard’s
“Remake Inventory” button calls
Restaurant.generate_schedule(remake: true)— but that is a full remake (heavier, potentially destructive). We will NOT use it for the admin trigger (see §5.3).
3. Goal
When a date is not bookable because inventory is missing, the diagnosis must:
- Classify it into the three states above (self-healing vs the two real problems) instead of a flat “config problem”.
- Surface the debugging context ops asked for: restaurant expiry date, relevant package end dates, the current inventory horizon (max generated date), and the last auto-extend run (when + outcome).
- Offer a safe on-demand trigger (“Generate now”) so ops don’t have to wait for 02:30, with a live “generating… → done” state.
4. Primary user & surface
- Primary: CS-Ops / admin on the bookability diagnosis page.
- v1 surface: admin diagnosis page only. No customer-facing changes.
5. Solution
5.1 Three-state missing-inventory classification (replaces the flat reason)
Extend the inventory_present gating field / the presenter so that when
inventory is missing for a date, it computes:
- In auto-extend scope?
Restaurant.with_auto_extend_inventoriesincludes this restaurant (i.e.not_expired.bookable). - Template covers this date? an
UpdatedInventoryrow exists for the restaurant + service_type withstart_date <= date <= end_date.
and renders:
- ✅ “Inventory not generated yet — covered by auto-extend.” (in scope + template) — “The nightly job (last run: T, outcome: …) will generate this. If urgent, [Generate now].” This is not a red config error — a distinct, low-alarm state.
- ⚠️ “Won’t auto-generate: no inventory template covers this date.” (in scope,
no template) — points ops to set up an
UpdatedInventory/allotment template (link if available). - ⚠️ “Won’t auto-generate: restaurant not in auto-extend scope.” (out of
scope) — shows why: expired (
expiry_date) or booking off (allow_booking).
5.2 Debugging context panel
Near the missing-inventory verdict, show (read-only):
- Restaurant expiry_date, days_in_advance, computed auto-extend horizon
=
min(today + days_in_advance, expiry_date). - Current inventory horizon =
restaurant.inventories.maximum(:date)(computed live — “inventory exists through X”). - Relevant package end dates (the active packages’
end_dates). - Last auto-extend run:
last_auto_extend_at+last_auto_extend_outcome.
5.3 On-demand “Generate now” trigger (safe)
- Admin button →
POST /admin/restaurants/:id/generate_inventory→AutoExtendInventorySubWorker.perform_async(restaurant_id). - Why this worker, not
generate_schedule(remake: true): the sub-worker is the same idempotent, only-missing-dates, non-destructive logic the nightly job uses — so “Generate now” does exactly what CS expects (run tonight’s job now) without clobbering existing inventory or live bookings. - Admin-namespaced, POST, CSRF-protected, scoped to one restaurant (mirror the
existing
refresh_inventory_cacheaction).
5.4 Last-run tracking (durable, for debugging)
- Add columns to
RestaurantInfo:last_auto_extend_at(datetime),last_auto_extend_outcome(string — e.g.generated:N,already_complete,skipped:inactive,skipped:expired,failed). - Written by
AutoExtendInventorySubWorker#performat its natural hook points:- success (generated N / already complete),
- each skip branch (inactive-not-bookable, not-bookable-or-expired) — these explain why a restaurant never gets inventory, high debugging value,
- the
rescue StandardErrorpath (failed) before re-raise.
- Additive, failure-isolated — a tracking-write error must never break or change the worker’s generation/retry behavior (wrap + APM report).
5.5 Live “generating” state (reuse WarmStatus pattern)
- Reuse
Availability::WarmStatus(Redis status +stuck?/started_ago_seconds- polling banner) with an inventory-generation status keyed per restaurant.
- The sub-worker writes
processingat start anddone/failedat end (same hook as §5.4 — the last-run write and the status write are one concern). - Banner: “⏳ Generating inventory… (started Ns ago)” → “✓ Done — refresh” on completion, via the existing poller. The nightly run lights up the same status (a feature: CS sees “the nightly job is generating for this restaurant now”).
6. Acceptance criteria
- A missing future date in scope + template-covered shows the ✅ self-healing state (NOT a red config error), naming the last run + horizon.
- A missing date in scope, no template shows the ⚠️ “no template” real-problem state.
- A missing date on an out-of-scope restaurant shows the ⚠️ “not in auto-extend scope” state with the reason (expired / booking off).
- The debugging panel shows expiry date, package end dates, auto-extend horizon, inventory horizon, and last-run (when + outcome).
- “Generate now” enqueues
AutoExtendInventorySubWorker, shows the live generating→done banner, and afterward the page reflects newly-generated dates. RestaurantInfo.last_auto_extend_at/_outcomeare written by the worker on success, each skip branch, and failure — verified by spec.- Tracking + status writes are additive and failure-isolated: no regression to the nightly generation (dates generated, retry behavior, idempotency).
- The trigger is POST + CSRF + single-restaurant-scoped; read paths trigger no generation.
7. Non-goals (v1)
- No change to the generation algorithm, the
with_auto_extend_inventoriesscope, orUpdatedInventorytemplate logic. “No template covers this date” is surfaced, not auto-fixed (we don’t create templates). - Not reusing/altering
generate_schedule(remake: true)(heavier/destructive). - No customer-facing changes.
- No change to package-level
auto_extendsemantics.
8. Risks
- Worker hot path: last-run/status writes run inside the nightly per-restaurant worker (thousands of restaurants). Must be O(1), additive, failure-isolated — a Redis/DB hiccup can’t break generation.
- Migration: two new
RestaurantInfocolumns — small, nullable, no backfill needed (null = “never tracked / pre-feature”). - Trigger abuse / load: many “Generate now” clicks could enqueue duplicate
sub-workers; the worker is idempotent so it’s safe, but debounce via the
in-flight status (don’t enqueue if already
processing). - Skip-state honesty: a restaurant intentionally off (expired) should read as “expected/intentional,” not alarm — copy must distinguish “broken” from “off”.
9. Source-of-truth file map
- Nightly job:
app/workers/schedule_workers/auto_extend_inventory_main_worker.rb,auto_extend_inventory_sub_worker.rb(perform,find_missing_inventory_dates,determine_end_date,fetch_updated_inventories_for_date, skip branches) - Scope:
lib/model_ext/restaurants/scopes.rb(with_auto_extend_inventories) - Tracking home:
app/models/restaurant_info.rb(+ migration) - Existing remake primitive (NOT used here):
owner_dashboards#remake_inventory→Restaurant.generate_schedule(remake: true) - Diagnosis to extend:
app/presenters/admin/bookability_presenter.rb,app/presenters/admin/bookability/gating_fields.rb(inventory_present),field_context.rb - Status/banner reuse:
app/services/availability/warm_status.rb,app/views/admin/restaurants/inventories/_warm_banner.html.erb - Trigger endpoint:
app/controllers/admin/restaurants_controller.rb+config/routes.rb(mirrorrefresh_inventory_cache)