Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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::AutoExtendInventoryMainWorker runs at 02:30 Asia/Bangkok, dispatching one ScheduleWorkers::AutoExtendInventorySubWorker.perform_async(restaurant_id) per restaurant in the with_auto_extend_inventories scope (not_expired.bookable).
  • What the sub-worker does (#perform, one pass per restaurant, covers dine-in + delivery): computes expected_dates = (today..end_date) where end_date = min(today + days_in_advance, expiry_date); finds all missing dates in that window; generates each only if an UpdatedInventory template (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 not bookable_and_not_expired?.
  • Therefore a missing future date within [today, today+days_in_advance] means one of three states:
    1. Covered & self-healing — restaurant in scope AND a template covers the date → the nightly job will fill it (or already should have).
    2. No template — restaurant in scope BUT no UpdatedInventory template covers the date → the job runs but silently generates nothing for it → real problem (needs a template/allotment).
    3. 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_LOGGER lines; RestaurantInfo has 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:

  1. Classify it into the three states above (self-healing vs the two real problems) instead of a flat “config problem”.
  2. 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).
  3. 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_inventories includes this restaurant (i.e. not_expired.bookable).
  • Template covers this date? an UpdatedInventory row exists for the restaurant + service_type with start_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_inventoryAutoExtendInventorySubWorker.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_cache action).

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#perform at 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 StandardError path (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 processing at start and done/failed at 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

  1. A missing future date in scope + template-covered shows the ✅ self-healing state (NOT a red config error), naming the last run + horizon.
  2. A missing date in scope, no template shows the ⚠️ “no template” real-problem state.
  3. A missing date on an out-of-scope restaurant shows the ⚠️ “not in auto-extend scope” state with the reason (expired / booking off).
  4. The debugging panel shows expiry date, package end dates, auto-extend horizon, inventory horizon, and last-run (when + outcome).
  5. “Generate now” enqueues AutoExtendInventorySubWorker, shows the live generating→done banner, and afterward the page reflects newly-generated dates.
  6. RestaurantInfo.last_auto_extend_at/_outcome are written by the worker on success, each skip branch, and failure — verified by spec.
  7. Tracking + status writes are additive and failure-isolated: no regression to the nightly generation (dates generated, retry behavior, idempotency).
  8. 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_inventories scope, or UpdatedInventory template 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_extend semantics.

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 RestaurantInfo columns — 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_inventoryRestaurant.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 (mirror refresh_inventory_cache)