RI optimizations

This commit is contained in:
Fabian @ Blax Software 2025-12-29 11:11:27 +01:00
parent 6e9c9043ae
commit 7aeffd27a9
7 changed files with 202 additions and 79 deletions

View File

@ -87,7 +87,7 @@ class ProductFactory extends Factory
): static { ): static {
return $this->afterCreating(function (Product $product) use ($count, $unit_amount, $sale_unit_amount) { return $this->afterCreating(function (Product $product) use ($count, $unit_amount, $sale_unit_amount) {
// Use realistic price range if not specified // Use realistic price range if not specified
$defaultPrice = $unit_amount ?? $this->faker->randomElement([ $priceAmount = $unit_amount ?? $this->faker->randomElement([
1999, // $19.99 1999, // $19.99
2999, // $29.99 2999, // $29.99
4999, // $49.99 4999, // $49.99
@ -99,21 +99,27 @@ class ProductFactory extends Factory
49999, // $499.99 49999, // $499.99
]); ]);
$prices = \Blax\Shop\Models\ProductPrice::factory() // Create first price with is_default = true to avoid second query
->count($count) \Blax\Shop\Models\ProductPrice::factory()->create([
->create([ 'purchasable_type' => get_class($product),
'purchasable_type' => get_class($product), 'purchasable_id' => $product->id,
'purchasable_id' => $product->id, 'unit_amount' => $priceAmount,
'unit_amount' => $defaultPrice, 'sale_unit_amount' => $sale_unit_amount,
'sale_unit_amount' => $sale_unit_amount, 'currency' => 'EUR',
'currency' => 'EUR', 'is_default' => true,
]); ]);
// Set the first price as default // Create additional prices if count > 1
if ($prices->isNotEmpty()) { if ($count > 1) {
$defaultPrice = $prices->first(); \Blax\Shop\Models\ProductPrice::factory()
$defaultPrice->is_default = true; ->count($count - 1)
$defaultPrice->save(); ->create([
'purchasable_type' => get_class($product),
'purchasable_id' => $product->id,
'unit_amount' => $priceAmount,
'sale_unit_amount' => $sale_unit_amount,
'currency' => 'EUR',
]);
} }
}); });
} }

View File

@ -7,11 +7,9 @@
processIsolation="false" processIsolation="false"
stopOnFailure="false" stopOnFailure="false"
cacheDirectory=".phpunit.cache" cacheDirectory=".phpunit.cache"
displayDetailsOnTestsThatTriggerDeprecations="true" cacheResult="true"
displayDetailsOnTestsThatTriggerErrors="true" executionOrder="defects"
displayDetailsOnTestsThatTriggerNotices="true" beStrictAboutOutputDuringTests="false"
displayDetailsOnTestsThatTriggerWarnings="true"
displayDetailsOnPhpunitDeprecations="true"
> >
<testsuites> <testsuites>
<testsuite name="BlaxShop Test Suite"> <testsuite name="BlaxShop Test Suite">
@ -29,11 +27,12 @@
<coverage includeUncoveredFiles="true" /> <coverage includeUncoveredFiles="true" />
<php> <php>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="mysql"/> <env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/> <env name="DB_DATABASE" value=":memory:"/>
<env name="CACHE_DRIVER" value="array"/> <env name="CACHE_DRIVER" value="array"/>
<env name="SESSION_DRIVER" value="array"/> <env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_DRIVER" value="sync"/> <env name="QUEUE_DRIVER" value="sync"/>
<env name="SHOP_CACHE_ENABLED" value="false"/> <env name="SHOP_CACHE_ENABLED" value="false"/>
<ini name="memory_limit" value="512M"/>
</php> </php>
</phpunit> </phpunit>

View File

