RI optimizations
This commit is contained in:
parent
6e9c9043ae
commit
7aeffd27a9
|
|
@ -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 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,
|
||||
]);
|
||||
|
||||
// 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' => $defaultPrice,
|
||||
'unit_amount' => $priceAmount,
|
||||
'sale_unit_amount' => $sale_unit_amount,
|
||||
'currency' => 'EUR',
|
||||
]);
|
||||
|
||||
// Set the first price as default
|
||||
if ($prices->isNotEmpty()) {
|
||||
$defaultPrice = $prices->first();
|
||||
$defaultPrice->is_default = true;
|
||||
$defaultPrice->save();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
11
phpunit.xml
11
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"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="BlaxShop Test Suite">
|
||||
|
|
@ -29,11 +27,12 @@
|
|||
<coverage includeUncoveredFiles="true" />
|
||||
<php>
|
||||
<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="CACHE_DRIVER" value="array"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="QUEUE_DRIVER" value="sync"/>
|
||||
<env name="SHOP_CACHE_ENABLED" value="false"/>
|
||||
<ini name="memory_limit" value="512M"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
|
@ -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();
|
||||
|
||||
// Batch load all price IDs
|
||||
$priceIds = $items->pluck('price_id')->filter()->unique()->values()->toArray();
|
||||
|
||||
if (empty($priceIds)) {
|
||||
return array_fill(0, $items->count(), null);
|
||||
}
|
||||
|
||||
// Use the relationship method, not property access
|
||||
$price = $item->price()->first();
|
||||
return $price ? $price->stripe_price_id : 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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue