BF stocks, A test

This commit is contained in:
Fabian @ Blax Software 2026-05-17 13:57:20 +02:00
parent 99fd71f4ae
commit 2bc64c6369
2 changed files with 262 additions and 3 deletions

View File

@ -402,7 +402,13 @@ trait HasStocks
$date = $date ?? now(); $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() $baseStock = $this->stocks()
->withoutGlobalScope('willExpire') ->withoutGlobalScope('willExpire')
->where('status', StockStatus::COMPLETED->value) ->where('status', StockStatus::COMPLETED->value)
@ -667,7 +673,41 @@ trait HasStocks
return PHP_INT_MAX; 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)) { if ($stock->expires_at && $stock->expires_at->between($dayStart, $dayEnd)) {
$events[] = Carbon::parse($stock->expires_at); $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 // Remove exact duplicates
@ -743,11 +791,21 @@ trait HasStocks
$dayMax = PHP_INT_MIN; $dayMax = PHP_INT_MIN;
// Check availability at each event timestamp to find min/max for the day // Check availability at each event timestamp to find min/max for the day
$eventDayEnd = $dayEnd->copy();
foreach ($events as $eventTime) { foreach ($events as $eventTime) {
$available = 0; $available = 0;
foreach ($allStocks as $stock) { foreach ($allStocks as $stock) {
if ($stock->status === StockStatus::COMPLETED && $stock->type !== StockType::CLAIMED) { 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; $available += $stock->quantity;
} }
} elseif ($stock->status === StockStatus::PENDING && $stock->type === StockType::CLAIMED) { } elseif ($stock->status === StockStatus::PENDING && $stock->type === StockType::CLAIMED) {

View File

@ -0,0 +1,201 @@
<?php
namespace Blax\Shop\Tests\Feature\Product;
use Blax\Shop\Enums\ProductStatus;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Product;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use PHPUnit\Framework\Attributes\Test;
/**
* Regression coverage for the bug where stock ledger entries with no
* `expires_at` were treated as if they had always existed so a DECREASE
* row created today retroactively reduced availability for every prior day.
*
* Real-world reproducer (from a librarian's report):
* 1. Book seeded with 5 copies at the start of the month
* 2. A member loans one copy on day 17
* 3. `shop:stocks:availability` for May rendered 4 available for ALL 31
* days including May 116, which is wrong because the loan didn't
* exist yet on those days.
*
* The fix scopes COMPLETED stock entries to their `created_at` boundary in
* the historical-query paths ({@see HasStocks::calendarAvailability} and
* {@see HasStocks::availableOnDate}): an entry only contributes to
* availability on date $X if it was persisted on or before $X. The
* day-level comparison (against end-of-day of $X) means a row seeded
* mid-day still counts for queries on that same day.
*
* {@see HasStocks::getAvailableStock} is intentionally NOT gated it's
* the "current snapshot" API and is called by {@see ProductStock::claim}
* with future/past booking dates, where the caller wants the live
* physical inventory. CLAIMED entries already model both ends of their
* window via `claimed_from` / `expires_at`, so this fix only touches the
* INCREASE/DECREASE/RETURN code path.
*/
class HistoricalAvailabilityTest extends TestCase
{
use RefreshDatabase;
private function newProduct(): Product
{
return Product::create([
'name' => '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 116 (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 1831 (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')),
);
}
}