BF stocks, A test
This commit is contained in:
parent
99fd71f4ae
commit
2bc64c6369
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 1–16, 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 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')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue