diff --git a/README.md b/README.md index 692f284..b8bd9ec 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ # Laravel Shop [![Tests](https://github.com/blax-software/laravel-shop/actions/workflows/tests.yml/badge.svg)](https://github.com/blax-software/laravel-shop/actions/workflows/tests.yml) -[![Tests Count](https://img.shields.io/badge/tests-1349%20passing-success?style=flat-square)](#testing) -[![Assertions](https://img.shields.io/badge/assertions-3641-blue?style=flat-square)](#testing) +[![Tests Count](https://img.shields.io/badge/tests-1376%20passing-success?style=flat-square)](#testing) +[![Assertions](https://img.shields.io/badge/assertions-3718-blue?style=flat-square)](#testing) [![Latest Version](https://img.shields.io/packagist/v/blax-software/laravel-shop.svg?style=flat-square)](https://packagist.org/packages/blax-software/laravel-shop) [![License](https://img.shields.io/packagist/l/blax-software/laravel-shop.svg?style=flat-square)](https://packagist.org/packages/blax-software/laravel-shop) [![PHP Version](https://img.shields.io/packagist/php-v/blax-software/laravel-shop.svg?style=flat-square)](https://packagist.org/packages/blax-software/laravel-shop) diff --git a/src/Models/Cart.php b/src/Models/Cart.php index b2626b8..549ce8e 100644 --- a/src/Models/Cart.php +++ b/src/Models/Cart.php @@ -1652,6 +1652,194 @@ class Cart extends Model ]; } + /** + * Customer-facing checkout-calendar payload for the cart. + * + * Wraps {@see self::calendarAvailability()} and adds everything the + * order-overview / date-picker UIs need in a single shot, so the + * frontend doesn't have to re-derive it from raw per-day maps: + * + * - `dates`: visible-window-only `iso => bool` bookable flag, which + * drives the calendar's per-day dots. + * - `items[]`: one entry per cart_item with duration-aware + * suggestions and a visible-window-scoped `dates_unavailable` list: + * - `available_for_selected` — every day of cart.from..cart.until + * has enough stock for this item's required quantity. + * - `closest_before` — END date of the latest duration-fitting + * bookable window strictly before cart.from. Reads naturally + * as "bookable UNTIL X". + * - `closest_after` — START date of the earliest duration-fitting + * bookable window strictly after cart.from. Walks from + * cart.from+1 (not cart.until+1) so a selection that overlaps + * a short outage still surfaces the FIRST recovery date, + * not "the next free duration past your chosen end". + * - `ever_available` — at least one day in the wide search window + * has stock for this item. Distinguishes "temporary date + * conflict" from "permanently sold out". + * - `dates_unavailable[]` — ISO dates within the visible window + * where this single item is blocked. The frontend unions these + * across items to paint red dots / hover tooltips. + * + * Performance: `$searchDays` bounds the closest-date search to a few + * months each side of the visible window. The default of 90 keeps the + * underlying per-product stock scan cheap; raise it for sparse + * calendars where bookings cluster far from the customer's selection. + * + * @return array{ + * dates: array, + * items: list, + * }>, + * } + */ + public function calendarAvailabilityHints( + ?\DateTimeInterface $from = null, + ?\DateTimeInterface $until = null, + int $searchDays = 90, + ): array { + $visibleStart = Carbon::parse($from ?? now())->startOfDay(); + $visibleEnd = Carbon::parse($until ?? $visibleStart->copy()->addDays(30))->endOfDay(); + $searchStart = $visibleStart->copy()->subDays($searchDays)->startOfDay(); + $searchEnd = $visibleEnd->copy()->addDays($searchDays)->endOfDay(); + + // One pass over the WIDE range — the inner calendarAvailability() + // already groups duplicate products and aggregates per-day, so we + // don't re-pay that cost per cart_item. + $availability = $this->calendarAvailability($searchStart, $searchEnd); + + $visibleStartIso = $visibleStart->toDateString(); + $visibleEndIso = $visibleEnd->toDateString(); + + $dates = []; + foreach (($availability['dates'] ?? []) as $iso => $row) { + if ($iso < $visibleStartIso || $iso > $visibleEndIso) continue; + $dates[$iso] = ($row['max'] ?? 0) >= 1; + } + + $selectedFromIso = $this->from ? Carbon::parse($this->from)->toDateString() : null; + $selectedUntilIso = $this->until ? Carbon::parse($this->until)->toDateString() : null; + $durationDays = 1; + if ($selectedFromIso && $selectedUntilIso) { + $durationDays = max( + 1, + Carbon::parse($selectedFromIso)->startOfDay() + ->diffInDays(Carbon::parse($selectedUntilIso)->startOfDay()) + 1 + ); + } + + // Items relation is guaranteed loaded by calendarAvailability(). + $items = []; + foreach (($availability['items'] ?? []) as $itemBlock) { + $required = (int) ($itemBlock['required_quantity'] ?? 1); + $dayRows = $itemBlock['availability']['dates'] ?? []; + + $cartItemIds = $this->items + ->where('purchasable_id', $itemBlock['product_id']) + ->pluck('id') + ->all(); + + $isAvailableOn = fn (string $iso): bool => + isset($dayRows[$iso]) && ($dayRows[$iso]['max'] ?? 0) >= $required; + + $availableForSelected = $selectedFromIso && $selectedUntilIso; + if ($availableForSelected) { + $cursor = Carbon::parse($selectedFromIso); + $end = Carbon::parse($selectedUntilIso); + while ($cursor->lte($end)) { + if (! $isAvailableOn($cursor->toDateString())) { + $availableForSelected = false; + break; + } + $cursor->addDay(); + } + } + + $windowFitsAt = function (string $startIso) use (&$isAvailableOn, $durationDays, $searchEnd): bool { + $cursor = Carbon::parse($startIso); + for ($i = 0; $i < $durationDays; $i++) { + if ($cursor->gt($searchEnd)) return false; + if (! $isAvailableOn($cursor->toDateString())) return false; + $cursor->addDay(); + } + return true; + }; + + $closestBefore = null; + $closestAfter = null; + if ($selectedFromIso) { + // closest_before: latest END strictly before cart.from + // with a duration window ending on it ("bookable UNTIL X"). + $candidateEnd = Carbon::parse($selectedFromIso)->subDay(); + while ($candidateEnd->gte($searchStart)) { + $start = $candidateEnd->copy()->subDays($durationDays - 1); + if ($start->gte($searchStart) && $windowFitsAt($start->toDateString())) { + $closestBefore = $candidateEnd->toDateString(); + break; + } + $candidateEnd->subDay(); + } + + // closest_after: earliest START strictly after cart.from + // — walking from cart.from+1 (NOT cart.until+1) so a + // selection overlapping a short outage surfaces the + // FIRST recovery date even when it sits inside the + // user's chosen range. windowFitsAt skips over blocked + // middle days for free. + $earliestStart = Carbon::parse($selectedFromIso)->addDay(); + while ($earliestStart->lte($searchEnd)) { + if ($windowFitsAt($earliestStart->toDateString())) { + $closestAfter = $earliestStart->toDateString(); + break; + } + $earliestStart->addDay(); + } + } + + $everAvailable = false; + foreach ($dayRows as $row) { + if (($row['max'] ?? 0) >= $required) { + $everAvailable = true; + break; + } + } + + $datesUnavailable = []; + foreach ($dayRows as $iso => $row) { + if ($iso < $visibleStartIso || $iso > $visibleEndIso) continue; + if (($row['max'] ?? 0) < $required) { + $datesUnavailable[] = $iso; + } + } + + foreach ($cartItemIds as $cartItemId) { + $items[] = [ + 'cart_item_id' => $cartItemId, + 'product_id' => $itemBlock['product_id'], + 'product_name' => $itemBlock['product_name'], + 'required_quantity' => $required, + 'available_for_selected' => $availableForSelected, + 'closest_before' => $closestBefore, + 'closest_after' => $closestAfter, + 'ever_available' => $everAvailable, + 'dates_unavailable' => $datesUnavailable, + ]; + } + } + + return [ + 'dates' => empty($dates) ? [] : $dates, + 'items' => $items, + ]; + } + /** * Validate cart for checkout without converting it * diff --git a/tests/Feature/Cart/CartCalendarAvailabilityHintsTest.php b/tests/Feature/Cart/CartCalendarAvailabilityHintsTest.php new file mode 100644 index 0000000..2846c67 --- /dev/null +++ b/tests/Feature/Cart/CartCalendarAvailabilityHintsTest.php @@ -0,0 +1,124 @@ + \Illuminate\Support\Str::uuid(), + 'name' => 'Test User', + 'email' => 'hints@example.com', + 'password' => bcrypt('password'), + ]); + + $cart = Cart::factory()->forCustomer($user)->create(); + + return [$user, $cart]; + } + + #[Test] + public function it_returns_empty_payload_for_empty_cart(): void + { + [, $cart] = $this->createUserWithCart(); + + $hints = $cart->calendarAvailabilityHints(); + + $this->assertSame([], $hints['dates']); + $this->assertSame([], $hints['items']); + } + + #[Test] + public function it_marks_every_visible_day_bookable_when_stock_covers_required_quantity(): void + { + [, $cart] = $this->createUserWithCart(); + + $product = Product::factory()->withStocks(10)->withPrices(1, 1000)->create(); + $cart->addToCart($product, 1); + + $from = Carbon::today(); + $until = Carbon::today()->addDays(4); + $hints = $cart->calendarAvailabilityHints($from, $until); + + $this->assertCount(5, $hints['dates']); + foreach ($hints['dates'] as $iso => $bookable) { + $this->assertTrue($bookable, "expected $iso bookable"); + } + $this->assertCount(1, $hints['items']); + $this->assertSame(1, $hints['items'][0]['required_quantity']); + $this->assertTrue($hints['items'][0]['ever_available']); + $this->assertSame([], $hints['items'][0]['dates_unavailable']); + } + + #[Test] + public function it_flags_zero_stock_product_as_never_available(): void + { + [, $cart] = $this->createUserWithCart(); + + $product = Product::factory()->create(['manage_stock' => true]); + $product->prices()->create([ + 'unit_amount' => 1000, + 'currency' => 'usd', + 'is_default' => true, + ]); + $cart->addToCart($product, 1); + + $hints = $cart->calendarAvailabilityHints(); + + $this->assertNotEmpty($hints['items']); + $this->assertFalse($hints['items'][0]['ever_available']); + // Every visible day should be in dates_unavailable. + $this->assertNotEmpty($hints['items'][0]['dates_unavailable']); + } + + #[Test] + public function it_emits_one_items_entry_per_cart_item_row_for_same_product(): void + { + [, $cart] = $this->createUserWithCart(); + + $product = Product::factory()->withStocks(10)->withPrices(1, 1000)->create(); + // Two add calls of the SAME product still create a single cart_item row + // (quantity sums) — so we still expect ONE hints entry. This test pins + // that behaviour so a future refactor that splits adds into separate + // rows would also need to update the hints emission. + $cart->addToCart($product, 1); + $cart->addToCart($product, 2); + + $hints = $cart->calendarAvailabilityHints(); + + $this->assertCount(1, $hints['items']); + $this->assertSame(3, $hints['items'][0]['required_quantity']); + } + + #[Test] + public function it_reports_available_for_selected_when_cart_dates_are_fully_bookable(): void + { + [, $cart] = $this->createUserWithCart(); + + $product = Product::factory()->withStocks(5)->withPrices(1, 1000)->create(); + $cart->addToCart($product, 1); + + $cart->setDates( + Carbon::today()->addDays(1), + Carbon::today()->addDays(3), + ); + + $hints = $cart->calendarAvailabilityHints( + Carbon::today(), + Carbon::today()->addDays(10), + ); + + $this->assertTrue($hints['items'][0]['available_for_selected']); + } +}