From 7aeffd27a965440b3520122e684ddab3879c10d6 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Mon, 29 Dec 2025 11:11:27 +0100 Subject: [PATCH] RI optimizations --- database/factories/ProductFactory.php | 36 +++++--- phpunit.xml | 11 +-- src/Models/Cart.php | 21 +++-- src/Models/Order.php | 38 +++++--- src/Models/Product.php | 38 ++++++-- src/Services/ShopService.php | 127 ++++++++++++++++++++------ src/Traits/HasStocks.php | 10 +- 7 files changed, 202 insertions(+), 79 deletions(-) diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php index 24865b9..323ba26 100644 --- a/database/factories/ProductFactory.php +++ b/database/factories/ProductFactory.php @@ -87,7 +87,7 @@ class ProductFactory extends Factory ): static { return $this->afterCreating(function (Product $product) use ($count, $unit_amount, $sale_unit_amount) { // Use realistic price range if not specified - $defaultPrice = $unit_amount ?? $this->faker->randomElement([ + $priceAmount = $unit_amount ?? $this->faker->randomElement([ 1999, // $19.99 2999, // $29.99 4999, // $49.99 @@ -99,21 +99,27 @@ class ProductFactory extends Factory 49999, // $499.99 ]); - $prices = \Blax\Shop\Models\ProductPrice::factory() - ->count($count) - ->create([ - 'purchasable_type' => get_class($product), - 'purchasable_id' => $product->id, - 'unit_amount' => $defaultPrice, - 'sale_unit_amount' => $sale_unit_amount, - 'currency' => 'EUR', - ]); + // Create first price with is_default = true to avoid second query + \Blax\Shop\Models\ProductPrice::factory()->create([ + 'purchasable_type' => get_class($product), + 'purchasable_id' => $product->id, + 'unit_amount' => $priceAmount, + 'sale_unit_amount' => $sale_unit_amount, + 'currency' => 'EUR', + 'is_default' => true, + ]); - // Set the first price as default - if ($prices->isNotEmpty()) { - $defaultPrice = $prices->first(); - $defaultPrice->is_default = true; - $defaultPrice->save(); + // Create additional prices if count > 1 + if ($count > 1) { + \Blax\Shop\Models\ProductPrice::factory() + ->count($count - 1) + ->create([ + 'purchasable_type' => get_class($product), + 'purchasable_id' => $product->id, + 'unit_amount' => $priceAmount, + 'sale_unit_amount' => $sale_unit_amount, + 'currency' => 'EUR', + ]); } }); } diff --git a/phpunit.xml b/phpunit.xml index cf6b9cd..14f79ae 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -7,11 +7,9 @@ processIsolation="false" stopOnFailure="false" cacheDirectory=".phpunit.cache" - displayDetailsOnTestsThatTriggerDeprecations="true" - displayDetailsOnTestsThatTriggerErrors="true" - displayDetailsOnTestsThatTriggerNotices="true" - displayDetailsOnTestsThatTriggerWarnings="true" - displayDetailsOnPhpunitDeprecations="true" + cacheResult="true" + executionOrder="defects" + beStrictAboutOutputDuringTests="false" > @@ -29,11 +27,12 @@ - + + \ No newline at end of file diff --git a/src/Models/Cart.php b/src/Models/Cart.php index 6daf3ad..457375a 100644 --- a/src/Models/Cart.php +++ b/src/Models/Cart.php @@ -285,14 +285,21 @@ class Cart extends Model */ public function stripePriceIds(): array { - return $this->items->map(function ($item) { - if (!$item->price_id) { - return null; - } + // Eager load priceModel to avoid N+1 queries + // Note: price() relationship conflicts with price column, so we use explicit loading + $items = $this->items()->get(); - // Use the relationship method, not property access - $price = $item->price()->first(); - return $price ? $price->stripe_price_id : null; + // Batch load all price IDs + $priceIds = $items->pluck('price_id')->filter()->unique()->values()->toArray(); + + 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(); } diff --git a/src/Models/Order.php b/src/Models/Order.php index 6e42ad6..f572bb5 100644 --- a/src/Models/Order.php +++ b/src/Models/Order.php @@ -706,7 +706,8 @@ class Order extends Model */ 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. + * Optimized to use single aggregated query. */ 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 [ 'period' => [ @@ -750,20 +764,20 @@ class Order extends Model 'until' => $until->format('Y-m-d H:i:s'), ], 'orders' => [ - 'total' => $query->count(), - 'completed' => (clone $query)->completed()->count(), - 'paid' => (clone $query)->paid()->count(), - 'unpaid' => (clone $query)->unpaid()->count(), + 'total' => (int) $stats->total_orders, + 'completed' => (int) $stats->completed, + 'paid' => (int) $stats->paid, + 'unpaid' => (int) $stats->unpaid, ], 'revenue' => [ - 'gross' => (int) (clone $query)->sum('amount_total'), - 'paid' => (int) (clone $query)->sum('amount_paid'), - 'refunded' => (int) (clone $query)->sum('amount_refunded'), - 'net' => (int) ((clone $query)->sum('amount_paid') - (clone $query)->sum('amount_refunded')), + 'gross' => (int) $stats->gross, + 'paid' => (int) $stats->paid_amount, + 'refunded' => (int) $stats->refunded, + 'net' => (int) ($stats->paid_amount - $stats->refunded), ], 'averages' => [ - 'order_value' => (float) (clone $query)->avg('amount_total') ?? 0, - 'paid_amount' => (float) (clone $query)->avg('amount_paid') ?? 0, + 'order_value' => (float) $stats->avg_order_value, + 'paid_amount' => (float) $stats->avg_paid_amount, ], ]; } diff --git a/src/Models/Product.php b/src/Models/Product.php index 5ee5d42..d357669 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -239,10 +239,17 @@ class Product extends Model implements Purchasable, Cartable // Generate unique slug and SKU $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'; $counter = 1; - - while (static::where('slug', $baseSlug . $suffix)->exists()) { + while (isset($existingSlugs[$baseSlug . $suffix])) { $suffix = '-copy-' . ++$counter; } $attributes['slug'] = $baseSlug . $suffix; @@ -250,10 +257,18 @@ class Product extends Model implements Purchasable, Cartable // Handle SKU uniqueness if ($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'; $skuCounter = 1; - - while (static::where('sku', $baseSku . $skuSuffix)->exists()) { + while (isset($existingSkus[strtoupper($baseSku . $skuSuffix)])) { $skuSuffix = '-COPY-' . ++$skuCounter; } $attributes['sku'] = $baseSku . $skuSuffix; @@ -659,13 +674,22 @@ class Product extends Model implements Purchasable, Cartable // Special handling for pool products if ($this->isPool()) { - $hasDirectPrice = $this->prices()->exists(); - $singleItems = $this->singleProducts; + // Use exists() for efficiency - avoids loading all prices just to check + $hasDirectPrice = $this->relationLoaded('prices') + ? $this->prices->isNotEmpty() + : $this->prices()->exists(); if (!$hasDirectPrice) { // 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) { - return $item->prices()->exists(); + return $item->relationLoaded('prices') + ? $item->prices->isNotEmpty() + : $item->prices()->exists(); }); if ($singleItemsWithPrices->isEmpty()) { diff --git a/src/Services/ShopService.php b/src/Services/ShopService.php index f1eb32a..5a96543 100644 --- a/src/Services/ShopService.php +++ b/src/Services/ShopService.php @@ -4,6 +4,7 @@ namespace Blax\Shop\Services; use Blax\Shop\Enums\OrderStatus; use Blax\Shop\Models\Cart; +use Blax\Shop\Models\CartItem; use Blax\Shop\Models\Order; use Blax\Shop\Models\Product; use Blax\Shop\Models\ProductCategory; @@ -364,41 +365,105 @@ class ShopService /** * Get shop statistics summary. + * Optimized to use aggregated queries instead of individual counts. */ 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 [ 'products' => [ - 'total' => Product::count(), - 'published' => Product::where('status', 'published')->count(), - 'draft' => Product::where('status', 'draft')->count(), - 'featured' => Product::where('featured', true)->count(), + 'total' => (int) $productStats->total, + 'published' => (int) $productStats->published, + 'draft' => (int) $productStats->draft, + 'featured' => (int) $productStats->featured, ], 'orders' => [ - 'total' => Order::count(), - 'pending' => $this->pendingOrders()->count(), - 'processing' => $this->processingOrders()->count(), - 'completed' => $this->completedOrders()->count(), - 'cancelled' => $this->cancelledOrders()->count(), - 'today' => $this->ordersToday()->count(), - 'this_week' => $this->ordersThisWeek()->count(), - 'this_month' => $this->ordersThisMonth()->count(), + 'total' => (int) $orderStats->total, + 'pending' => (int) $orderStats->pending, + 'processing' => (int) $orderStats->processing, + 'completed' => (int) $orderStats->completed, + 'cancelled' => (int) $orderStats->cancelled, + 'today' => (int) $orderStats->today, + 'this_week' => (int) $orderStats->this_week, + 'this_month' => (int) $orderStats->this_month, ], 'revenue' => [ - 'total' => $this->totalRevenue(), - 'today' => $this->revenueToday(), - 'this_week' => $this->revenueThisWeek(), - 'this_month' => $this->revenueThisMonth(), - 'this_year' => $this->revenueThisYear(), - 'refunded' => $this->totalRefunded(), - 'net' => $this->netRevenue(), - 'average_order' => $this->averageOrderValue(), + 'total' => $totalRevenue, + 'today' => (int) $orderStats->revenue_today, + 'this_week' => (int) $orderStats->revenue_this_week, + 'this_month' => (int) $orderStats->revenue_this_month, + 'this_year' => (int) $orderStats->revenue_this_year, + 'refunded' => $totalRefunded, + 'net' => $totalRevenue - $totalRefunded, + 'average_order' => (float) $orderStats->average_order, ], 'carts' => [ - 'active' => Cart::where('status', 'active')->count(), - 'abandoned' => Cart::where('status', 'abandoned')->count(), - 'expired' => Cart::where('status', 'expired')->count(), - 'converted' => Cart::whereNotNull('converted_at')->count(), + 'active' => (int) $cartStats->active, + 'abandoned' => (int) $cartStats->abandoned, + 'expired' => (int) $cartStats->expired, + 'converted' => (int) $cartStats->converted, ], 'categories' => [ 'total' => ProductCategory::count(), @@ -530,14 +595,18 @@ class ShopService */ public function deleteOldCarts(): int { - $carts = $this->cartsToDelete()->get(); - $count = $carts->count(); + // Get cart IDs to delete + $cartIds = $this->cartsToDelete()->pluck('id')->toArray(); - foreach ($carts as $cart) { - $cart->forceDelete(); + if (empty($cartIds)) { + 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(); } // ========================================================================= diff --git a/src/Traits/HasStocks.php b/src/Traits/HasStocks.php index b7e6969..79e5fe2 100644 --- a/src/Traits/HasStocks.php +++ b/src/Traits/HasStocks.php @@ -139,7 +139,8 @@ trait HasStocks 'expires_at' => $until, ]); - $this->logStockChange(-$quantity, 'decrease'); + // Pass pre-calculated quantity to avoid extra query + $this->logStockChange(-$quantity, 'decrease', $available - $quantity); $this->save(); @@ -167,6 +168,8 @@ trait HasStocks '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->save(); @@ -418,13 +421,14 @@ trait HasStocks * * @param int $quantityChange The change in quantity (positive or negative) * @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([ 'product_id' => $this->id, 'quantity_change' => $quantityChange, - 'quantity_after' => $this->getAvailableStock(), + 'quantity_after' => $quantityAfter ?? $this->getAvailableStock(), 'type' => $type, 'created_at' => now(), 'updated_at' => now(),