From edbf116c48d112124ddb70fc02b71f2555de4304 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Tue, 16 Dec 2025 13:58:03 +0100 Subject: [PATCH] AIBFR pool/booking/cart --- docs/05-product-relations.md | 667 +++++++++++++++++ docs/ProductTypes/01-booking-products.md | 319 ++++++++ docs/ProductTypes/02-pool-products.md | 689 ++++++++++++++++++ docs/README.md | 369 ++++++++++ src/Enums/PricingStrategy.php | 24 + src/Models/Cart.php | 89 ++- src/Models/Product.php | 25 +- src/Traits/HasPrices.php | 12 +- src/Traits/HasPricingStrategy.php | 29 + src/Traits/HasShoppingCapabilities.php | 4 +- src/Traits/MayBePoolProduct.php | 388 ++++++++-- .../Feature/CartAddToCartPoolPricingTest.php | 262 +++++++ tests/Feature/PoolProductPricingTest.php | 15 +- tests/Feature/PoolSeparateCartItemsTest.php | 4 + 14 files changed, 2828 insertions(+), 68 deletions(-) create mode 100644 docs/05-product-relations.md create mode 100644 docs/ProductTypes/01-booking-products.md create mode 100644 docs/ProductTypes/02-pool-products.md create mode 100644 docs/README.md create mode 100644 src/Enums/PricingStrategy.php create mode 100644 src/Traits/HasPricingStrategy.php diff --git a/docs/05-product-relations.md b/docs/05-product-relations.md new file mode 100644 index 0000000..304b2ce --- /dev/null +++ b/docs/05-product-relations.md @@ -0,0 +1,667 @@ +# Product Relations + +## Overview + +The Product Relations system enables complex relationships between products through a flexible, type-based association model. Products can be related to each other in various ways for different business purposes like upselling, bundling, cross-selling, and structural groupings. + +## Architecture + +### Database Structure + +Product relations are stored in a many-to-many pivot table with type differentiation: + +``` +products +├── id +├── name +├── type +└── ... + +product_relations (pivot table) +├── product_id → The source product +├── related_product_id → The target product +├── type → ProductRelationType enum +├── sort_order → Optional ordering +└── timestamps +``` + +### Key Concepts + +1. **Directional Relations**: Relations go from `product_id` to `related_product_id` +2. **Typed Relations**: Each relation has a specific type (e.g., UPSELL, RELATED) +3. **Flexible**: Same product can have multiple relation types to different products +4. **Bidirectional Support**: Some relations (POOL/SINGLE) create reverse relations automatically + +## Relation Types + +### Marketing Relations + +These relations help with product discovery and sales optimization: + +#### 1. RELATED (`ProductRelationType::RELATED`) + +**Purpose**: Products that are commonly viewed or purchased together + +**Use Case**: "Customers also viewed" or "Similar products" + +**Example**: +```php +// Canon Camera → Canon Lenses (related products) +$camera->productRelations()->attach($lens->id, [ + 'type' => ProductRelationType::RELATED->value +]); + +// Access +$relatedProducts = $camera->relatedProducts; +``` + +**Direction**: Can be one-way or bidirectional +**Visibility**: Typically shown on product detail pages + +--- + +#### 2. UPSELL (`ProductRelationType::UPSELL`) + +**Purpose**: Higher-tier or premium alternatives to encourage upgrades + +**Use Case**: Suggesting a better/more expensive product + +**Example**: +```php +// Basic Plan → Premium Plan (upsell) +$basicPlan->productRelations()->attach($premiumPlan->id, [ + 'type' => ProductRelationType::UPSELL->value +]); + +// Access +$upsells = $basicPlan->upsellProducts; +``` + +**Direction**: One-way (Basic → Premium, not reverse) +**Visibility**: Product pages, cart pages, checkout + +--- + +#### 3. CROSS_SELL (`ProductRelationType::CROSS_SELL`) + +**Purpose**: Complementary products often purchased together + +**Use Case**: "Frequently bought together" or "Complete your purchase" + +**Example**: +```php +// Laptop → Mouse, Laptop Bag, USB Hub (cross-sells) +$laptop->productRelations()->attach([$mouse->id, $bag->id, $hub->id], [ + 'type' => ProductRelationType::CROSS_SELL->value +]); + +// Access +$crossSells = $laptop->crossSellProducts; +``` + +**Direction**: One-way (main product → accessories) +**Visibility**: Cart page, checkout, product page + +--- + +#### 4. DOWNSELL (`ProductRelationType::DOWNSELL`) + +**Purpose**: Lower-priced alternatives if customer balks at price + +**Use Case**: "Too expensive? Try this instead" + +**Example**: +```php +// Premium Plan → Basic Plan (downsell) +$premiumPlan->productRelations()->attach($basicPlan->id, [ + 'type' => ProductRelationType::DOWNSELL->value +]); + +// Access +$downsells = $premiumPlan->downsellProducts; +``` + +**Direction**: One-way (Premium → Basic, not reverse) +**Visibility**: Shown when customer hesitates or abandons cart + +--- + +#### 5. ADD_ON (`ProductRelationType::ADD_ON`) + +**Purpose**: Optional extras that enhance the main product + +**Use Case**: Extended warranties, gift wrapping, rush delivery + +**Example**: +```php +// Product → Extended Warranty (add-on) +$product->productRelations()->attach($warranty->id, [ + 'type' => ProductRelationType::ADD_ON->value +]); + +// Access +$addOns = $product->addOnProducts; +``` + +**Direction**: One-way (main product → add-on) +**Visibility**: Product page, during add-to-cart flow + +--- + +### Structural Relations + +These relations define product hierarchy and composition: + +#### 6. VARIATION (`ProductRelationType::VARIATION`) + +**Purpose**: Different versions/variants of the same base product + +**Use Case**: Size, color, or configuration variations + +**Example**: +```php +// T-Shirt (Variable) → T-Shirt Small, T-Shirt Medium (variations) +$tshirt->productRelations()->attach([$small->id, $medium->id, $large->id], [ + 'type' => ProductRelationType::VARIATION->value +]); + +// Access +$variations = $tshirt->variantProducts; +``` + +**Direction**: One-way (parent → variations) +**Visibility**: Product page variant selector + +--- + +#### 7. BUNDLE (`ProductRelationType::BUNDLE`) + +**Purpose**: Group of products sold together as a package + +**Use Case**: "Starter Kit" or "Complete Package" offerings + +**Example**: +```php +// Starter Bundle → Individual Products +$starterKit->productRelations()->attach([ + $product1->id, + $product2->id, + $product3->id +], [ + 'type' => ProductRelationType::BUNDLE->value +]); + +// Access +$bundleProducts = $starterKit->bundleProducts; +``` + +**Direction**: One-way (bundle → components) +**Visibility**: Bundle product page showing contents + +--- + +### Pool Relations (Special) + +These are bidirectional relations for pool/single item management: + +#### 8. SINGLE (`ProductRelationType::SINGLE`) + +**Purpose**: Link pool product to its individual component items + +**Use Case**: Pool product pointing to actual bookable items + +**Example**: +```php +// Parking Pool → Individual Spots (single items) +$pool->productRelations()->attach($spot1->id, [ + 'type' => ProductRelationType::SINGLE->value +]); + +// Access +$singleItems = $pool->singleProducts; +``` + +**Direction**: Pool → Single Items +**Auto-creates**: Reverse POOL relation + +--- + +#### 9. POOL (`ProductRelationType::POOL`) + +**Purpose**: Link individual items back to their pool container + +**Use Case**: Single item referencing its parent pool + +**Example**: +```php +// This is automatically created when using attachSingleItems() +// Individual Spot → Parking Pool (pool reference) + +// Access +$pools = $spot1->poolProducts; +``` + +**Direction**: Single Item → Pool +**Auto-created**: By `attachSingleItems()` method + +--- + +## Usage Examples + +### Basic Relations + +```php +// Create a product with related products +$camera = Product::find(1); + +// Add related products (one at a time) +$camera->productRelations()->attach($lens->id, [ + 'type' => ProductRelationType::RELATED->value, + 'sort_order' => 1, +]); + +// Add multiple related products +$camera->productRelations()->attach([ + $lens->id => ['type' => ProductRelationType::RELATED->value], + $tripod->id => ['type' => ProductRelationType::RELATED->value], + $bag->id => ['type' => ProductRelationType::RELATED->value], +]); + +// Retrieve related products +$relatedProducts = $camera->relatedProducts; +``` + +### Cross-Selling + +```php +// Set up cross-sells for laptop +$laptop->productRelations()->attach([ + $mouse->id => ['type' => ProductRelationType::CROSS_SELL->value, 'sort_order' => 1], + $bag->id => ['type' => ProductRelationType::CROSS_SELL->value, 'sort_order' => 2], + $warranty->id => ['type' => ProductRelationType::CROSS_SELL->value, 'sort_order' => 3], +]); + +// In cart or checkout +$crossSells = $laptop->crossSellProducts()->orderBy('sort_order')->get(); +``` + +### Upselling Flow + +```php +// Basic → Premium upsell path +$basicPlan->productRelations()->attach($premiumPlan->id, [ + 'type' => ProductRelationType::UPSELL->value +]); + +// Premium → Enterprise upsell path +$premiumPlan->productRelations()->attach($enterprisePlan->id, [ + 'type' => ProductRelationType::UPSELL->value +]); + +// Show upsells +if ($cart->contains($basicPlan)) { + $suggestedUpgrade = $basicPlan->upsellProducts->first(); +} +``` + +### Product Variations + +```php +// Variable product with variations +$tshirt = Product::create([ + 'name' => 'T-Shirt', + 'type' => ProductType::VARIABLE, +]); + +// Create variations +$small = Product::create(['name' => 'T-Shirt Small', 'type' => ProductType::VARIATION]); +$medium = Product::create(['name' => 'T-Shirt Medium', 'type' => ProductType::VARIATION]); +$large = Product::create(['name' => 'T-Shirt Large', 'type' => ProductType::VARIATION]); + +// Link variations +$tshirt->productRelations()->attach([ + $small->id => ['type' => ProductRelationType::VARIATION->value], + $medium->id => ['type' => ProductRelationType::VARIATION->value], + $large->id => ['type' => ProductRelationType::VARIATION->value], +]); + +// Display on product page +$variations = $tshirt->variantProducts; +``` + +### Pool/Single Relations (Special Case) + +Pool relations are unique because they're bidirectional: + +```php +// ✅ CORRECT WAY - Use attachSingleItems() +$pool = Product::create(['type' => ProductType::POOL]); +$spot1 = Product::create(['type' => ProductType::BOOKING]); +$spot2 = Product::create(['type' => ProductType::BOOKING]); + +// This creates BOTH directions automatically: +$pool->attachSingleItems([$spot1->id, $spot2->id]); + +// Now both directions work: +$pool->singleProducts; // Returns: [spot1, spot2] +$spot1->poolProducts; // Returns: [pool] + +// ❌ WRONG WAY - Don't use attach() directly +$pool->productRelations()->attach($spot1->id, [ + 'type' => ProductRelationType::SINGLE->value +]); +// This only creates one direction! Missing reverse POOL relation. +``` + +**What `attachSingleItems()` does:** + +```php +public function attachSingleItems(array $singleItemIds): void +{ + // 1. Attach SINGLE relations from pool to items + $this->productRelations()->attach( + array_fill_keys($singleItemIds, ['type' => ProductRelationType::SINGLE->value]) + ); + + // 2. Attach reverse POOL relations from items to pool + foreach ($singleItemIds as $singleItemId) { + $singleItem = static::find($singleItemId); + $singleItem->productRelations()->attach($this->id, [ + 'type' => ProductRelationType::POOL->value + ]); + } +} +``` + +## Advanced Usage + +### Filtering by Relation Type + +```php +// Get all relations of a specific type +$upsells = $product->relationsByType(ProductRelationType::UPSELL); + +// Or use dedicated method +$upsells = $product->upsellProducts; + +// Get multiple types +$suggestions = $product->productRelations() + ->whereIn('type', [ + ProductRelationType::UPSELL->value, + ProductRelationType::CROSS_SELL->value + ]) + ->get(); +``` + +### Custom Queries + +```php +// Relations with additional constraints +$premiumUpsells = $product->upsellProducts() + ->where('products.price', '>', 10000) + ->orderBy('products.price', 'asc') + ->get(); + +// Limited cross-sells +$topCrossSells = $product->crossSellProducts() + ->orderByPivot('sort_order') + ->limit(3) + ->get(); +``` + +### Checking Relations + +```php +// Check if product has any upsells +if ($product->upsellProducts()->exists()) { + // Show upsell section +} + +// Count related products +$relatedCount = $product->relatedProducts()->count(); + +// Check specific relation +$hasRelation = $product->productRelations() + ->where('related_product_id', $otherProduct->id) + ->where('type', ProductRelationType::RELATED->value) + ->exists(); +``` + +### Managing Relations + +```php +// Add relation +$product->productRelations()->attach($relatedId, [ + 'type' => ProductRelationType::RELATED->value, + 'sort_order' => 1, +]); + +// Update relation +$product->productRelations()->updateExistingPivot($relatedId, [ + 'sort_order' => 2 +]); + +// Remove relation +$product->productRelations()->detach($relatedId); + +// Remove all relations of a type +$product->relatedProducts()->detach(); + +// Sync relations (replace all) +$product->productRelations()->sync([ + $id1 => ['type' => ProductRelationType::RELATED->value], + $id2 => ['type' => ProductRelationType::RELATED->value], +]); +``` + +## Relation Type Reference + +| Type | Enum | Direction | Auto-Reverse | Typical Use | +|------------|-----------------------------------|-------------|--------------|---------------------------------| +| RELATED | `ProductRelationType::RELATED` | One-way | No | Similar products, "also viewed" | +| UPSELL | `ProductRelationType::UPSELL` | One-way | No | Premium alternatives | +| CROSS_SELL | `ProductRelationType::CROSS_SELL` | One-way | No | Complementary products | +| DOWNSELL | `ProductRelationType::DOWNSELL` | One-way | No | Lower-priced alternatives | +| ADD_ON | `ProductRelationType::ADD_ON` | One-way | No | Optional extras | +| VARIATION | `ProductRelationType::VARIATION` | One-way | No | Product variants | +| BUNDLE | `ProductRelationType::BUNDLE` | One-way | No | Package components | +| SINGLE | `ProductRelationType::SINGLE` | Pool → Item | Yes (POOL) | Pool single items | +| POOL | `ProductRelationType::POOL` | Item → Pool | Yes (SINGLE) | Item's pool reference | + +## Best Practices + +### 1. Use Appropriate Relation Types + +```php +// ✅ CORRECT - Semantic meaning +$product->productRelations()->attach($accessory->id, [ + 'type' => ProductRelationType::CROSS_SELL->value // Complementary +]); + +// ❌ INCORRECT - Wrong type +$product->productRelations()->attach($accessory->id, [ + 'type' => ProductRelationType::UPSELL->value // Accessory isn't an upgrade +]); +``` + +### 2. Use Helper Methods + +```php +// ✅ CORRECT - Dedicated method +$upsells = $product->upsellProducts; + +// ❌ VERBOSE - Manual filtering +$upsells = $product->productRelations() + ->wherePivot('type', ProductRelationType::UPSELL->value) + ->get(); +``` + +### 3. Sort Order for Display + +```php +// ✅ CORRECT - Use sort_order +$product->productRelations()->attach($items, [ + $item1->id => ['type' => ProductRelationType::CROSS_SELL->value, 'sort_order' => 1], + $item2->id => ['type' => ProductRelationType::CROSS_SELL->value, 'sort_order' => 2], +]); + +$crossSells = $product->crossSellProducts()->orderByPivot('sort_order')->get(); +``` + +### 4. Always Use attachSingleItems() for Pools + +```php +// ✅ CORRECT - Bidirectional +$pool->attachSingleItems([$item1->id, $item2->id]); + +// ❌ INCORRECT - One-way only +$pool->productRelations()->attach($item1->id, [ + 'type' => ProductRelationType::SINGLE->value +]); +``` + +### 5. Eager Load Relations + +```php +// ✅ CORRECT - Avoid N+1 +$products = Product::with('crossSellProducts')->get(); + +foreach ($products as $product) { + $product->crossSellProducts; // Already loaded +} + +// ❌ INCORRECT - N+1 queries +$products = Product::all(); + +foreach ($products as $product) { + $product->crossSellProducts; // Query per product +} +``` + +### 6. Validate Relation Logic + +```php +// ✅ CORRECT - Check business logic +if ($premiumPlan->price > $basicPlan->price) { + $basicPlan->productRelations()->attach($premiumPlan->id, [ + 'type' => ProductRelationType::UPSELL->value + ]); +} + +// ❌ INCORRECT - No validation +$basicPlan->productRelations()->attach($cheaperPlan->id, [ + 'type' => ProductRelationType::UPSELL->value // Upsell to cheaper product?? +]); +``` + +## Common Patterns + +### Product Page Relations Display + +```php +// Show all related products +$relatedProducts = $product->relatedProducts()->limit(4)->get(); + +// Show upsell if available +$upsell = $product->upsellProducts()->first(); + +// Show add-ons +$addOns = $product->addOnProducts; +``` + +### Cart Cross-Sells + +```php +$cart = $user->currentCart(); +$allCrossSells = collect(); + +foreach ($cart->items as $item) { + $crossSells = $item->purchasable->crossSellProducts; + $allCrossSells = $allCrossSells->merge($crossSells); +} + +// Remove duplicates and products already in cart +$uniqueCrossSells = $allCrossSells + ->unique('id') + ->reject(fn($p) => $cart->items->contains('purchasable_id', $p->id)); +``` + +### Upsell at Checkout + +```php +$cartTotal = $cart->getTotal(); + +// Find upsells for products in cart +$upsellOpportunities = $cart->items + ->map(fn($item) => $item->purchasable->upsellProducts) + ->flatten() + ->unique('id'); + +// Filter to affordable upsells +$affordableUpsells = $upsellOpportunities->filter( + fn($upsell) => $upsell->price <= $cartTotal * 1.2 // Max 20% more +); +``` + +### Bundle Product Display + +```php +// Show bundle contents +$bundle = Product::find($id); +$components = $bundle->bundleProducts; + +$totalValue = $components->sum('price'); +$bundlePrice = $bundle->price; +$savings = $totalValue - $bundlePrice; + +// "Save $50 when you buy the bundle!" +``` + +## Troubleshooting + +### Relations Not Showing + +**Check:** +1. Relation type is correct: `ProductRelationType::RELATED->value` +2. Using correct method: `relatedProducts` not `productRelations` +3. Pivot data exists: Check `product_relations` table + +### Pool/Single Relations Not Bidirectional + +**Solution:** +```php +// Use dedicated method +$pool->attachSingleItems($itemIds); + +// NOT regular attach() +``` + +### Duplicate Relations + +**Prevent:** +```php +// Check before adding +if (!$product->relatedProducts()->where('id', $relatedId)->exists()) { + $product->productRelations()->attach($relatedId, [ + 'type' => ProductRelationType::RELATED->value + ]); +} +``` + +### N+1 Query Issues + +**Solution:** +```php +// Eager load +$products = Product::with([ + 'crossSellProducts', + 'upsellProducts', + 'relatedProducts' +])->get(); +``` + +## Related Documentation + +- [Pool Products](./ProductTypes/02-pool-products.md) - POOL/SINGLE relations in detail +- [Product Types](./ProductTypes/) - Understanding different product types +- [Stock Management](./06-stock-management.md) - How stock works with relations diff --git a/docs/ProductTypes/01-booking-products.md b/docs/ProductTypes/01-booking-products.md new file mode 100644 index 0000000..a980c61 --- /dev/null +++ b/docs/ProductTypes/01-booking-products.md @@ -0,0 +1,319 @@ +# Booking Products + +## Overview + +Booking products (`ProductType::BOOKING`) are time-based products that can be reserved for specific date ranges. They are designed for scenarios where customers need to reserve items for a period of time, such as hotel rooms, rental equipment, parking spaces, or event tickets. + +## Key Characteristics + +### 1. **Time-Based Availability** +- Products are reserved for specific date ranges (`from` to `until`) +- Stock is tracked on a per-date basis +- Multiple customers can have overlapping claims if enough stock exists +- Claims automatically release when they expire + +### 2. **Stock Management** +- **MUST** have `manage_stock = true` +- Stock represents the number of units available simultaneously +- Example: A hotel room with stock=5 means 5 rooms can be booked at the same time + +### 3. **Price Calculation** +- Prices are calculated based on the number of days +- Formula: `price = unit_amount × number_of_days × quantity` +- Example: $100/day room for 3 days = $300 + +### 4. **Stock Claiming** +- When added to cart, stock is "claimed" (reserved but not sold) +- Claims have a start date (`claimed_from`) and end date (`expires_at`) +- Claims reduce available stock during their active period +- Claims can be released (e.g., when removed from cart or cart expires) + +## How It Works + +### Stock Tracking System + +Booking products use a sophisticated two-entry stock system: + +1. **DECREASE Entry** (COMPLETED status) + - Reduces available stock immediately + - Quantity: `-X` (negative) + - Has an `expires_at` date (when the booking ends) + +2. **CLAIMED Entry** (PENDING status) + - Tracks the claim/reservation + - Quantity: `+X` (positive, represents claimed amount) + - Has both `claimed_from` and `expires_at` dates + - Links to the reference model (Cart, Order, etc.) + +### Availability Checking + +```php +// Check if product is available for a date range +$product->isAvailableForBooking($from, $until, $quantity); +``` + +The system checks: +1. How much stock is available (total stock) +2. How much is already claimed during the requested period +3. If `available - claimed >= requested_quantity` + +### Example Scenario: Hotel Room + +```php +// Create a hotel room product +$room = Product::create([ + 'name' => 'Deluxe Suite', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, +]); + +// We have 5 rooms available +$room->increaseStock(5); + +// Add price: $200 per day +ProductPrice::create([ + 'purchasable_id' => $room->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 20000, // $200.00 (in cents) + 'currency' => 'USD', + 'is_default' => true, +]); + +// Customer books 2 rooms from Jan 1-3 (2 days) +$from = Carbon::parse('2025-01-01'); +$until = Carbon::parse('2025-01-03'); + +// Check availability +if ($room->isAvailableForBooking($from, $until, 2)) { + // Add to cart (claims stock automatically) + $cart->addToCart($room, 2, [], $from, $until); + + // Price calculation: + // 2 rooms × 2 days × $200/day = $800 +} + +// Available stock during booking: +// - Before: 5 rooms available +// - During Jan 1-3: 3 rooms available (5 - 2 claimed) +// - After Jan 3: 5 rooms available (claims expire) +``` + +## Configuration Requirements + +### Database Requirements + +1. **Product Table** + ```php + 'type' => ProductType::BOOKING, + 'manage_stock' => true, // REQUIRED + ``` + +2. **Stock Table** (`product_stocks`) + - `claimed_from` - When the booking starts + - `expires_at` - When the booking ends + - `reference_type` - Polymorphic relation (Cart, Order, etc.) + - `reference_id` - ID of the reference model + +### Validation + +```php +// Validate booking configuration +try { + $product->validateBookingConfiguration(); +} catch (InvalidBookingConfigurationException $e) { + // Handle invalid configuration +} +``` + +Common validation errors: +- Stock management not enabled +- No available stock +- No price set +- Invalid date range (from >= until) + +## Cart Integration + +### Adding to Cart + +```php +$from = Carbon::parse('2025-01-15'); +$until = Carbon::parse('2025-01-20'); // 5 days + +$cartItem = $cart->addToCart($product, $quantity = 1, [], $from, $until); + +// CartItem properties: +// - from: 2025-01-15 +// - until: 2025-01-20 +// - price: unit_amount × 5 days +// - quantity: number of units +``` + +### What Happens: +1. System checks availability for the date range +2. If available, stock is claimed: + - DECREASE entry with `expires_at = 2025-01-20` + - CLAIMED entry with `claimed_from = 2025-01-15`, `expires_at = 2025-01-20` +3. Cart item stores the date range +4. Price is calculated for the duration + +### Removing from Cart + +```php +$cartItem->delete(); +``` + +What happens: +1. Claimed stocks are released +2. CLAIMED entry status changes to COMPLETED +3. Stock becomes available again + +## Checkout Flow + +### Purchase Process + +```php +$cart->checkout(); +``` + +1. **Before Checkout** + - Stock is CLAIMED (reserved) + - Status: PENDING + - Can be released if cart expires + +2. **After Successful Checkout** + - CLAIMED entries remain (converted to sold) + - Stock stays decreased for the booking period + - After `expires_at`, stock automatically becomes available again + +3. **Failed Checkout** + - Claims are released + - Stock returns to available pool + +## Advanced Usage + +### Checking Availability Calendar + +```php +// Get available stock for each day +$from = Carbon::parse('2025-01-01'); +$until = Carbon::parse('2025-01-31'); + +$availability = []; +$current = $from->copy(); + +while ($current <= $until) { + $nextDay = $current->copy()->addDay(); + $available = $product->isAvailableForBooking($current, $nextDay, 1); + $availability[$current->format('Y-m-d')] = $available; + $current->addDay(); +} +``` + +### Multiple Bookings + +```php +// Booking 1: Jan 1-5 +$cart->addToCart($room, 1, [], + Carbon::parse('2025-01-01'), + Carbon::parse('2025-01-05') +); + +// Booking 2: Jan 3-7 (overlaps with Booking 1) +// This works if there's enough stock +$cart->addToCart($room, 1, [], + Carbon::parse('2025-01-03'), + Carbon::parse('2025-01-07') +); +``` + +### Getting Available Stock on a Specific Date + +```php +$date = Carbon::parse('2025-01-15'); +$available = $product->getAvailableStock($date); +``` + +## Common Use Cases + +### 1. Hotel Rooms +- Stock = number of rooms +- Customers book rooms for date ranges +- Multiple rooms can be booked simultaneously + +### 2. Equipment Rental +- Stock = number of items available +- Customers rent for specific periods +- Items return to inventory after rental ends + +### 3. Parking Spaces +- Stock = number of spots +- Reserved for specific time periods +- Automatically available after reservation expires + +### 4. Event Tickets (Time-Based) +- Stock = number of seats +- Reserved for specific time slots +- Released if not purchased within time limit + +### 5. Service Appointments +- Stock = number of available slots +- Each booking claims one slot +- Slots are time-specific + +## Best Practices + +1. **Always Enable Stock Management** + ```php + 'manage_stock' => true // REQUIRED for booking products + ``` + +2. **Set Appropriate Stock Levels** + - Stock = maximum concurrent bookings + - Too low = lost revenue + - Too high = overbooking risk + +3. **Handle Overlapping Bookings** + - System automatically manages overlaps + - Just ensure enough stock exists + +4. **Cart Expiration** + - Set appropriate cart expiration times + - Expired claims auto-release stock + +5. **Price Per Day** + - Set `unit_amount` as the daily rate + - System automatically multiplies by days + +6. **Date Validation** + - Always validate `from < until` + - Validate minimum/maximum booking duration if needed + +7. **Availability Checking** + - Always check availability before claiming + - Use `isAvailableForBooking()` method + +## Troubleshooting + +### "Not enough stock available" +- Check if stock is claimed by other bookings during the period +- Verify `manage_stock = true` +- Check if stock was actually added (`increaseStock()`) + +### "Stock management not enabled" +- Set `manage_stock = true` on the product + +### Bookings Not Releasing +- Check that cart items are properly deleted +- Verify that claims have `expires_at` set +- Expired claims should auto-release (verify cron/queue is running) + +### Price Calculation Wrong +- Verify `unit_amount` is the daily rate +- Check that date calculation is correct (diff in days) +- Ensure price is multiplied by both days and quantity + +## Related Documentation + +- [Pool Products](./02-pool-products.md) - Managing groups of booking products +- [Product Relations](../05-product-relations.md) - How products relate to each other +- [Stock Management](../06-stock-management.md) - Detailed stock system documentation diff --git a/docs/ProductTypes/02-pool-products.md b/docs/ProductTypes/02-pool-products.md new file mode 100644 index 0000000..53d505c --- /dev/null +++ b/docs/ProductTypes/02-pool-products.md @@ -0,0 +1,689 @@ +# Pool Products + +## Overview + +Pool products (`ProductType::POOL`) are special container products that manage a group of individual items (typically booking products) as a unified product offering. Instead of customers selecting a specific item, they book from a "pool" and the system automatically assigns an available item. + +## Concept + +Think of a pool product as a "hotel" rather than a specific "room": +- **Pool Product** = "Parking Spaces" (what customers see) +- **Single Items** = Individual parking spots 1, 2, 3 (managed behind the scenes) + +Customers book "a parking space" without specifying which one. The system automatically assigns the first available spot. + +## Key Characteristics + +### 1. **Does Not Manage Its Own Stock** +- Always set `manage_stock = false` on pool products +- Stock comes from the sum of available single items +- Pool availability = total available single items + +### 2. **Contains Single Items** +- Single items are linked via product relations (`ProductRelationType::SINGLE`) +- Single items are typically `ProductType::BOOKING` with `manage_stock = true` +- Each single item has its own stock (usually 1) + +### 3. **Bidirectional Relations** +- Pool → Single Items (via `SINGLE` relation type) +- Single Items → Pool (via `POOL` relation type, reverse reference) + +### 4. **Flexible Pricing** +- Can have its own direct price +- OR inherit price from single items using a pricing strategy +- Three strategies: LOWEST, HIGHEST, AVERAGE (default: LOWEST) + +### 5. **Automatic Assignment** +- When booked, automatically claims the first available single item +- Customers don't choose which specific item +- System handles all assignment logic + +## Architecture + +### Relation Structure + +``` +Pool Product (type: POOL, manage_stock: false) + ├── SINGLE relation → Single Item 1 (type: BOOKING, stock: 1) + ├── SINGLE relation → Single Item 2 (type: BOOKING, stock: 1) + └── SINGLE relation → Single Item 3 (type: BOOKING, stock: 1) + +Single Item 1 + └── POOL relation → Pool Product (reverse reference) +``` + +### Stock Flow + +``` +Customer books → Pool Product + ↓ + Checks availability of single items + ↓ + Claims first available single item + ↓ + Single item stock is reduced +``` + +## Creating Pool Products + +### Basic Setup + +```php +// 1. Create the pool product +$parkingPool = Product::create([ + 'name' => 'Parking Spaces', + 'type' => ProductType::POOL, + 'manage_stock' => false, // IMPORTANT: Pool doesn't manage stock +]); + +// 2. Create individual single items (booking products) +$spot1 = Product::create([ + 'name' => 'Parking Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, // Single items DO manage stock +]); +$spot1->increaseStock(1); // Each spot has 1 unit + +$spot2 = Product::create([ + 'name' => 'Parking Spot 2', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, +]); +$spot2->increaseStock(1); + +$spot3 = Product::create([ + 'name' => 'Parking Spot 3', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, +]); +$spot3->increaseStock(1); + +// 3. Set prices on single items +ProductPrice::create([ + 'purchasable_id' => $spot1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2000, // $20/day + 'currency' => 'USD', + 'is_default' => true, +]); + +// Similar for spot2 and spot3... + +// 4. Link single items to the pool +$parkingPool->attachSingleItems([$spot1->id, $spot2->id, $spot3->id]); + +// This creates bidirectional relations automatically: +// - Pool → Single Items (SINGLE type) +// - Single Items → Pool (POOL type) +``` + +## Pricing Strategies + +Pool products support flexible pricing through the `HasPricingStrategy` trait. + +### 1. Direct Pool Pricing + +Set a price directly on the pool product: + +```php +ProductPrice::create([ + 'purchasable_id' => $parkingPool->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2500, // Fixed $25/day for any parking spot + 'currency' => 'USD', + 'is_default' => true, +]); + +// Pool uses its own price, ignoring single item prices +$price = $parkingPool->getCurrentPrice(); // 2500 +``` + +### 2. Inherited Pricing (Default) + +If no direct price is set, pool inherits from **available** single items: + +```php +use Blax\Shop\Enums\PricingStrategy; + +// Single items have different prices: +// - Spot 1: $20/day +// - Spot 2: $30/day +// - Spot 3: $25/day + +// LOWEST strategy (default) +$parkingPool->setPricingStrategy(PricingStrategy::LOWEST); +$price = $parkingPool->getCurrentPrice(); // 2000 ($20 - lowest) + +// HIGHEST strategy +$parkingPool->setPricingStrategy(PricingStrategy::HIGHEST); +$price = $parkingPool->getCurrentPrice(); // 3000 ($30 - highest) + +// AVERAGE strategy +$parkingPool->setPricingStrategy(PricingStrategy::AVERAGE); +$price = $parkingPool->getCurrentPrice(); // 2500 ($25 - average) +``` + +### 3. Available-Based Pricing (Dynamic) + +**Critical Feature:** Pricing only considers **available** single items, not all items. + +```php +// Scenario: 3 parking spots +// - Spot 1: $20/day (AVAILABLE) +// - Spot 2: $30/day (CLAIMED for Jan 1-5) +// - Spot 3: $25/day (AVAILABLE) + +$from = Carbon::parse('2025-01-03'); +$until = Carbon::parse('2025-01-04'); + +// With LOWEST strategy: +// Available spots: Spot 1 ($20), Spot 3 ($25) +// Price: $20 (lowest of available spots, not $30 from claimed spot) +$price = $parkingPool->getCurrentPrice(); // 2000 +``` + +This ensures customers always see the price of what they'll actually get! + +## Availability Checking + +### Get Pool Availability + +```php +$from = Carbon::parse('2025-01-15'); +$until = Carbon::parse('2025-01-20'); + +// Check if pool has N units available +$available = $parkingPool->isPoolAvailable($from, $until, $quantity = 2); + +// Get maximum available quantity +$maxQuantity = $parkingPool->getPoolMaxQuantity($from, $until); // 3 + +// Get detailed availability per single item +$items = $parkingPool->getSingleItemsAvailability($from, $until); +// Returns: +// [ +// ['id' => 1, 'name' => 'Spot 1', 'available' => 1], +// ['id' => 2, 'name' => 'Spot 2', 'available' => 0], // claimed +// ['id' => 3, 'name' => 'Spot 3', 'available' => 1], +// ] +``` + +### Availability Calendar + +```php +// Get availability for each day in a range +$calendar = $parkingPool->getPoolAvailabilityCalendar( + '2025-01-01', + '2025-01-31' +); + +// Returns: +// [ +// '2025-01-01' => 3, +// '2025-01-02' => 2, // 1 claimed +// '2025-01-03' => 3, +// ... +// ] +``` + +### Find Available Periods + +```php +// Find periods with at least 2 spots available for 3+ consecutive days +$periods = $parkingPool->getPoolAvailablePeriods( + startDate: '2025-01-01', + endDate: '2025-01-31', + quantity: 2, + minConsecutiveDays: 3 +); + +// Returns: +// [ +// [ +// 'from' => '2025-01-01', +// 'until' => '2025-01-10', +// 'min_available' => 2, +// ], +// [ +// 'from' => '2025-01-15', +// 'until' => '2025-01-20', +// 'min_available' => 3, +// ], +// ] +``` + +## Stock Claiming Process + +### Automatic Assignment + +When a pool product is added to cart: + +```php +$from = Carbon::parse('2025-01-15'); +$until = Carbon::parse('2025-01-20'); + +// Customer adds pool to cart +$cartItem = $cart->addToCart($parkingPool, $quantity = 2, [], $from, $until); +``` + +**Behind the scenes:** + +1. **Check Availability** + - System checks if 2 spots are available during Jan 15-20 + - Uses `getPoolMaxQuantity($from, $until)` + +2. **Claim Stock from Single Items** + - Calls `claimPoolStock(2, $cartItem, $from, $until)` + - Finds first 2 available single items + - Claims 1 unit from each: `$spot->claimStock(1, $cartItem, $from, $until)` + +3. **Store Claimed Items** + - Cart item metadata stores which single items were claimed + - Metadata: `claimed_single_items: [spot1_id, spot2_id]` + +4. **Calculate Price** + - Gets price from available single items (using pricing strategy) + - Multiplies by number of days + - Stores in cart item + +### Manual Stock Operations + +```php +// Manually claim pool stock +$claimedItems = $parkingPool->claimPoolStock( + quantity: 2, + reference: $order, + from: Carbon::parse('2025-01-15'), + until: Carbon::parse('2025-01-20'), + note: 'VIP booking' +); +// Returns: [Spot1, Spot3] (array of claimed Product instances) + +// Release pool stock +$released = $parkingPool->releasePoolStock($order); +// Returns: 2 (number of claims released) +``` + +## Validation + +### Configuration Validation + +```php +use Blax\Shop\Exceptions\InvalidPoolConfigurationException; + +try { + $result = $parkingPool->validatePoolConfiguration(); + // Returns: + // [ + // 'valid' => true, + // 'errors' => [], + // 'warnings' => ['Some items have zero stock'], + // ] +} catch (InvalidPoolConfigurationException $e) { + // Critical error +} +``` + +**Common Validation Errors:** + +1. **No Single Items** + ```php + throw InvalidPoolConfigurationException::noSingleItems($poolName); + ``` + +2. **Mixed Product Types** + ```php + throw InvalidPoolConfigurationException::mixedSingleItemTypes($poolName); + ``` + +3. **Single Items Without Stock Management** + ```php + throw InvalidPoolConfigurationException::singleItemsWithoutStock($poolName, $itemNames); + ``` + +4. **Single Items With Zero Stock** (warning) + ```php + throw InvalidPoolConfigurationException::singleItemsWithZeroStock($poolName, $itemNames); + ``` + +### Validation on Creation + +```php +// Always validate after setup +$parkingPool->attachSingleItems([$spot1->id, $spot2->id]); + +if (!$parkingPool->validatePoolConfiguration()['valid']) { + // Handle errors +} +``` + +## Cart Integration + +### Adding Pool to Cart + +```php +$from = Carbon::parse('2025-01-15'); +$until = Carbon::parse('2025-01-17'); // 2 days + +$cartItem = $cart->addToCart($parkingPool, $quantity = 1, [], $from, $until); + +// Cart item properties: +// - purchasable: Pool Product +// - quantity: 1 +// - from: 2025-01-15 +// - until: 2025-01-17 +// - price: (unit_amount × 2 days) +// - meta->claimed_single_items: [spot_id] +``` + +### Viewing Claimed Items + +```php +$meta = $cartItem->getMeta(); +$claimedItemIds = $meta->claimed_single_items ?? []; + +// Load the actual products +$claimedItems = Product::whereIn('id', $claimedItemIds)->get(); +``` + +### Removing from Cart + +```php +$cartItem->delete(); +``` + +**What happens:** +1. System finds claimed single items from metadata +2. Releases claims on each single item +3. Stock becomes available again + +## Advanced Usage + +### Mixed Pricing + +```php +// Different single items can have different prices +$spot1->defaultPrice()->update(['unit_amount' => 2000]); // $20/day +$spot2->defaultPrice()->update(['unit_amount' => 3000]); // $30/day (premium) +$spot3->defaultPrice()->update(['unit_amount' => 2500]); // $25/day + +// With LOWEST strategy (default) +$parkingPool->setPricingStrategy(PricingStrategy::LOWEST); +$price = $parkingPool->getCurrentPrice(); // 2000 + +// Customer gets cheapest available spot +// But if $20 spot is claimed, next customer gets $25 spot at $25 price +``` + +### Dynamic Pricing Based on Availability + +```php +$from = Carbon::parse('2025-01-15'); +$until = Carbon::parse('2025-01-20'); + +// Get price for specific dates (considers availability) +$price = $parkingPool->getLowestAvailablePoolPrice($from, $until); +``` + +### Price Range Display + +```php +// Get min/max price range from available items +$range = $parkingPool->getPoolPriceRange(); +// Returns: ['min' => 2000, 'max' => 3000] + +// Display to customer: "From $20 to $30 per day" +``` + +### Checking for Booking Single Items + +```php +// Check if pool contains any booking-type single items +$hasBooking = $parkingPool->hasBookingSingleItems(); // true + +// This affects how cart handles date requirements +``` + +## Common Use Cases + +### 1. Parking Spaces + +```php +// Pool: "Parking at Hotel" +// Singles: Spot 1, Spot 2, Spot 3, ..., Spot 50 +// Each spot: 1 unit stock +// Customer books "a parking space", system assigns specific spot +``` + +### 2. Hotel Room Categories + +```php +// Pool: "Standard Rooms" +// Singles: Room 101, Room 102, Room 103 +// All same price, customer doesn't care which room +// System auto-assigns available room +``` + +### 3. Equipment Fleet + +```php +// Pool: "Rental Cars - Compact" +// Singles: Car VIN-001, Car VIN-002, Car VIN-003 +// Customer rents "a compact car", not a specific car +// System tracks which car is assigned +``` + +### 4. Event Seating + +```php +// Pool: "General Admission" +// Singles: Seat A1, Seat A2, Seat A3, ... +// Customer books "general admission ticket" +// System assigns specific seat +``` + +### 5. Service Providers + +```php +// Pool: "Consultation Services" +// Singles: Consultant A, Consultant B, Consultant C +// Customer books "a consultation" +// System assigns available consultant +``` + +## Best Practices + +### 1. Pool Configuration + +```php +// ✅ CORRECT +$pool = Product::create([ + 'type' => ProductType::POOL, + 'manage_stock' => false, // Pool doesn't manage stock +]); + +// ❌ INCORRECT +$pool = Product::create([ + 'type' => ProductType::POOL, + 'manage_stock' => true, // Wrong! Pool shouldn't manage stock +]); +``` + +### 2. Single Item Configuration + +```php +// ✅ CORRECT +$singleItem = Product::create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, // Single items manage stock +]); +$singleItem->increaseStock(1); + +// ❌ INCORRECT +$singleItem = Product::create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => false, // Single items need stock management +]); +``` + +### 3. Use attachSingleItems() + +```php +// ✅ CORRECT - Creates bidirectional relations +$pool->attachSingleItems([$item1->id, $item2->id]); + +// ❌ INCORRECT - Only creates one-way relation +$pool->productRelations()->attach($item1->id, [ + 'type' => ProductRelationType::SINGLE->value +]); +// Missing reverse POOL relation! +``` + +### 4. Always Validate + +```php +// ✅ CORRECT +$pool->attachSingleItems($itemIds); +$validation = $pool->validatePoolConfiguration(); + +if (!$validation['valid']) { + throw new Exception('Invalid pool configuration'); +} + +// ❌ INCORRECT - No validation +$pool->attachSingleItems($itemIds); +// What if items don't have stock? What if mixed types? +``` + +### 5. Set Pricing Strategy + +```php +// ✅ CORRECT - Explicit strategy +$pool->setPricingStrategy(PricingStrategy::LOWEST); + +// ⚠️ IMPLICIT - Uses default (LOWEST) +// $pool price will use LOWEST by default +``` + +### 6. Check Availability Before Booking + +```php +// ✅ CORRECT +if ($pool->isPoolAvailable($from, $until, $quantity)) { + $cart->addToCart($pool, $quantity, [], $from, $until); +} + +// ❌ INCORRECT - No availability check +$cart->addToCart($pool, $quantity, [], $from, $until); +// May fail if not enough stock! +``` + +## Troubleshooting + +### "Pool product has no single items to claim" + +**Cause:** Pool has no single items attached + +**Solution:** +```php +$pool->attachSingleItems([$item1->id, $item2->id]); +``` + +### "Not enough stock available" + +**Cause:** All single items are claimed/booked + +**Solutions:** +1. Add more single items to the pool +2. Check if claims have expired and need cleanup +3. Verify single items have stock: `$item->increaseStock(1)` + +### Pricing Shows Wrong Value + +**Cause:** Pricing strategy or unavailable items + +**Solution:** +```php +// Check which items are available +$items = $pool->getSingleItemsAvailability($from, $until); + +// Verify pricing strategy +$strategy = $pool->getPricingStrategy(); + +// Check prices from available items only +$price = $pool->getLowestAvailablePoolPrice($from, $until); +``` + +### Single Items Not Released After Cart Deletion + +**Cause:** Metadata not properly storing claimed items + +**Solution:** +```php +// Ensure cart item has metadata +$meta = $cartItem->getMeta(); +$claimedItems = $meta->claimed_single_items ?? []; + +// Manually release if needed +$pool->releasePoolStock($cartItem); +``` + +### Bidirectional Relations Missing + +**Cause:** Used `productRelations()->attach()` instead of `attachSingleItems()` + +**Solution:** +```php +// Always use this method for pools +$pool->attachSingleItems($itemIds); + +// This creates BOTH: +// - Pool → Items (SINGLE) +// - Items → Pool (POOL) +``` + +## Performance Considerations + +### 1. Lazy Loading + +```php +// ❌ N+1 query problem +foreach ($pools as $pool) { + $pool->singleProducts; // Query per pool +} + +// ✅ Eager loading +$pools = Product::with('singleProducts')->where('type', ProductType::POOL)->get(); +``` + +### 2. Availability Caching + +For high-traffic scenarios: + +```php +// Cache availability calendar +$cacheKey = "pool:{$pool->id}:availability:{$from}:{$until}"; +$calendar = Cache::remember($cacheKey, 3600, function() use ($pool, $from, $until) { + return $pool->getPoolAvailabilityCalendar($from, $until); +}); +``` + +### 3. Batch Validation + +```php +// Validate multiple pools at once +$pools->each(function($pool) { + try { + $pool->validatePoolConfiguration(); + } catch (InvalidPoolConfigurationException $e) { + Log::error("Invalid pool: {$pool->name}", ['error' => $e->getMessage()]); + } +}); +``` + +## Related Documentation + +- [Booking Products](./01-booking-products.md) - Understanding single items in pools +- [Product Relations](../05-product-relations.md) - Relation system details +- [Pricing Strategies](../07-pricing-strategies.md) - In-depth pricing documentation +- [Stock Management](../06-stock-management.md) - How stock system works diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..6e9fe19 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,369 @@ +# Laravel Shop Documentation + +## Table of Contents + +### Product Types +- [Booking Products](./ProductTypes/01-booking-products.md) - Time-based reservations and rentals +- [Pool Products](./ProductTypes/02-pool-products.md) - Managing groups of booking items + +### Core Features +- [Products Overview](./01-products.md) - Basic product management +- [Stripe Integration](./02-stripe.md) - Payment processing +- [Purchasing](./03-purchasing.md) - Order and purchase flow +- [Stripe Checkout](./04-stripe-checkout.md) - Checkout integration +- [Product Relations](./05-product-relations.md) - How products relate to each other + +## Quick Start + +### Understanding Booking Products + +Booking products are time-based items that can be reserved for specific date ranges. Perfect for: +- Hotel rooms +- Rental equipment +- Parking spaces +- Event tickets +- Service appointments + +[Learn more →](./ProductTypes/01-booking-products.md) + +### Understanding Pool Products + +Pool products manage groups of individual items as a unified offering. Customers book from a "pool" and the system automatically assigns an available item. Ideal for: +- Hotel room categories ("Standard Rooms") +- Equipment fleets ("Rental Cars - Compact") +- Parking facilities ("Parking Spaces") +- General admission seating + +[Learn more →](./ProductTypes/02-pool-products.md) + +### Understanding Product Relations + +The relation system enables complex product associations for marketing and structural purposes: +- **Marketing**: Upsells, cross-sells, related products +- **Structural**: Variations, bundles, pool/single items + +[Learn more →](./05-product-relations.md) + +## Key Concepts + +### Stock Management +- **Booking Products**: Track time-based availability with claims +- **Pool Products**: Aggregate availability from single items +- **Claims**: Temporary stock reservations (cart, bookings) + +### Pricing Strategies +Pool products support flexible pricing: +- **LOWEST** (default): Cheapest available item +- **HIGHEST**: Most expensive available item +- **AVERAGE**: Average of available items + +Prices are calculated from **available** items only, ensuring customers see accurate pricing. + +### Product Relations +Nine relation types for different purposes: +- RELATED, UPSELL, CROSS_SELL, DOWNSELL, ADD_ON (marketing) +- VARIATION, BUNDLE (structural) +- SINGLE, POOL (special bidirectional for pool management) + +## Common Workflows + +### Creating a Booking Product + +```php +$room = Product::create([ + 'name' => 'Deluxe Suite', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, +]); + +$room->increaseStock(5); // 5 rooms available + +ProductPrice::create([ + 'purchasable_id' => $room->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 20000, // $200/day + 'is_default' => true, +]); +``` + +### Creating a Pool Product + +```php +// 1. Create pool +$pool = Product::create([ + 'name' => 'Parking Spaces', + 'type' => ProductType::POOL, + 'manage_stock' => false, +]); + +// 2. Create single items +$spot1 = Product::create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, +]); +$spot1->increaseStock(1); + +// 3. Link them +$pool->attachSingleItems([$spot1->id, $spot2->id, $spot3->id]); + +// 4. Set pricing strategy +$pool->setPricingStrategy(PricingStrategy::LOWEST); +``` + +### Booking a Product + +```php +$from = Carbon::parse('2025-01-15'); +$until = Carbon::parse('2025-01-20'); + +// Check availability +if ($product->isAvailableForBooking($from, $until, $quantity = 1)) { + // Add to cart (claims stock automatically) + $cart->addToCart($product, 1, [], $from, $until); +} +``` + +### Setting Up Product Relations + +```php +// Cross-sells +$laptop->productRelations()->attach([ + $mouse->id => ['type' => ProductRelationType::CROSS_SELL->value], + $bag->id => ['type' => ProductRelationType::CROSS_SELL->value], +]); + +// Upsells +$basicPlan->productRelations()->attach($premiumPlan->id, [ + 'type' => ProductRelationType::UPSELL->value +]); + +// Retrieve +$crossSells = $laptop->crossSellProducts; +$upsell = $basicPlan->upsellProducts->first(); +``` + +## Architecture Overview + +### Product Types + +``` +ProductType Enum: +├── SIMPLE → Standard products +├── VARIABLE → Products with variations +├── GROUPED → Product groups +├── EXTERNAL → External/affiliate products +├── BOOKING → Time-based reservations ⭐ +├── VARIATION → Variant of a variable product +└── POOL → Container for booking items ⭐ +``` + +### Relation Types + +``` +ProductRelationType Enum: +├── RELATED → Similar products +├── UPSELL → Premium alternatives +├── CROSS_SELL → Complementary products +├── DOWNSELL → Lower-priced alternatives +├── ADD_ON → Optional extras +├── VARIATION → Product variants +├── BUNDLE → Package components +├── SINGLE → Pool → Single items ⭐ +└── POOL → Single item → Pool ⭐ +``` + +### Stock System + +``` +Stock Flow: +├── INCREASE → Add inventory (COMPLETED) +├── DECREASE → Remove inventory (COMPLETED) +├── CLAIMED → Reserve inventory (PENDING) ⭐ +│ ├── Creates DECREASE entry (reduces available) +│ └── Creates CLAIMED entry (tracks reservation) +└── RETURN → Return to inventory (COMPLETED) + +Available Stock = Sum(COMPLETED entries) - Sum(active CLAIMS) +``` + +## Best Practices + +### Booking Products +✅ Always set `manage_stock = true` +✅ Check availability before booking +✅ Validate configuration with `validateBookingConfiguration()` +✅ Handle date ranges properly (from < until) + +### Pool Products +✅ Set pool `manage_stock = false` +✅ Set single items `manage_stock = true` +✅ Use `attachSingleItems()` for bidirectional relations +✅ Validate with `validatePoolConfiguration()` +✅ Set explicit pricing strategy + +### Relations +✅ Use appropriate relation types semantically +✅ Use helper methods (`upsellProducts` not manual queries) +✅ Eager load to avoid N+1 queries +✅ Add `sort_order` for display ordering + +## Troubleshooting + +### "Not enough stock available" +- Check if stock was added: `$product->increaseStock(5)` +- Check if stock is claimed by other bookings +- Verify `manage_stock = true` + +### "Pool product has no single items" +- Use `attachSingleItems()` to link items +- Verify single items exist and have stock + +### Pool/Single relations not bidirectional +- Must use `attachSingleItems()` not regular `attach()` +- This creates both SINGLE and POOL relations automatically + +### Pricing shows wrong value +- Check pricing strategy: `$pool->getPricingStrategy()` +- Verify which items are available: `getSingleItemsAvailability()` +- Remember: only available items are priced + +## Development Tips + +### Testing Bookings + +```php +// Create test booking product +$product = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, +]); +$product->increaseStock(10); + +// Test availability +$this->assertTrue($product->isAvailableForBooking($from, $until, 5)); + +// Test booking +$cart->addToCart($product, 5, [], $from, $until); +$this->assertEquals(5, $product->getAvailableStock()); +``` + +### Testing Pools + +```php +// Create pool with items +$pool = Product::factory()->create(['type' => ProductType::POOL]); +$items = Product::factory()->count(3)->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, +]); +$items->each->increaseStock(1); + +$pool->attachSingleItems($items->pluck('id')->toArray()); + +// Test availability +$this->assertEquals(3, $pool->getPoolMaxQuantity($from, $until)); +``` + +### Debugging Relations + +```php +// Check relation exists +dd($product->productRelations() + ->where('related_product_id', $otherId) + ->where('type', ProductRelationType::RELATED->value) + ->exists()); + +// Check bidirectional +dd($pool->singleProducts()->pluck('id')); +dd($singleItem->poolProducts()->pluck('id')); +``` + +## Performance + +### Eager Loading + +```php +// Load all relations +Product::with([ + 'singleProducts', + 'poolProducts', + 'crossSellProducts', + 'upsellProducts', + 'prices' +])->get(); + +// Load nested +Product::with('singleProducts.prices')->get(); +``` + +### Caching + +```php +// Cache availability calendars +Cache::remember("pool:{$id}:availability", 3600, function() { + return $pool->getPoolAvailabilityCalendar($from, $until); +}); + +// Cache pricing +Cache::remember("pool:{$id}:price", 3600, function() { + return $pool->getCurrentPrice(); +}); +``` + +## API Examples + +### Get Booking Availability + +```php +GET /api/products/{id}/availability?from=2025-01-15&until=2025-01-20 + +Response: +{ + "available": true, + "max_quantity": 5, + "calendar": { + "2025-01-15": 5, + "2025-01-16": 3, + ... + } +} +``` + +### Get Pool Information + +```php +GET /api/products/{id}/pool-details + +Response: +{ + "pool": { + "id": 1, + "name": "Parking Spaces", + "pricing_strategy": "lowest" + }, + "single_items": [ + {"id": 10, "name": "Spot 1", "available": 1}, + {"id": 11, "name": "Spot 2", "available": 0}, + {"id": 12, "name": "Spot 3", "available": 1} + ], + "price_range": { + "min": 2000, + "max": 3000 + } +} +``` + +## Contributing + +When adding new features: +1. Update relevant documentation +2. Add test cases for booking/pool scenarios +3. Consider stock management implications +4. Validate relation logic + +## Support + +For issues or questions: +- Check troubleshooting sections +- Review test cases for examples +- See related documentation for context diff --git a/src/Enums/PricingStrategy.php b/src/Enums/PricingStrategy.php new file mode 100644 index 0000000..b110ad5 --- /dev/null +++ b/src/Enums/PricingStrategy.php @@ -0,0 +1,24 @@ + 'Lowest', + self::HIGHEST => 'Highest', + self::AVERAGE => 'Average', + }; + } + + public static function default(): self + { + return self::LOWEST; + } +} diff --git a/src/Models/Cart.php b/src/Models/Cart.php index 35f64c1..8dc9879 100644 --- a/src/Models/Cart.php +++ b/src/Models/Cart.php @@ -66,9 +66,7 @@ class Cart extends Model public function getTotal(): float { - return $this->items->sum(function ($item) { - return $item->subtotal; - }); + return $this->items()->sum('subtotal'); } public function getTotalItems(): int @@ -209,6 +207,34 @@ class Cart extends Model $until = is_string($parameters['until']) ? Carbon::parse($parameters['until']) : $parameters['until']; } + // For pool products with quantity > 1, add them one at a time to get progressive pricing + if ($cartable instanceof Product && $cartable->isPool() && $quantity > 1) { + // Pre-validate that we have enough total availability + // This prevents creating partial batches when stock is insufficient + if ($from && $until) { + $available = $cartable->getPoolMaxQuantity($from, $until); + if ($available !== PHP_INT_MAX && $quantity > $available) { + throw new \Blax\Shop\Exceptions\NotEnoughStockException( + "Pool product '{$cartable->name}' has only {$available} items available for the requested period. Requested: {$quantity}" + ); + } + } else { + $available = $cartable->getPoolMaxQuantity(); + if ($available !== PHP_INT_MAX && $quantity > $available) { + throw new \Blax\Shop\Exceptions\NotEnoughStockException( + "Pool product '{$cartable->name}' has only {$available} items available. Requested: {$quantity}" + ); + } + } + + // Add items one at a time for progressive pricing + $lastCartItem = null; + for ($i = 0; $i < $quantity; $i++) { + $lastCartItem = $this->addToCart($cartable, 1, $parameters, $from, $until); + } + return $lastCartItem; + } + // Validate Product-specific requirements if ($cartable instanceof Product) { // Validate pricing before adding to cart @@ -266,12 +292,23 @@ class Cart extends Model } } + // For pool products, calculate current quantity in cart once to ensure consistency + // Force fresh query to get latest cart state (important for recursive calls) + $currentQuantityInCart = null; + if ($cartable instanceof Product && $cartable->isPool()) { + $this->unsetRelation('items'); // Clear cached relationship + $currentQuantityInCart = $this->items() + ->where('purchasable_id', $cartable->getKey()) + ->where('purchasable_type', get_class($cartable)) + ->sum('quantity'); + } + // Check if item already exists in cart with same parameters, dates, AND price $existingItem = $this->items() ->where('purchasable_id', $cartable->getKey()) ->where('purchasable_type', get_class($cartable)) ->get() - ->first(function ($item) use ($parameters, $from, $until, $cartable) { + ->first(function ($item) use ($parameters, $from, $until, $cartable, $currentQuantityInCart) { $existingParams = is_array($item->parameters) ? $item->parameters : (array) $item->parameters; @@ -292,16 +329,21 @@ class Cart extends Model ); } - // For pool products, also check if price matches - // Different prices mean different availability/items, so separate cart items + // For pool products, check pricing strategy to determine merge behavior $priceMatch = true; if ($cartable instanceof Product && $cartable->isPool()) { - $currentPrice = $cartable->getCurrentPrice(); + // For pools, always check if prices match to allow merging items with same price + $currentPrice = $cartable->getNextAvailablePoolPrice($currentQuantityInCart, null, $from, $until); + if (!$currentPrice) { + // Fallback to getCurrentPrice if getNextAvailablePoolPrice returns null (no single items) + $currentPrice = $cartable->getCurrentPrice(); + } if ($from && $until) { $days = max(1, $from->diff($until)->days); $currentPrice *= $days; } - // Compare with small delta to account for floating point precision + + // Compare prices - merge if prices match $priceMatch = abs((float)$item->price - $currentPrice) < 0.01; } @@ -309,12 +351,30 @@ class Cart extends Model }); // Calculate price per day (base price) - $pricePerDay = $cartable->getCurrentPrice(); - $regularPricePerDay = $cartable->getCurrentPrice(false) ?? $pricePerDay; + // For pool products, get price based on how many items are already in cart + if ($cartable instanceof Product && $cartable->isPool()) { + // Use the quantity we calculated earlier for consistency + // Get price for the next available item + $pricePerDay = $cartable->getNextAvailablePoolPrice($currentQuantityInCart, null, $from, $until); + $regularPricePerDay = $cartable->getNextAvailablePoolPrice($currentQuantityInCart, false, $from, $until) ?? $pricePerDay; + + // If no price found from pool items, try the pool's direct price as fallback + if ($pricePerDay === null && $cartable->hasPrice()) { + $pricePerDay = $cartable->defaultPrice()->first()?->getCurrentPrice($cartable->isOnSale()); + $regularPricePerDay = $cartable->defaultPrice()->first()?->getCurrentPrice(false) ?? $pricePerDay; + } + } else { + $pricePerDay = $cartable->getCurrentPrice(); + $regularPricePerDay = $cartable->getCurrentPrice(false) ?? $pricePerDay; + } // Ensure prices are not null if ($pricePerDay === null) { - throw new \Exception("Product '{$cartable->name}' has no valid price."); + $debugInfo = ''; + if ($cartable instanceof Product && $cartable->isPool()) { + $debugInfo = " (Pool product, currentQuantityInCart: {$currentQuantityInCart}, hasPrice: " . ($cartable->hasPrice() ? 'yes' : 'no') . ")"; + } + throw new \Exception("Product '{$cartable->name}' has no valid price.{$debugInfo}"); } // Calculate days if booking dates provided @@ -327,6 +387,11 @@ class Cart extends Model $pricePerUnit = $pricePerDay * $days; $regularPricePerUnit = $regularPricePerDay * $days; + // Defensive check - ensure pricePerUnit is not null + if ($pricePerUnit === null) { + throw new \Exception("Cart item price calculation resulted in null for '{$cartable->name}' (pricePerDay: {$pricePerDay}, days: {$days})"); + } + // Calculate total price $totalPrice = $pricePerUnit * $quantity; @@ -354,7 +419,7 @@ class Cart extends Model 'until' => $until, ]); - return $cartItem->fresh(); + return $cartItem; } public function removeFromCart( diff --git a/src/Models/Product.php b/src/Models/Product.php index b69f39d..6bc12ac 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -18,6 +18,7 @@ use Blax\Shop\Exceptions\InvalidBookingConfigurationException; use Blax\Shop\Exceptions\InvalidPoolConfigurationException; use Blax\Shop\Traits\HasCategories; use Blax\Shop\Traits\HasPrices; +use Blax\Shop\Traits\HasPricingStrategy; use Blax\Shop\Traits\HasProductRelations; use Blax\Shop\Traits\HasStocks; use Blax\Shop\Traits\MayBePoolProduct; @@ -30,7 +31,7 @@ use Illuminate\Support\Facades\Cache; class Product extends Model implements Purchasable, Cartable { - use HasFactory, HasUuids, HasMetaTranslation, HasStocks, HasPrices, HasCategories, HasProductRelations, MayBePoolProduct; + use HasFactory, HasUuids, HasMetaTranslation, HasStocks, HasPrices, HasPricingStrategy, HasCategories, HasProductRelations, MayBePoolProduct; protected $fillable = [ 'slug', @@ -369,11 +370,27 @@ class Product extends Model implements Purchasable, Cartable /** * Get the current price with pool product inheritance support */ - public function getCurrentPrice(bool|null $sales_price = null): ?float + public function getCurrentPrice(bool|null $sales_price = null, mixed $cart = null): ?float { - // If this is a pool product, use the trait method + // If this is a pool product, use cart-aware pricing if cart is provided if ($this->isPool()) { - return $this->getPoolCurrentPrice($sales_price); + // If no cart provided, try to get the current user's cart + if (!$cart && auth()->check()) { + $cart = auth()->user()->currentCart(); + } + + if ($cart) { + // Cart-aware: Get price for next available item after what's in cart + $currentQuantityInCart = $cart->items() + ->where('purchasable_id', $this->getKey()) + ->where('purchasable_type', get_class($this)) + ->sum('quantity'); + + return $this->getNextAvailablePoolPrice($currentQuantityInCart, $sales_price); + } + + // No cart and no user: Get inherited price based on strategy (lowest/highest/average of ALL available items) + return $this->getInheritedPoolPrice($sales_price); } // For non-pool products, use the trait's default behavior diff --git a/src/Traits/HasPrices.php b/src/Traits/HasPrices.php index 0c9bdc2..e14a4dc 100644 --- a/src/Traits/HasPrices.php +++ b/src/Traits/HasPrices.php @@ -18,8 +18,18 @@ trait HasPrices ); } - public function getCurrentPrice(bool|null $sales_price = null): ?float + public function getCurrentPrice(bool|null $sales_price = null, mixed $cart = null): ?float { + // For pool products with a cart, get dynamic pricing based on cart state + if ($cart && method_exists($this, 'isPool') && $this->isPool()) { + $currentQuantityInCart = $cart->items() + ->where('purchasable_id', $this->getKey()) + ->where('purchasable_type', get_class($this)) + ->sum('quantity'); + + return $this->getNextAvailablePoolPrice($currentQuantityInCart, $sales_price); + } + return $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale()); } diff --git a/src/Traits/HasPricingStrategy.php b/src/Traits/HasPricingStrategy.php new file mode 100644 index 0000000..7225f26 --- /dev/null +++ b/src/Traits/HasPricingStrategy.php @@ -0,0 +1,29 @@ +getMeta(); + $strategyValue = $meta->pricing_strategy ?? PricingStrategy::default()->value; + + return PricingStrategy::tryFrom($strategyValue) ?? PricingStrategy::default(); + } + + /** + * Set the pricing strategy + */ + public function setPricingStrategy(PricingStrategy $strategy): void + { + $this->updateMetaKey('pricing_strategy', $strategy->value); + $this->save(); + } +} diff --git a/src/Traits/HasShoppingCapabilities.php b/src/Traits/HasShoppingCapabilities.php index 3c02d47..a26f1a7 100644 --- a/src/Traits/HasShoppingCapabilities.php +++ b/src/Traits/HasShoppingCapabilities.php @@ -9,11 +9,9 @@ use Blax\Shop\Exceptions\MultiplePurchaseOptions; use Blax\Shop\Exceptions\NotEnoughStockException; use Blax\Shop\Exceptions\NotPurchasable; use Blax\Shop\Models\Cart; -use Blax\Shop\Models\CartItem; use Blax\Shop\Models\ProductPurchase; use Blax\Shop\Models\Product; use Blax\Shop\Models\ProductPrice; -use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Support\Collection; @@ -107,7 +105,7 @@ trait HasShoppingCapabilities // Handle booking products $isBooking = $product->type === ProductType::BOOKING; - + if ($isBooking && (!$from || !$until)) { throw new \Exception("Booking products require 'from' and 'until' dates"); } diff --git a/src/Traits/MayBePoolProduct.php b/src/Traits/MayBePoolProduct.php index b619ab4..2afcdb8 100644 --- a/src/Traits/MayBePoolProduct.php +++ b/src/Traits/MayBePoolProduct.php @@ -4,6 +4,7 @@ namespace Blax\Shop\Traits; use Blax\Shop\Enums\ProductRelationType; use Blax\Shop\Enums\ProductType; +use Blax\Shop\Enums\PricingStrategy; use Blax\Shop\Enums\StockStatus; use Blax\Shop\Enums\StockType; use Blax\Shop\Exceptions\InvalidPoolConfigurationException; @@ -227,14 +228,15 @@ trait MayBePoolProduct /** * Get inherited price from single items based on pricing strategy + * Gets prices from available (not yet claimed) single items */ - protected function getInheritedPoolPrice(bool|null $sales_price = null): ?float + public function getInheritedPoolPrice(bool|null $sales_price = null, ?\DateTimeInterface $from = null, ?\DateTimeInterface $until = null): ?float { if (!$this->isPool()) { return null; } - $strategy = $this->getPoolPricingStrategy(); + $strategy = $this->getPricingStrategy(); $singleItems = $this->singleProducts; @@ -242,7 +244,19 @@ trait MayBePoolProduct return null; } - $prices = $singleItems->map(function ($item) use ($sales_price) { + // Get available prices from single items (filtering out claimed items) + $prices = $singleItems->map(function ($item) use ($sales_price, $from, $until) { + // Only get price if the item is available + if ($from && $until) { + if (!$item->isAvailableForBooking($from, $until, 1)) { + return null; + } + } else { + if ($item->getAvailableStock() <= 0 && $item->manage_stock) { + return null; + } + } + return $item->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $item->isOnSale()); })->filter()->values(); @@ -251,41 +265,349 @@ trait MayBePoolProduct } return match ($strategy) { - 'lowest' => $prices->min(), - 'highest' => $prices->max(), - 'average' => round($prices->avg()), - default => round($prices->avg()), // Default to average + PricingStrategy::LOWEST => $prices->min(), + PricingStrategy::HIGHEST => $prices->max(), + PricingStrategy::AVERAGE => round($prices->avg()), }; } /** - * Get the pool pricing strategy from metadata + * Get the lowest price from available single items */ - public function getPoolPricingStrategy(): string + public function getLowestAvailablePoolPrice(?\DateTimeInterface $from = null, ?\DateTimeInterface $until = null, mixed $cart = null): ?float { if (!$this->isPool()) { - return 'average'; + return null; } - $meta = $this->getMeta(); - return $meta->pricing_strategy ?? 'average'; + // If no cart provided, try to get the current user's cart + if (!$cart && auth()->check()) { + $cart = auth()->user()->currentCart(); + } + + // If cart is provided, use dynamic pricing based on cart state + if ($cart) { + $currentQuantityInCart = $cart->items() + ->where('purchasable_id', $this->getKey()) + ->where('purchasable_type', get_class($this)) + ->sum('quantity'); + + return $this->getNextAvailablePoolPrice($currentQuantityInCart, null, $from, $until); + } + + $singleItems = $this->singleProducts; + + if ($singleItems->isEmpty()) { + return null; + } + + $prices = $singleItems->map(function ($item) use ($from, $until) { + // Only get price if the item is available + if ($from && $until) { + if (!$item->isAvailableForBooking($from, $until, 1)) { + return null; + } + } else { + if ($item->getAvailableStock() <= 0 && $item->manage_stock) { + return null; + } + } + + return $item->defaultPrice()->first()?->getCurrentPrice($item->isOnSale()); + })->filter()->values(); + + return $prices->isEmpty() ? null : $prices->min(); } /** - * Set the pool pricing strategy + * Get the highest price from available single items */ - public function setPoolPricingStrategy(string $strategy): void + public function getHighestAvailablePoolPrice(?\DateTimeInterface $from = null, ?\DateTimeInterface $until = null, mixed $cart = null): ?float + { + if (!$this->isPool()) { + return null; + } + + // If no cart provided, try to get the current user's cart + if (!$cart && auth()->check()) { + $cart = auth()->user()->currentCart(); + } + + // If cart is provided, get the highest price from remaining available items + if ($cart) { + $currentQuantityInCart = $cart->items() + ->where('purchasable_id', $this->getKey()) + ->where('purchasable_type', get_class($this)) + ->sum('quantity'); + + // Get the pool's actual pricing strategy to determine allocation order + $strategy = $this->getPricingStrategy(); + + // Get available items + $singleItems = $this->singleProducts; + + if ($singleItems->isEmpty()) { + return null; + } + + // Build a list of all available item prices with their quantities + $availableItems = []; + + foreach ($singleItems as $item) { + // Check if item is available + $available = 0; + + if ($from && $until) { + if ($item->isBooking() && $item->isAvailableForBooking($from, $until, 1)) { + $available = $item->getAvailableStock(); + } elseif (!$item->isBooking()) { + $available = $item->getAvailableStock(); + } + } else { + if ($item->manage_stock) { + $available = $item->getAvailableStock(); + } else { + $available = PHP_INT_MAX; + } + } + + if ($available > 0) { + $price = $item->defaultPrice()->first()?->getCurrentPrice($item->isOnSale()); + + // If no price on single item but pool has direct price, use pool's price + if ($price === null && $this->hasPrice()) { + $price = $this->defaultPrice()->first()?->getCurrentPrice($this->isOnSale()); + } + + if ($price !== null) { + $availableItems[] = [ + 'price' => $price, + 'quantity' => $available, + ]; + } + } + } + + if (empty($availableItems)) { + return null; + } + + // Sort items based on the pool's actual pricing strategy to determine allocation order + usort($availableItems, function ($a, $b) use ($strategy) { + return match ($strategy) { + PricingStrategy::LOWEST => $a['price'] <=> $b['price'], + PricingStrategy::HIGHEST => $b['price'] <=> $a['price'], + PricingStrategy::AVERAGE => $a['price'] <=> $b['price'], + }; + }); + + // Skip through items based on allocation order, then get highest of remaining + $skipped = 0; + $remainingItems = []; + + foreach ($availableItems as $item) { + if ($skipped >= $currentQuantityInCart) { + // All cart items have been accounted for, these are remaining + $remainingItems[] = $item; + } else { + $skipFromThis = min($item['quantity'], $currentQuantityInCart - $skipped); + $skipped += $skipFromThis; + + // If there are items left in this batch after skipping + if ($item['quantity'] > $skipFromThis) { + $remainingItems[] = [ + 'price' => $item['price'], + 'quantity' => $item['quantity'] - $skipFromThis, + ]; + } + } + } + + // Return the highest price from remaining items + if (empty($remainingItems)) { + return null; + } + + return max(array_column($remainingItems, 'price')); + } + + $singleItems = $this->singleProducts; + + if ($singleItems->isEmpty()) { + return null; + } + + $prices = $singleItems->map(function ($item) use ($from, $until) { + // Only get price if the item is available + if ($from && $until) { + if (!$item->isAvailableForBooking($from, $until, 1)) { + return null; + } + } else { + if ($item->getAvailableStock() <= 0 && $item->manage_stock) { + return null; + } + } + + return $item->defaultPrice()->first()?->getCurrentPrice($item->isOnSale()); + })->filter()->values(); + + return $prices->isEmpty() ? null : $prices->max(); + } + + /** + * Set the pool pricing strategy (for backwards compatibility) + */ + public function setPoolPricingStrategy(string|PricingStrategy $strategy): void { if (!$this->isPool()) { throw new \Exception('This method is only for pool products'); } - if (!in_array($strategy, ['average', 'lowest', 'highest'])) { - throw new \InvalidArgumentException("Invalid pricing strategy: {$strategy}"); + // Handle both string and enum inputs + if (is_string($strategy)) { + $strategyEnum = PricingStrategy::tryFrom($strategy); + if (!$strategyEnum) { + throw new \InvalidArgumentException("Invalid pricing strategy: {$strategy}"); + } + $strategy = $strategyEnum; } - $this->updateMetaKey('pricing_strategy', $strategy); - $this->save(); + $this->setPricingStrategy($strategy); + } + + /** + * Get the price for the next available item from the pool + * considering how many items have already been allocated/claimed + * + * This method simulates "picking" items from the pool in order of the pricing strategy + * and returns the price of the Nth item + * + * @param int $skipQuantity How many items to skip (already allocated) + * @param bool|null $sales_price Whether to get sale price + * @param \DateTimeInterface|null $from Start date for availability check + * @param \DateTimeInterface|null $until End date for availability check + * @return float|null Price of the next available item + */ + public function getNextAvailablePoolPrice( + int $skipQuantity = 0, + bool|null $sales_price = null, + ?\DateTimeInterface $from = null, + ?\DateTimeInterface $until = null + ): ?float { + if (!$this->isPool()) { + return null; + } + + $strategy = $this->getPricingStrategy(); + $singleItems = $this->singleProducts; + + if ($singleItems->isEmpty()) { + return null; + } + + // Build a list of all available item prices with their quantities + $availableItems = []; + + foreach ($singleItems as $item) { + // Check if item is available + $available = 0; + + if ($from && $until) { + if ($item->isBooking()) { + // For booking items, calculate actual available quantity during the period + if (!$item->manage_stock) { + $available = PHP_INT_MAX; + } else { + // Calculate overlapping claims for this specific period + $overlappingClaims = $item->stocks() + ->where('type', \Blax\Shop\Enums\StockType::CLAIMED->value) + ->where('status', \Blax\Shop\Enums\StockStatus::PENDING->value) + ->where(function ($query) use ($from, $until) { + $query->where(function ($q) use ($from, $until) { + $q->whereBetween('claimed_from', [$from, $until]); + })->orWhere(function ($q) use ($from, $until) { + $q->whereBetween('expires_at', [$from, $until]); + })->orWhere(function ($q) use ($from, $until) { + $q->where('claimed_from', '<=', $from) + ->where('expires_at', '>=', $until); + })->orWhere(function ($q) use ($from, $until) { + $q->whereNull('claimed_from') + ->where(function ($subQ) use ($from, $until) { + $subQ->whereNull('expires_at') + ->orWhere('expires_at', '>=', $from); + }); + }); + }) + ->sum('quantity'); + + $available = max(0, $item->getAvailableStock() - abs($overlappingClaims)); + } + } elseif (!$item->isBooking()) { + $available = $item->getAvailableStock(); + } + } else { + if ($item->manage_stock) { + $available = $item->getAvailableStock(); + } else { + $available = PHP_INT_MAX; + } + } + + if ($available > 0) { + $price = $item->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $item->isOnSale()); + + // If no price on single item but pool has direct price, use pool's price + if ($price === null && $this->hasPrice()) { + $price = $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale()); + } + + if ($price !== null) { + $availableItems[] = [ + 'price' => $price, + 'quantity' => $available, + 'item' => $item, + ]; + } + } + } + + if (empty($availableItems)) { + return null; + } + + // For AVERAGE strategy, return the average price of all available items + if ($strategy === PricingStrategy::AVERAGE) { + $totalPrice = 0; + $totalQuantity = 0; + foreach ($availableItems as $item) { + $totalPrice += $item['price'] * $item['quantity']; + $totalQuantity += $item['quantity']; + } + return $totalQuantity > 0 ? $totalPrice / $totalQuantity : null; + } + + // Sort items based on pricing strategy (for LOWEST and HIGHEST) + usort($availableItems, function ($a, $b) use ($strategy) { + return match ($strategy) { + PricingStrategy::LOWEST => $a['price'] <=> $b['price'], + PricingStrategy::HIGHEST => $b['price'] <=> $a['price'], + PricingStrategy::AVERAGE => 0, // Already handled above + }; + }); + + // Skip through items based on $skipQuantity + $skipped = 0; + foreach ($availableItems as $item) { + if ($skipped + $item['quantity'] > $skipQuantity) { + // This is the item we want + return $item['price']; + } + $skipped += $item['quantity']; + } + + // If we've skipped past all items, return null + return null; } /** @@ -344,38 +666,16 @@ trait MayBePoolProduct } /** - * Get the highest price from single items + * Get the price range for pool products (from available items) */ - public function getHighestPoolPrice(): ?float + public function getPoolPriceRange(?\DateTimeInterface $from = null, ?\DateTimeInterface $until = null): ?array { if (!$this->isPool()) { return null; } - $singleItems = $this->singleProducts; - - if ($singleItems->isEmpty()) { - return null; - } - - $prices = $singleItems->map(function ($item) { - return $item->defaultPrice()->first()?->getCurrentPrice($item->isOnSale()); - })->filter()->values(); - - return $prices->isEmpty() ? null : $prices->max(); - } - - /** - * Get the price range for pool products - */ - public function getPoolPriceRange(): ?array - { - if (!$this->isPool()) { - return null; - } - - $lowest = $this->getLowestPoolPrice(); - $highest = $this->getHighestPoolPrice(); + $lowest = $this->getLowestAvailablePoolPrice($from, $until); + $highest = $this->getHighestAvailablePoolPrice($from, $until); if ($lowest === null || $highest === null) { return null; diff --git a/tests/Feature/CartAddToCartPoolPricingTest.php b/tests/Feature/CartAddToCartPoolPricingTest.php index 6dbdb0a..cd931ec 100644 --- a/tests/Feature/CartAddToCartPoolPricingTest.php +++ b/tests/Feature/CartAddToCartPoolPricingTest.php @@ -4,6 +4,8 @@ namespace Blax\Shop\Tests\Feature; use Blax\Shop\Enums\ProductRelationType; use Blax\Shop\Enums\ProductType; +use Blax\Shop\Enums\PricingStrategy; +use Blax\Shop\Enums\StockType; use Blax\Shop\Models\Cart; use Blax\Shop\Models\Product; use Blax\Shop\Models\ProductPrice; @@ -103,6 +105,9 @@ class CartAddToCartPoolPricingTest extends TestCase 'is_default' => true, ]); + // Set pricing strategy to average: (2000 + 5000) / 2 = 3500 + $this->poolProduct->setPricingStrategy(PricingStrategy::AVERAGE); + // Pool should inherit average: (2000 + 5000) / 2 = 3500 $cartItem = $this->cart->addToCart($this->poolProduct, 1); @@ -164,6 +169,9 @@ class CartAddToCartPoolPricingTest extends TestCase $until = Carbon::now()->addDays(3)->startOfDay(); // 2 days $days = $from->diffInDays($until); + // Set pricing strategy to average: (2000 + 5000) / 2 = 3500 per day + $this->poolProduct->setPricingStrategy(PricingStrategy::AVERAGE); + // Pool inherits average: (2000 + 5000) / 2 = 3500 per day $cartItem = $this->cart->addToCart($this->poolProduct, 1, [], $from, $until); @@ -448,6 +456,9 @@ class CartAddToCartPoolPricingTest extends TestCase $from = Carbon::now()->addDays(1)->startOfDay(); $until = Carbon::now()->addDays(2)->startOfDay(); // 1 day + // Set pricing strategy to average + $this->poolProduct->setPricingStrategy(PricingStrategy::AVERAGE); + $cartItem = $this->cart->addToCart($this->poolProduct, 1, [], $from, $until); // Average sale price: (3000 + 5000) / 2 = 4000 per day @@ -992,4 +1003,255 @@ class CartAddToCartPoolPricingTest extends TestCase $this->assertNotNull($cartItem2); $this->assertEquals(500, $cartItem2->quantity); } + + /** @test */ + public function it_picks_correct_price_for_pool_and_items_and_respects_stocks() + { + $this->actingAs($this->user); + + $pool = Product::factory() + ->withPrices(1, 5000) // 50€ + ->create([ + 'name' => 'Parking Pool', + 'type' => ProductType::POOL + ]); + + $spot1 = Product::factory() + ->withStocks(2) + ->withPrices(1, 2000) // 20€ + ->create([ + 'name' => 'Spot 1', + 'type' => ProductType::BOOKING, + ]); + + $spot2 = Product::factory() + ->withStocks(2) + ->create([ + 'name' => 'Spot 2', + 'type' => ProductType::BOOKING, + ]); + + $spot3 = Product::factory() + ->withStocks(2) + ->withPrices(1, 8000) // 80€ + ->create([ + 'name' => 'Spot 3', + 'type' => ProductType::BOOKING, + ]); + + $pool->attachSingleItems([ + $spot1->id, + $spot2->id, + $spot3->id + ]); + + // Pool should have unlimited availability + $this->assertEquals(6, $pool->getAvailableQuantity()); + + $pool->setPoolPricingStrategy('lowest'); + + $cart = $this->user->currentCart(); + + $this->assertEquals(0, $cart->items()->count()); + + $this->assertThrows( + fn() => $cartItem = $cart->addToCart($pool, 1000), + \Blax\Shop\Exceptions\NotEnoughStockException::class + ); + + // 1. Addition + $this->assertEquals(2000, $pool->getCurrentPrice(cart: $cart)); // 20.00 + $this->assertEquals(2000, $pool->getLowestAvailablePoolPrice(cart: $cart)); // 20.00 + $this->assertEquals(8000, $pool->getHighestAvailablePoolPrice(cart: $cart)); // 80.00 + $cartItem = $cart->addToCart($pool, 1); + + $this->assertNotNull($cartItem); + + // 2. Addition + $this->assertEquals(2000, $pool->getCurrentPrice()); // 20.00 + $this->assertEquals(2000, $pool->getLowestAvailablePoolPrice()); // 20.00 + $this->assertEquals(8000, $pool->getHighestAvailablePoolPrice()); // 80.00 + $cartItem = $cart->addToCart($pool, 1); + + $this->assertNotNull($cartItem); + $this->assertEquals(4000, $cartItem->subtotal); // 20.00 × 2 + + // 3. Addition + $this->assertEquals(5000, $pool->getCurrentPrice(cart: $cart)); // 50.00 + $this->assertEquals(5000, $pool->getLowestAvailablePoolPrice(cart: $cart)); // 50.00 + $this->assertEquals(8000, $pool->getHighestAvailablePoolPrice(cart: $cart)); // 80.00 + $cartItem = $cart->addToCart($pool, 1); + + $this->assertNotNull($cartItem); + $this->assertEquals(5000, $cartItem->price); // Next lowest (inherited from pool): 50.00 + $this->assertEquals(5000, $cartItem->subtotal); // 50.00 (not cumulative) + + // 4. Addition + $this->assertEquals(5000, $pool->getCurrentPrice()); // 50.00 + $this->assertEquals(5000, $pool->getLowestAvailablePoolPrice()); // 50.00 + $this->assertEquals(8000, $pool->getHighestAvailablePoolPrice()); // 80.00 + $cartItem = $cart->addToCart($pool, 1); + + $this->assertNotNull($cartItem); + $this->assertEquals(5000, $cartItem->price); // Next lowest (inherited from pool): 50.00 + $this->assertEquals(10000, $cartItem->subtotal); // 50.00 × 2 (merged) + + // 5. Addition + $this->assertEquals(8000, $pool->getCurrentPrice(cart: $cart)); // 80.00 + $this->assertEquals(8000, $pool->getLowestAvailablePoolPrice(cart: $cart)); // 80.00 + $this->assertEquals(8000, $pool->getHighestAvailablePoolPrice(cart: $cart)); // 80.00 + $cartItem = $cart->addToCart($pool, 1); + + $this->assertNotNull($cartItem); + $this->assertEquals(8000, $cartItem->price); // Next lowest: 80.00 + $this->assertEquals(8000, $cartItem->subtotal); // 80.00 + + // 6. Addition + $this->assertEquals(8000, $pool->getCurrentPrice()); // 80.00 + $this->assertEquals(8000, $pool->getLowestAvailablePoolPrice()); // 80.00 + $this->assertEquals(8000, $pool->getHighestAvailablePoolPrice()); // 80.00 + $cartItem = $cart->addToCart($pool, 1); + + $this->assertNotNull($cartItem); + $this->assertEquals(8000, $cartItem->price); // Next lowest: 80.00 + $this->assertEquals(16000, $cartItem->subtotal); // 80.00 × 2 (merged) + + $this->assertEquals(3, $cart->items()->count()); + + $this->assertNull($pool->getCurrentPrice()); + $this->assertNull($pool->getLowestAvailablePoolPrice()); + $this->assertNull($pool->getHighestAvailablePoolPrice()); + $this->assertNull($pool->getCurrentPrice(cart: $cart)); + $this->assertNull($pool->getLowestAvailablePoolPrice(cart: $cart)); + $this->assertNull($pool->getHighestAvailablePoolPrice(cart: $cart)); + + + // 7. Addition + $this->assertThrows( + fn() => $cart->addToCart($pool, 1), + \Blax\Shop\Exceptions\NotEnoughStockException::class + ); + } + + /** @test */ + public function it_picks_correct_price_respects_stocks_respects_timespan_for_price() + { + $this->actingAs($this->user); + + $pool = Product::factory() + ->withPrices(1, 5000) // 50€ + ->create([ + 'name' => 'Parking Pool', + 'type' => ProductType::POOL + ]); + + $spot1 = Product::factory() + ->withStocks(2) + ->withPrices(1, 2000) // 20€ + ->create([ + 'name' => 'Spot 1', + 'type' => ProductType::BOOKING, + ]); + + $spot2 = Product::factory() + ->withStocks(2) + ->create([ + 'name' => 'Spot 2', + 'type' => ProductType::BOOKING, + ]); + + $spot3 = Product::factory() + ->withStocks(2) + ->withPrices(1, 8000) // 80€ + ->create([ + 'name' => 'Spot 3', + 'type' => ProductType::BOOKING, + ]); + + $pool->attachSingleItems([ + $spot1->id, + $spot2->id, + $spot3->id + ]); + + // Pool should have unlimited availability + $this->assertEquals(6, $pool->getAvailableQuantity()); + + $pool->setPoolPricingStrategy('lowest'); + + $cart = $this->user->currentCart(); + + $this->assertEquals(0, $cart->items()->count()); + + $this->assertThrows( + fn() => $cartItem = $cart->addToCart($pool, 1000), + \Blax\Shop\Exceptions\NotEnoughStockException::class + ); + + $cart->addToCart( + $pool, + 3, + [], + now()->addWeek(), + now()->addWeek()->addDays(5) + ); + + $this->assertEquals( + (2000 * 2 * 5) + (5000 * 1 * 5), + $cart->getTotal() + ); + + $cart->addToCart( + $pool, + 3, + [], + now()->addWeek(), + now()->addWeek()->addDays(5) + ); + + $this->assertEquals( + (2000 * 2 * 5) + (5000 * 2 * 5) + (8000 * 2 * 5), + $cart->getTotal() + ); + + $this->assertEquals(3, $cart->items()->count()); + + // Clear cart + $cart->items()->delete(); + + $this->assertEquals(0, $cart->items()->count()); + $this->assertEquals(0, $cart->getTotal()); + + // Make one spot unavailable for part of the period + $spot2->adjustStock( + StockType::CLAIMED, + 1, + from: now()->addWeek()->addDays(2), + until: now()->addWeek()->addDays(3) + ); + + $this->assertThrows( + fn() => $cart->addToCart( + $pool, + 6, + [], + now()->addWeek(), + now()->addWeek()->addDays(5) + ), + \Blax\Shop\Exceptions\NotEnoughStockException::class + ); + + $cart->addToCart( + $pool, + 5, + [], + now()->addWeek(), + now()->addWeek()->addDays(5) + ); + + $this->assertEquals( + (2000 * 2 * 5) + (5000 * 1 * 5) + (8000 * 2 * 5), + $cart->getTotal() + ); + } } diff --git a/tests/Feature/PoolProductPricingTest.php b/tests/Feature/PoolProductPricingTest.php index 2f4361f..e48ecec 100644 --- a/tests/Feature/PoolProductPricingTest.php +++ b/tests/Feature/PoolProductPricingTest.php @@ -4,6 +4,7 @@ namespace Blax\Shop\Tests\Feature; use Blax\Shop\Enums\ProductRelationType; use Blax\Shop\Enums\ProductType; +use Blax\Shop\Enums\PricingStrategy; use Blax\Shop\Models\Product; use Blax\Shop\Models\ProductPrice; use Blax\Shop\Tests\TestCase; @@ -130,6 +131,9 @@ class PoolProductPricingTest extends TestCase 'is_default' => true, ]); + // Set pricing strategy to average + $this->poolProduct->setPricingStrategy(PricingStrategy::AVERAGE); + // Pool should inherit average: (5000 + 7000) / 2 = 6000 $price = $this->poolProduct->getCurrentPrice(); @@ -164,7 +168,7 @@ class PoolProductPricingTest extends TestCase 'is_default' => true, ]); - $lowestPrice = $this->poolProduct->getLowestPoolPrice(); + $lowestPrice = $this->poolProduct->getLowestAvailablePoolPrice(); $this->assertEquals(5000, $lowestPrice); } @@ -188,7 +192,7 @@ class PoolProductPricingTest extends TestCase 'is_default' => true, ]); - $highestPrice = $this->poolProduct->getHighestPoolPrice(); + $highestPrice = $this->poolProduct->getHighestAvailablePoolPrice(); $this->assertEquals(7000, $highestPrice); } @@ -394,6 +398,9 @@ class PoolProductPricingTest extends TestCase $price1->update(['unit_amount' => 6000]); $this->poolProduct->refresh(); + // Set to average pricing to see the average of 6000 and 5000 + $this->poolProduct->setPricingStrategy(PricingStrategy::AVERAGE); + $updatedPrice = $this->poolProduct->getCurrentPrice(); $this->assertEquals(5500, $updatedPrice); // Average of 6000 and 5000 } @@ -417,8 +424,8 @@ class PoolProductPricingTest extends TestCase 'is_default' => true, ]); - // Store strategy in metadata - $this->poolProduct->updateMetaKey('pricing_strategy', 'lowest'); + // Store strategy in metadata using the enum + $this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST); $price = $this->poolProduct->getCurrentPrice(); diff --git a/tests/Feature/PoolSeparateCartItemsTest.php b/tests/Feature/PoolSeparateCartItemsTest.php index abafcaa..63b433c 100644 --- a/tests/Feature/PoolSeparateCartItemsTest.php +++ b/tests/Feature/PoolSeparateCartItemsTest.php @@ -4,6 +4,7 @@ namespace Blax\Shop\Tests\Feature; use Blax\Shop\Enums\ProductRelationType; use Blax\Shop\Enums\ProductType; +use Blax\Shop\Enums\PricingStrategy; use Blax\Shop\Models\Cart; use Blax\Shop\Models\Product; use Blax\Shop\Models\ProductPrice; @@ -102,6 +103,9 @@ class PoolSeparateCartItemsTest extends TestCase $from = Carbon::now()->addDays(1)->startOfDay(); $until = Carbon::now()->addDays(3)->startOfDay(); + // Use AVERAGE strategy to ensure price change when single items change + $this->pool->setPricingStrategy(PricingStrategy::AVERAGE); + // Add first item $item1 = $this->cart->addToCart($this->pool, 1, [], $from, $until); $price1 = $item1->price;