AIBFR pool/booking/cart
This commit is contained in:
parent
3045f72304
commit
edbf116c48
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue