diff --git a/src/Traits/HasStocks.php b/src/Traits/HasStocks.php index 4d54af8..b79adf5 100644 --- a/src/Traits/HasStocks.php +++ b/src/Traits/HasStocks.php @@ -402,7 +402,13 @@ trait HasStocks $date = $date ?? now(); - // Base stock: all COMPLETED entries except CLAIMED, filtered using the provided date + // Base stock: all COMPLETED entries except CLAIMED, filtered using + // the provided date. This intentionally does NOT gate by the ledger + // row's created_at — callers like {@see ProductStock::claim} pass a + // booking-window start (which can predate the seed row by seconds) + // and rightly expect the current physical inventory back. For + // historical "as of date X, ignoring later changes" queries, use + // {@see self::availableOnDate()} instead. $baseStock = $this->stocks() ->withoutGlobalScope('willExpire') ->where('status', StockStatus::COMPLETED->value) @@ -667,7 +673,41 @@ trait HasStocks return PHP_INT_MAX; } - return $this->getAvailableStock($date); + // Historically-aware variant of {@see self::getAvailableStock()}: + // ledger rows created AFTER $date are excluded, so a DECREASE placed + // today doesn't retroactively reduce availability on a prior day. + // Day-level comparison (against end of $date's day) so a row seeded + // mid-day still counts for queries on that same day. + $dateDayEnd = Carbon::instance($date)->copy()->endOfDay(); + + $baseStock = $this->stocks() + ->withoutGlobalScope('willExpire') + ->where('status', StockStatus::COMPLETED->value) + ->where('type', '!=', StockType::CLAIMED->value) + ->where('created_at', '<=', $dateDayEnd) + ->where(function ($query) use ($date) { + $query->whereNull('expires_at') + ->orWhere('expires_at', '>', $date); + }) + ->sum('quantity'); + + // Add back claims that should not reduce availability at the given date. + $inactiveClaims = $this->stocks() + ->withoutGlobalScope('willExpire') + ->where('type', StockType::CLAIMED->value) + ->where('status', StockStatus::PENDING->value) + ->where(function ($query) use ($date) { + $query->where(function ($q) use ($date) { + $q->whereNotNull('claimed_from') + ->where('claimed_from', '>', $date); + })->orWhere(function ($q) use ($date) { + $q->whereNotNull('expires_at') + ->where('expires_at', '<=', $date); + }); + }) + ->sum('quantity'); + + return max(0, $baseStock + $inactiveClaims); } /** @@ -734,6 +774,14 @@ trait HasStocks if ($stock->expires_at && $stock->expires_at->between($dayStart, $dayEnd)) { $events[] = Carbon::parse($stock->expires_at); } + // The moment a COMPLETED entry becomes effective is itself a + // transition point — without sampling here, a DECREASE at + // 13:32 followed by an INCREASE at 17:00 (or vice versa) + // would be invisible to min/max if we only inspected day + // boundaries. + if ($stock->created_at && $stock->created_at->between($dayStart, $dayEnd)) { + $events[] = Carbon::parse($stock->created_at); + } } // Remove exact duplicates @@ -743,11 +791,21 @@ trait HasStocks $dayMax = PHP_INT_MIN; // Check availability at each event timestamp to find min/max for the day + $eventDayEnd = $dayEnd->copy(); foreach ($events as $eventTime) { $available = 0; foreach ($allStocks as $stock) { if ($stock->status === StockStatus::COMPLETED && $stock->type !== StockType::CLAIMED) { - if (is_null($stock->expires_at) || $stock->expires_at > $eventTime) { + // A COMPLETED entry only contributes from the day it + // was created — without this gate, a DECREASE from a + // loan placed today would retroactively reduce + // availability on every prior day in the grid. + // Compared at day granularity (against end-of-day of + // the rendered day) so a stock seeded mid-day still + // contributes for every event in that same day. + $hasStarted = $stock->created_at === null || $stock->created_at <= $eventDayEnd; + $notExpired = is_null($stock->expires_at) || $stock->expires_at > $eventTime; + if ($hasStarted && $notExpired) { $available += $stock->quantity; } } elseif ($stock->status === StockStatus::PENDING && $stock->type === StockType::CLAIMED) { diff --git a/tests/Feature/Product/HistoricalAvailabilityTest.php b/tests/Feature/Product/HistoricalAvailabilityTest.php new file mode 100644 index 0000000..2d0d317 --- /dev/null +++ b/tests/Feature/Product/HistoricalAvailabilityTest.php @@ -0,0 +1,201 @@ + 'Hyperion', + 'sku' => 'HYP-HIST', + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'is_visible' => true, + 'manage_stock' => true, + ]); + } + + #[Test] + public function decrease_today_does_not_retroactively_reduce_yesterday(): void + { + // Day 1: seed inventory. Day 10: loan one copy. + Carbon::setTestNow(Carbon::parse('2026-05-01 09:00:00')); + $product = $this->newProduct(); + $product->increaseStock(5); + + Carbon::setTestNow(Carbon::parse('2026-05-10 13:32:00')); + $product->decreaseStock(1); + + // Availability on day 5 (before the loan): still 5 copies. + $this->assertSame( + 5, + $product->availableOnDate(Carbon::parse('2026-05-05 12:00:00')), + 'past dates must not be affected by a later DECREASE', + ); + + // Availability on day 10 after the loan happened: 4. + $this->assertSame( + 4, + $product->availableOnDate(Carbon::parse('2026-05-10 14:00:00')), + 'present-day availability reflects the loan', + ); + + // Availability on day 20 (no further activity): still 4. + $this->assertSame( + 4, + $product->availableOnDate(Carbon::parse('2026-05-20 09:00:00')), + ); + } + + #[Test] + public function increase_today_does_not_retroactively_inflate_yesterday(): void + { + // Symmetric guard: stock added today shouldn't make yesterday richer. + Carbon::setTestNow(Carbon::parse('2026-05-01 09:00:00')); + $product = $this->newProduct(); + $product->increaseStock(2); + + Carbon::setTestNow(Carbon::parse('2026-05-15 10:00:00')); + $product->increaseStock(3); // restock — three more copies arrived + + $this->assertSame( + 2, + $product->availableOnDate(Carbon::parse('2026-05-10 12:00:00')), + 'past dates only see the initial 2 copies', + ); + $this->assertSame( + 5, + $product->availableOnDate(Carbon::parse('2026-05-20 12:00:00')), + 'after the restock, total is 5', + ); + } + + #[Test] + public function calendar_availability_reflects_the_actual_timeline(): void + { + // This is the user's reported scenario almost verbatim. The library + // had its 5 copies long before the queried month, so we seed in April + // — otherwise the INCREASE row's created_at would itself split day 1 + // (legitimately) and complicate the assertions. + Carbon::setTestNow(Carbon::parse('2026-04-15 09:00:00')); + $product = $this->newProduct(); + $product->increaseStock(5); + + // Loan on May 17 13:32 — exactly as the bug report described. + Carbon::setTestNow(Carbon::parse('2026-05-17 13:32:00')); + $product->decreaseStock(1); + + $calendar = $product->calendarAvailability( + Carbon::parse('2026-05-01 00:00:00'), + Carbon::parse('2026-05-31 23:59:59'), + ); + + $dates = $calendar['dates']; + + // Days 1–16 (before the loan): full availability. + foreach (['2026-05-01', '2026-05-10', '2026-05-16'] as $day) { + $this->assertSame( + ['min' => 5, 'max' => 5], + $dates[$day], + "calendar must show 5 available on {$day} (before the loan)", + ); + } + + // Day 17 (the loan day itself): the calendar uses day-level + // granularity for the created_at gate, so a stock change anywhere + // during the day counts at every event on that day. Result: 4 across + // the board, even though the actual transition happened at 13:32. + // This is a deliberate trade-off — instant-level granularity created + // test-fixture chaos for any seed-then-query-now pattern, since the + // calendar's day boundaries always landed before the seed timestamp. + $this->assertSame( + ['min' => 4, 'max' => 4], + $dates['2026-05-17'], + ); + + // Days 18–31 (loan still out, nothing returned): 4 available. + foreach (['2026-05-18', '2026-05-25', '2026-05-31'] as $day) { + $this->assertSame( + ['min' => 4, 'max' => 4], + $dates[$day], + "calendar must show 4 available on {$day} (loan in effect)", + ); + } + } + + #[Test] + public function calendar_summary_reports_the_correct_min_and_max(): void + { + // 5 copies seeded before the queried month, 1 loaned mid-month → + // calendar should report max_available=5 (some days), min_available=4 + // (after the loan). Before the fix, both would have been 4 because + // every day rendered 4-4. + Carbon::setTestNow(Carbon::parse('2026-04-15 09:00:00')); + $product = $this->newProduct(); + $product->increaseStock(5); + + Carbon::setTestNow(Carbon::parse('2026-05-17 13:32:00')); + $product->decreaseStock(1); + + $calendar = $product->calendarAvailability( + Carbon::parse('2026-05-01 00:00:00'), + Carbon::parse('2026-05-31 23:59:59'), + ); + + $this->assertSame(5, $calendar['max_available'], 'peak availability for the month is 5'); + $this->assertSame(4, $calendar['min_available'], 'trough availability is 4 after the loan'); + } + + #[Test] + public function available_on_date_in_the_future_anticipates_no_changes(): void + { + // Asking about a future date with no scheduled changes should return + // current availability — the loan persists, so 4 stays 4. + Carbon::setTestNow(Carbon::parse('2026-05-17 13:32:00')); + $product = $this->newProduct(); + $product->increaseStock(5); + $product->decreaseStock(1); + + $this->assertSame( + 4, + $product->availableOnDate(Carbon::parse('2026-06-15 12:00:00')), + ); + } +}