@ -285,14 +285,21 @@ class Cart extends Model
*/ */
public function stripePriceIds(): array public function stripePriceIds(): array
{ {
return $this->items->map(function ($item) { // Eager load priceModel to avoid N+1 queries
if (!$item->price_id) { // Note: price() relationship conflicts with price column, so we use explicit loading
return null; $items = $this->items()->get();
}
// Use the relationship method, not property access // Batch load all price IDs
$price = $item->price()->first(); $priceIds = $items->pluck('price_id')->filter()->unique()->values()->toArray();
return $price ? $price->stripe_price_id : null;
if (empty($priceIds)) {
return array_fill(0, $items->count(), null);
}
$prices = ProductPrice::whereIn('id', $priceIds)->pluck('stripe_price_id', 'id');
return $items->map(function ($item) use ($prices) {
return $item->price_id ? ($prices[$item->price_id] ?? null) : null;
})->toArray(); })->toArray();
} }

View File

@ -706,7 +706,8 @@ class Order extends Model
*/ */
public static function getNetRevenue(): int public static function getNetRevenue(): int
{ {
return static::getTotalRevenue() - static::getTotalRefunded(); $result = static::selectRaw('COALESCE(SUM(amount_paid), 0) - COALESCE(SUM(amount_refunded), 0) as net')->first();
return (int) $result->net;
} }
/** /**
@ -739,10 +740,23 @@ class Order extends Model
/** /**
* Get revenue summary for a specific period. * Get revenue summary for a specific period.
* Optimized to use single aggregated query.
*/ */
public static function getRevenueSummary(\DateTimeInterface $from, \DateTimeInterface $until): array public static function getRevenueSummary(\DateTimeInterface $from, \DateTimeInterface $until): array
{ {
$query = static::createdBetween($from, $until); $stats = static::createdBetween($from, $until)
->selectRaw("
COUNT(*) as total_orders,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN amount_paid > 0 THEN 1 ELSE 0 END) as paid,
SUM(CASE WHEN amount_paid = 0 OR amount_paid IS NULL THEN 1 ELSE 0 END) as unpaid,
COALESCE(SUM(amount_total), 0) as gross,
COALESCE(SUM(amount_paid), 0) as paid_amount,
COALESCE(SUM(amount_refunded), 0) as refunded,
COALESCE(AVG(amount_total), 0) as avg_order_value,
COALESCE(AVG(amount_paid), 0) as avg_paid_amount
", [OrderStatus::COMPLETED->value])
->first();
return [ return [
'period' => [ 'period' => [
@ -750,20 +764,20 @@ class Order extends Model
'until' => $until->format('Y-m-d H:i:s'), 'until' => $until->format('Y-m-d H:i:s'),
], ],
'orders' => [ 'orders' => [
'total' => $query->count(), 'total' => (int) $stats->total_orders,
'completed' => (clone $query)->completed()->count(), 'completed' => (int) $stats->completed,
'paid' => (clone $query)->paid()->count(), 'paid' => (int) $stats->paid,
'unpaid' => (clone $query)->unpaid()->count(), 'unpaid' => (int) $stats->unpaid,
], ],
'revenue' => [ 'revenue' => [
'gross' => (int) (clone $query)->sum('amount_total'), 'gross' => (int) $stats->gross,
'paid' => (int) (clone $query)->sum('amount_paid'), 'paid' => (int) $stats->paid_amount,
'refunded' => (int) (clone $query)->sum('amount_refunded'), 'refunded' => (int) $stats->refunded,
'net' => (int) ((clone $query)->sum('amount_paid') - (clone $query)->sum('amount_refunded')), 'net' => (int) ($stats->paid_amount - $stats->refunded),
], ],
'averages' => [ 'averages' => [
'order_value' => (float) (clone $query)->avg('amount_total') ?? 0, 'order_value' => (float) $stats->avg_order_value,
'paid_amount' => (float) (clone $query)->avg('amount_paid') ?? 0, 'paid_amount' => (float) $stats->avg_paid_amount,
], ],
]; ];
} }

View File

