AIBFR pool/booking/cart

This commit is contained in:
Fabian @ Blax Software 2025-12-16 13:58:03 +01:00
parent 3045f72304
commit edbf116c48
14 changed files with 2828 additions and 68 deletions

View File

@ -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

View File

@ -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

View File

@ -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

369
docs/README.md Normal file
View File

@ -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

View File

@ -0,0 +1,24 @@
<?php
namespace Blax\Shop\Enums;
enum PricingStrategy: string
{
case LOWEST = 'lowest';
case HIGHEST = 'highest';
case AVERAGE = 'average';
public function label(): string
{
return match ($this) {
self::LOWEST => 'Lowest',
self::HIGHEST => 'Highest',
self::AVERAGE => 'Average',
};
}
public static function default(): self
{
return self::LOWEST;
}
}

View File

@ -66,9 +66,7 @@ class Cart extends Model
public function getTotal(): float public function getTotal(): float
{ {
return $this->items->sum(function ($item) { return $this->items()->sum('subtotal');
return $item->subtotal;
});
} }
public function getTotalItems(): int public function getTotalItems(): int
@ -209,6 +207,34 @@ class Cart extends Model
$until = is_string($parameters['until']) ? Carbon::parse($parameters['until']) : $parameters['until']; $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 // Validate Product-specific requirements
if ($cartable instanceof Product) { if ($cartable instanceof Product) {
// Validate pricing before adding to cart // 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 // Check if item already exists in cart with same parameters, dates, AND price
$existingItem = $this->items() $existingItem = $this->items()
->where('purchasable_id', $cartable->getKey()) ->where('purchasable_id', $cartable->getKey())
->where('purchasable_type', get_class($cartable)) ->where('purchasable_type', get_class($cartable))
->get() ->get()
->first(function ($item) use ($parameters, $from, $until, $cartable) { ->first(function ($item) use ($parameters, $from, $until, $cartable, $currentQuantityInCart) {
$existingParams = is_array($item->parameters) $existingParams = is_array($item->parameters)
? $item->parameters ? $item->parameters
: (array) $item->parameters; : (array) $item->parameters;
@ -292,16 +329,21 @@ class Cart extends Model
); );
} }
// For pool products, also check if price matches // For pool products, check pricing strategy to determine merge behavior
// Different prices mean different availability/items, so separate cart items
$priceMatch = true; $priceMatch = true;
if ($cartable instanceof Product && $cartable->isPool()) { 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) { if ($from && $until) {
$days = max(1, $from->diff($until)->days); $days = max(1, $from->diff($until)->days);
$currentPrice *= $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; $priceMatch = abs((float)$item->price - $currentPrice) < 0.01;
} }
@ -309,12 +351,30 @@ class Cart extends Model
}); });
// Calculate price per day (base price) // Calculate price per day (base price)
$pricePerDay = $cartable->getCurrentPrice(); // For pool products, get price based on how many items are already in cart
$regularPricePerDay = $cartable->getCurrentPrice(false) ?? $pricePerDay; 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 // Ensure prices are not null
if ($pricePerDay === 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 // Calculate days if booking dates provided
@ -327,6 +387,11 @@ class Cart extends Model
$pricePerUnit = $pricePerDay * $days; $pricePerUnit = $pricePerDay * $days;
$regularPricePerUnit = $regularPricePerDay * $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 // Calculate total price
$totalPrice = $pricePerUnit * $quantity; $totalPrice = $pricePerUnit * $quantity;
@ -354,7 +419,7 @@ class Cart extends Model
'until' => $until, 'until' => $until,
]); ]);
return $cartItem->fresh(); return $cartItem;
} }
public function removeFromCart( public function removeFromCart(

View File

@ -18,6 +18,7 @@ use Blax\Shop\Exceptions\InvalidBookingConfigurationException;
use Blax\Shop\Exceptions\InvalidPoolConfigurationException; use Blax\Shop\Exceptions\InvalidPoolConfigurationException;
use Blax\Shop\Traits\HasCategories; use Blax\Shop\Traits\HasCategories;
use Blax\Shop\Traits\HasPrices; use Blax\Shop\Traits\HasPrices;
use Blax\Shop\Traits\HasPricingStrategy;
use Blax\Shop\Traits\HasProductRelations; use Blax\Shop\Traits\HasProductRelations;
use Blax\Shop\Traits\HasStocks; use Blax\Shop\Traits\HasStocks;
use Blax\Shop\Traits\MayBePoolProduct; use Blax\Shop\Traits\MayBePoolProduct;
@ -30,7 +31,7 @@ use Illuminate\Support\Facades\Cache;
class Product extends Model implements Purchasable, Cartable 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 = [ protected $fillable = [
'slug', 'slug',
@ -369,11 +370,27 @@ class Product extends Model implements Purchasable, Cartable
/** /**
* Get the current price with pool product inheritance support * 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()) { 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 // For non-pool products, use the trait's default behavior

View File

@ -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()); return $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale());
} }

View File

@ -0,0 +1,29 @@
<?php
namespace Blax\Shop\Traits;
use Blax\Shop\Enums\PricingStrategy;
trait HasPricingStrategy
{
/**
* Get the pricing strategy from metadata
* Defaults to LOWEST if not set
*/
public function getPricingStrategy(): PricingStrategy
{
$meta = $this->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();
}
}

View File

@ -9,11 +9,9 @@ use Blax\Shop\Exceptions\MultiplePurchaseOptions;
use Blax\Shop\Exceptions\NotEnoughStockException; use Blax\Shop\Exceptions\NotEnoughStockException;
use Blax\Shop\Exceptions\NotPurchasable; use Blax\Shop\Exceptions\NotPurchasable;
use Blax\Shop\Models\Cart; use Blax\Shop\Models\Cart;
use Blax\Shop\Models\CartItem;
use Blax\Shop\Models\ProductPurchase; use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice; use Blax\Shop\Models\ProductPrice;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;

View File

@ -4,6 +4,7 @@ namespace Blax\Shop\Traits;
use Blax\Shop\Enums\ProductRelationType; use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\PricingStrategy;
use Blax\Shop\Enums\StockStatus; use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType; use Blax\Shop\Enums\StockType;
use Blax\Shop\Exceptions\InvalidPoolConfigurationException; use Blax\Shop\Exceptions\InvalidPoolConfigurationException;
@ -227,14 +228,15 @@ trait MayBePoolProduct
/** /**
* Get inherited price from single items based on pricing strategy * 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()) { if (!$this->isPool()) {
return null; return null;
} }
$strategy = $this->getPoolPricingStrategy(); $strategy = $this->getPricingStrategy();
$singleItems = $this->singleProducts; $singleItems = $this->singleProducts;
@ -242,7 +244,19 @@ trait MayBePoolProduct
return null; 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()); return $item->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $item->isOnSale());
})->filter()->values(); })->filter()->values();
@ -251,41 +265,349 @@ trait MayBePoolProduct
} }
return match ($strategy) { return match ($strategy) {
'lowest' => $prices->min(), PricingStrategy::LOWEST => $prices->min(),
'highest' => $prices->max(), PricingStrategy::HIGHEST => $prices->max(),
'average' => round($prices->avg()), PricingStrategy::AVERAGE => round($prices->avg()),
default => round($prices->avg()), // Default to average
}; };
} }
/** /**
* 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()) { if (!$this->isPool()) {
return 'average'; return null;
} }
$meta = $this->getMeta(); // If no cart provided, try to get the current user's cart
return $meta->pricing_strategy ?? 'average'; 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()) { if (!$this->isPool()) {
throw new \Exception('This method is only for pool products'); throw new \Exception('This method is only for pool products');
} }
if (!in_array($strategy, ['average', 'lowest', 'highest'])) { // Handle both string and enum inputs
throw new \InvalidArgumentException("Invalid pricing strategy: {$strategy}"); 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->setPricingStrategy($strategy);
$this->save(); }
/**
* 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()) { if (!$this->isPool()) {
return null; return null;
} }
$singleItems = $this->singleProducts; $lowest = $this->getLowestAvailablePoolPrice($from, $until);
$highest = $this->getHighestAvailablePoolPrice($from, $until);
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();
if ($lowest === null || $highest === null) { if ($lowest === null || $highest === null) {
return null; return null;

View File

@ -4,6 +4,8 @@ namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductRelationType; use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\PricingStrategy;
use Blax\Shop\Enums\StockType;
use Blax\Shop\Models\Cart; use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice; use Blax\Shop\Models\ProductPrice;
@ -103,6 +105,9 @@ class CartAddToCartPoolPricingTest extends TestCase
'is_default' => true, '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 // Pool should inherit average: (2000 + 5000) / 2 = 3500
$cartItem = $this->cart->addToCart($this->poolProduct, 1); $cartItem = $this->cart->addToCart($this->poolProduct, 1);
@ -164,6 +169,9 @@ class CartAddToCartPoolPricingTest extends TestCase
$until = Carbon::now()->addDays(3)->startOfDay(); // 2 days $until = Carbon::now()->addDays(3)->startOfDay(); // 2 days
$days = $from->diffInDays($until); $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 // Pool inherits average: (2000 + 5000) / 2 = 3500 per day
$cartItem = $this->cart->addToCart($this->poolProduct, 1, [], $from, $until); $cartItem = $this->cart->addToCart($this->poolProduct, 1, [], $from, $until);
@ -448,6 +456,9 @@ class CartAddToCartPoolPricingTest extends TestCase
$from = Carbon::now()->addDays(1)->startOfDay(); $from = Carbon::now()->addDays(1)->startOfDay();
$until = Carbon::now()->addDays(2)->startOfDay(); // 1 day $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); $cartItem = $this->cart->addToCart($this->poolProduct, 1, [], $from, $until);
// Average sale price: (3000 + 5000) / 2 = 4000 per day // Average sale price: (3000 + 5000) / 2 = 4000 per day
@ -992,4 +1003,255 @@ class CartAddToCartPoolPricingTest extends TestCase
$this->assertNotNull($cartItem2); $this->assertNotNull($cartItem2);
$this->assertEquals(500, $cartItem2->quantity); $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()
);
}
} }

View File

@ -4,6 +4,7 @@ namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductRelationType; use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\PricingStrategy;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice; use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Tests\TestCase; use Blax\Shop\Tests\TestCase;
@ -130,6 +131,9 @@ class PoolProductPricingTest extends TestCase
'is_default' => true, 'is_default' => true,
]); ]);
// Set pricing strategy to average
$this->poolProduct->setPricingStrategy(PricingStrategy::AVERAGE);
// Pool should inherit average: (5000 + 7000) / 2 = 6000 // Pool should inherit average: (5000 + 7000) / 2 = 6000
$price = $this->poolProduct->getCurrentPrice(); $price = $this->poolProduct->getCurrentPrice();
@ -164,7 +168,7 @@ class PoolProductPricingTest extends TestCase
'is_default' => true, 'is_default' => true,
]); ]);
$lowestPrice = $this->poolProduct->getLowestPoolPrice(); $lowestPrice = $this->poolProduct->getLowestAvailablePoolPrice();
$this->assertEquals(5000, $lowestPrice); $this->assertEquals(5000, $lowestPrice);
} }
@ -188,7 +192,7 @@ class PoolProductPricingTest extends TestCase
'is_default' => true, 'is_default' => true,
]); ]);
$highestPrice = $this->poolProduct->getHighestPoolPrice(); $highestPrice = $this->poolProduct->getHighestAvailablePoolPrice();
$this->assertEquals(7000, $highestPrice); $this->assertEquals(7000, $highestPrice);
} }
@ -394,6 +398,9 @@ class PoolProductPricingTest extends TestCase
$price1->update(['unit_amount' => 6000]); $price1->update(['unit_amount' => 6000]);
$this->poolProduct->refresh(); $this->poolProduct->refresh();
// Set to average pricing to see the average of 6000 and 5000
$this->poolProduct->setPricingStrategy(PricingStrategy::AVERAGE);
$updatedPrice = $this->poolProduct->getCurrentPrice(); $updatedPrice = $this->poolProduct->getCurrentPrice();
$this->assertEquals(5500, $updatedPrice); // Average of 6000 and 5000 $this->assertEquals(5500, $updatedPrice); // Average of 6000 and 5000
} }
@ -417,8 +424,8 @@ class PoolProductPricingTest extends TestCase
'is_default' => true, 'is_default' => true,
]); ]);
// Store strategy in metadata // Store strategy in metadata using the enum
$this->poolProduct->updateMetaKey('pricing_strategy', 'lowest'); $this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST);
$price = $this->poolProduct->getCurrentPrice(); $price = $this->poolProduct->getCurrentPrice();

View File

@ -4,6 +4,7 @@ namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductRelationType; use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\PricingStrategy;
use Blax\Shop\Models\Cart; use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice; use Blax\Shop\Models\ProductPrice;
@ -102,6 +103,9 @@ class PoolSeparateCartItemsTest extends TestCase
$from = Carbon::now()->addDays(1)->startOfDay(); $from = Carbon::now()->addDays(1)->startOfDay();
$until = Carbon::now()->addDays(3)->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 // Add first item
$item1 = $this->cart->addToCart($this->pool, 1, [], $from, $until); $item1 = $this->cart->addToCart($this->pool, 1, [], $from, $until);
$price1 = $item1->price; $price1 = $item1->price;