@ -239,10 +239,17 @@ class Product extends Model implements Purchasable, Cartable
// Generate unique slug and SKU // Generate unique slug and SKU
$baseSlug = preg_replace('/-copy(-\d+)?$/', '', $this->slug); $baseSlug = preg_replace('/-copy(-\d+)?$/', '', $this->slug);
// Get all existing slugs with this base in one query
$existingSlugs = static::where('slug', 'LIKE', $baseSlug . '-copy%')
->orWhere('slug', $baseSlug . '-copy')
->pluck('slug')
->flip()
->toArray();
$suffix = '-copy'; $suffix = '-copy';
$counter = 1; $counter = 1;
while (isset($existingSlugs[$baseSlug . $suffix])) {
while (static::where('slug', $baseSlug . $suffix)->exists()) {
$suffix = '-copy-' . ++$counter; $suffix = '-copy-' . ++$counter;
} }
$attributes['slug'] = $baseSlug . $suffix; $attributes['slug'] = $baseSlug . $suffix;
@ -250,10 +257,18 @@ class Product extends Model implements Purchasable, Cartable
// Handle SKU uniqueness // Handle SKU uniqueness
if ($this->sku) { if ($this->sku) {
$baseSku = preg_replace('/-COPY(-\d+)?$/i', '', $this->sku); $baseSku = preg_replace('/-COPY(-\d+)?$/i', '', $this->sku);
// Get all existing SKUs with this base in one query
$existingSkus = static::where('sku', 'LIKE', $baseSku . '-COPY%')
->orWhere('sku', $baseSku . '-COPY')
->pluck('sku')
->map(fn($s) => strtoupper($s))
->flip()
->toArray();
$skuSuffix = '-COPY'; $skuSuffix = '-COPY';
$skuCounter = 1; $skuCounter = 1;
while (isset($existingSkus[strtoupper($baseSku . $skuSuffix)])) {
while (static::where('sku', $baseSku . $skuSuffix)->exists()) {
$skuSuffix = '-COPY-' . ++$skuCounter; $skuSuffix = '-COPY-' . ++$skuCounter;
} }
$attributes['sku'] = $baseSku . $skuSuffix; $attributes['sku'] = $baseSku . $skuSuffix;
@ -659,13 +674,22 @@ class Product extends Model implements Purchasable, Cartable
// Special handling for pool products // Special handling for pool products
if ($this->isPool()) { if ($this->isPool()) {
$hasDirectPrice = $this->prices()->exists(); // Use exists() for efficiency - avoids loading all prices just to check
$singleItems = $this->singleProducts; $hasDirectPrice = $this->relationLoaded('prices')
? $this->prices->isNotEmpty()
: $this->prices()->exists();
if (!$hasDirectPrice) { if (!$hasDirectPrice) {
// Check if single items have prices to inherit // Check if single items have prices to inherit
// Use withCount for efficiency if not already loaded
$singleItems = $this->relationLoaded('singleProducts')
? $this->singleProducts
: $this->singleProducts()->get();
$singleItemsWithPrices = $singleItems->filter(function ($item) { $singleItemsWithPrices = $singleItems->filter(function ($item) {
return $item->prices()->exists(); return $item->relationLoaded('prices')
? $item->prices->isNotEmpty()
: $item->prices()->exists();
}); });
if ($singleItemsWithPrices->isEmpty()) { if ($singleItemsWithPrices->isEmpty()) {

View File

@ -4,6 +4,7 @@ namespace Blax\Shop\Services;
use Blax\Shop\Enums\OrderStatus; use Blax\Shop\Enums\OrderStatus;
use Blax\Shop\Models\Cart; use Blax\Shop\Models\Cart;
use Blax\Shop\Models\CartItem;
use Blax\Shop\Models\Order; use Blax\Shop\Models\Order;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductCategory; use Blax\Shop\Models\ProductCategory;
@ -364,41 +365,105 @@ class ShopService
/** /**
* Get shop statistics summary. * Get shop statistics summary.
* Optimized to use aggregated queries instead of individual counts.
*/ */
public function stats(): array public function stats(): array
{ {
// Aggregate product counts in single query
$productStats = Product::selectRaw("
COUNT(*) as total,
SUM(CASE WHEN status = 'published' THEN 1 ELSE 0 END) as published,
SUM(CASE WHEN status = 'draft' THEN 1 ELSE 0 END) as draft,
SUM(CASE WHEN featured = 1 THEN 1 ELSE 0 END) as featured
")->first();
// Aggregate order counts and revenue in single query
$today = Carbon::today();
$startOfWeek = Carbon::now()->startOfWeek();
$endOfWeek = Carbon::now()->endOfWeek();
$startOfMonth = Carbon::now()->startOfMonth();
$endOfMonth = Carbon::now()->endOfMonth();
$startOfYear = Carbon::now()->startOfYear();
$endOfYear = Carbon::now()->endOfYear();
$orderStats = Order::selectRaw("
COUNT(*) as total,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as processing,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as cancelled,
SUM(CASE WHEN DATE(created_at) = ? THEN 1 ELSE 0 END) as today,
SUM(CASE WHEN created_at BETWEEN ? AND ? THEN 1 ELSE 0 END) as this_week,
SUM(CASE WHEN created_at BETWEEN ? AND ? THEN 1 ELSE 0 END) as this_month,
COALESCE(SUM(amount_paid), 0) as total_revenue,
COALESCE(SUM(CASE WHEN DATE(created_at) = ? THEN amount_paid ELSE 0 END), 0) as revenue_today,
COALESCE(SUM(CASE WHEN created_at BETWEEN ? AND ? THEN amount_paid ELSE 0 END), 0) as revenue_this_week,
COALESCE(SUM(CASE WHEN created_at BETWEEN ? AND ? THEN amount_paid ELSE 0 END), 0) as revenue_this_month,
COALESCE(SUM(CASE WHEN created_at BETWEEN ? AND ? THEN amount_paid ELSE 0 END), 0) as revenue_this_year,
COALESCE(SUM(amount_refunded), 0) as total_refunded,
COALESCE(AVG(amount_total), 0) as average_order
", [
OrderStatus::PENDING->value,
OrderStatus::PROCESSING->value,
OrderStatus::COMPLETED->value,
OrderStatus::CANCELLED->value,
$today,
$startOfWeek,
$endOfWeek,
$startOfMonth,
$endOfMonth,
$today,
$startOfWeek,
$endOfWeek,
$startOfMonth,
$endOfMonth,
$startOfYear,
$endOfYear,
])->first();
// Aggregate cart counts in single query
$cartStats = Cart::selectRaw("
SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active,
SUM(CASE WHEN status = 'abandoned' THEN 1 ELSE 0 END) as abandoned,
SUM(CASE WHEN status = 'expired' THEN 1 ELSE 0 END) as expired,
SUM(CASE WHEN converted_at IS NOT NULL THEN 1 ELSE 0 END) as converted
")->first();
$totalRevenue = (int) $orderStats->total_revenue;
$totalRefunded = (int) $orderStats->total_refunded;
return [ return [
'products' => [ 'products' => [
'total' => Product::count(), 'total' => (int) $productStats->total,
'published' => Product::where('status', 'published')->count(), 'published' => (int) $productStats->published,
'draft' => Product::where('status', 'draft')->count(), 'draft' => (int) $productStats->draft,
'featured' => Product::where('featured', true)->count(), 'featured' => (int) $productStats->featured,
], ],
'orders' => [ 'orders' => [
'total' => Order::count(), 'total' => (int) $orderStats->total,
'pending' => $this->pendingOrders()->count(), 'pending' => (int) $orderStats->pending,
'processing' => $this->processingOrders()->count(), 'processing' => (int) $orderStats->processing,
'completed' => $this->completedOrders()->count(), 'completed' => (int) $orderStats->completed,
'cancelled' => $this->cancelledOrders()->count(), 'cancelled' => (int) $orderStats->cancelled,
'today' => $this->ordersToday()->count(), 'today' => (int) $orderStats->today,
'this_week' => $this->ordersThisWeek()->count(), 'this_week' => (int) $orderStats->this_week,
'this_month' => $this->ordersThisMonth()->count(), 'this_month' => (int) $orderStats->this_month,
], ],
'revenue' => [ 'revenue' => [
'total' => $this->totalRevenue(), 'total' => $totalRevenue,
'today' => $this->revenueToday(), 'today' => (int) $orderStats->revenue_today,
'this_week' => $this->revenueThisWeek(), 'this_week' => (int) $orderStats->revenue_this_week,
'this_month' => $this->revenueThisMonth(), 'this_month' => (int) $orderStats->revenue_this_month,
'this_year' => $this->revenueThisYear(), 'this_year' => (int) $orderStats->revenue_this_year,
'refunded' => $this->totalRefunded(), 'refunded' => $totalRefunded,
'net' => $this->netRevenue(), 'net' => $totalRevenue - $totalRefunded,
'average_order' => $this->averageOrderValue(), 'average_order' => (float) $orderStats->average_order,
], ],
'carts' => [ 'carts' => [
'active' => Cart::where('status', 'active')->count(), 'active' => (int) $cartStats->active,
'abandoned' => Cart::where('status', 'abandoned')->count(), 'abandoned' => (int) $cartStats->abandoned,
'expired' => Cart::where('status', 'expired')->count(), 'expired' => (int) $cartStats->expired,
'converted' => Cart::whereNotNull('converted_at')->count(), 'converted' => (int) $cartStats->converted,
], ],
'categories' => [ 'categories' => [
'total' => ProductCategory::count(), 'total' => ProductCategory::count(),
@ -530,14 +595,18 @@ class ShopService
*/ */
public function deleteOldCarts(): int public function deleteOldCarts(): int
{ {
$carts = $this->cartsToDelete()->get(); // Get cart IDs to delete
$count = $carts->count(); $cartIds = $this->cartsToDelete()->pluck('id')->toArray();
foreach ($carts as $cart) { if (empty($cartIds)) {
$cart->forceDelete(); return 0;
} }
return $count; // Delete cart items in batch first (foreign key constraint)
CartItem::whereIn('cart_id', $cartIds)->delete();
// Delete carts in batch
return Cart::whereIn('id', $cartIds)->delete();
} }
// ========================================================================= // =========================================================================

View File

@ -139,7 +139,8 @@ trait HasStocks
'expires_at' => $until, 'expires_at' => $until,
]); ]);
$this->logStockChange(-$quantity, 'decrease'); // Pass pre-calculated quantity to avoid extra query
$this->logStockChange(-$quantity, 'decrease', $available - $quantity);
$this->save(); $this->save();
@ -167,6 +168,8 @@ trait HasStocks
'status' => StockStatus::COMPLETED, 'status' => StockStatus::COMPLETED,
]); ]);
// Log stock change - getAvailableStock will be called by logStockChange
// This is acceptable since we need the accurate quantity after
$this->logStockChange($quantity, 'increase'); $this->logStockChange($quantity, 'increase');
$this->save(); $this->save();
@ -418,13 +421,14 @@ trait HasStocks
* *
* @param int $quantityChange The change in quantity (positive or negative) * @param int $quantityChange The change in quantity (positive or negative)
* @param string $type The type of change (increase, decrease, adjust) * @param string $type The type of change (increase, decrease, adjust)
* @param int|null $quantityAfter Optional pre-calculated quantity after change (avoids extra query)
*/ */
protected function logStockChange(int $quantityChange, string $type): void protected function logStockChange(int $quantityChange, string $type, ?int $quantityAfter = null): void
{ {
DB::table('product_stock_logs')->insert([ DB::table('product_stock_logs')->insert([
'product_id' => $this->id, 'product_id' => $this->id,
'quantity_change' => $quantityChange, 'quantity_change' => $quantityChange,
'quantity_after' => $this->getAvailableStock(), 'quantity_after' => $quantityAfter ?? $this->getAvailableStock(),
'type' => $type, 'type' => $type,
'created_at' => now(), 'created_at' => now(),
'updated_at' => now(), 'updated_at' => now(),