diff --git a/app/Livewire/Studio/Dashboard/Analysis.php b/app/Livewire/Studio/Dashboard/Analysis.php index 65ae97e..6c2fa17 100644 --- a/app/Livewire/Studio/Dashboard/Analysis.php +++ b/app/Livewire/Studio/Dashboard/Analysis.php @@ -19,6 +19,7 @@ use App\Models\Voucher; use App\Traits\Notification\WithSubscribeNotification; use Carbon\Carbon; +use Illuminate\Support\Facades\DB; use Livewire\Attributes\Title; use Livewire\Component; @@ -264,8 +265,7 @@ protected function loadData() auth()->user()->hasRole(['Developer', 'Owner']) ? [ 'title' => 'Belanja Barang', 'value' => formatCurrencyNumber( - Purchase::when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds)) - ->when($dateQuery || ($startDate && $endDate), $filterDate) + Purchase::when($dateQuery || ($startDate && $endDate), $filterDate) ->sum('total'), 'Rp' ), @@ -283,64 +283,61 @@ protected function loadData() ], ]); - $this->topPerfumes = Perfume::with(['items' => function ($query) use ($outletIds, $dateQuery, $startDate, $endDate, $filterDate) { - $query->whereNotNull('order_id') - ->whereHas('order', function ($q) use ($outletIds, $dateQuery, $startDate, $endDate, $filterDate) { - $q->when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds)) - ->when($dateQuery || ($startDate && $endDate), $filterDate); - }); - }]) - ->select('id', 'name', 'sale_price') - ->get() - ->map(fn ($perfume) => [ - 'name' => $perfume->name, - 'sale_price' => formatCurrencyNumber($perfume->sale_price, 'Rp'), - 'total_sold' => formatCurrencyNumber($perfume->items->sum('quantity')), - 'total_sales' => formatCurrencyNumber($perfume->items->sum(fn ($item) => $item->order?->total ?? 0), 'Rp'), - ]) - ->sortByDesc('total_sales') - ->filter(fn ($product) => $product['total_sold'] > 0) + $this->topPerfumes = OrderItem::where('orderable_type', Perfume::class) + ->whereHas('order', function ($q) use ($outletIds, $dateQuery, $startDate, $endDate, $filterDate) { + $q->when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds)) + ->when($dateQuery || ($startDate && $endDate), $filterDate); + }) + ->with('orderable') + ->select('orderable_id', 'orderable_type', DB::raw('SUM(quantity) as total_sold'), DB::raw('SUM(unit_price * quantity) as total_sales_value')) + ->groupBy('orderable_id', 'orderable_type') + ->orderByDesc('total_sales_value') ->take(5) + ->get() + ->map(fn ($item) => [ + 'name' => $item->orderable?->name ?? 'Unknown', + 'sale_price' => formatCurrencyNumber($item->orderable?->sale_price ?? 0, 'Rp'), + 'total_sold' => formatCurrencyNumber($item->total_sold), + 'total_sales' => formatCurrencyNumber($item->total_sales_value, 'Rp'), + ]) ->toArray(); - $this->topProducts = Product::with(['items' => function ($query) use ($outletIds, $dateQuery, $startDate, $endDate, $filterDate) { - $query->whereNotNull('order_id') - ->whereHas('order', function ($q) use ($outletIds, $dateQuery, $startDate, $endDate, $filterDate) { - $q->when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds)) - ->when($dateQuery || ($startDate && $endDate), $filterDate); - }); - }]) - ->select('id', 'name', 'sale_price') - ->get() - ->map(fn ($product) => [ - 'name' => $product->name, - 'sale_price' => formatCurrencyNumber($product->sale_price, 'Rp'), - 'total_sold' => formatCurrencyNumber($product->items->sum('quantity')), - 'total_sales' => formatCurrencyNumber($product->items->sum(fn ($item) => $item->order?->total ?? 0), 'Rp'), - ]) - ->sortByDesc('total_sales') - ->filter(fn ($product) => $product['total_sold'] > 0) + $this->topProducts = OrderItem::where('orderable_type', Product::class) + ->whereHas('order', function ($q) use ($outletIds, $dateQuery, $startDate, $endDate, $filterDate) { + $q->when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds)) + ->when($dateQuery || ($startDate && $endDate), $filterDate); + }) + ->with('orderable') + ->select('orderable_id', 'orderable_type', DB::raw('SUM(quantity) as total_sold'), DB::raw('SUM(unit_price * quantity) as total_sales_value')) + ->groupBy('orderable_id', 'orderable_type') + ->orderByDesc('total_sales_value') ->take(5) + ->get() + ->map(fn ($item) => [ + 'name' => $item->orderable?->name ?? 'Unknown', + 'sale_price' => formatCurrencyNumber($item->orderable?->sale_price ?? 0, 'Rp'), + 'total_sold' => formatCurrencyNumber($item->total_sold), + 'total_sales' => formatCurrencyNumber($item->total_sales_value, 'Rp'), + ]) ->toArray(); - $this->topBottles = Bottle::with(['items' => function ($query) use ($outletIds, $dateQuery, $startDate, $endDate, $filterDate) { - $query->whereNotNull('order_id') - ->whereHas('order', function ($q) use ($outletIds, $dateQuery, $startDate, $endDate, $filterDate) { - $q->when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds)) - ->when($dateQuery || ($startDate && $endDate), $filterDate); - }); - }]) - ->select('id', 'name', 'sale_price') - ->get() - ->map(fn ($bottle) => [ - 'name' => $bottle->name, - 'sale_price' => formatCurrencyNumber($bottle->sale_price, 'Rp'), - 'total_sold' => formatCurrencyNumber($bottle->items->sum('quantity')), - 'total_sales' => formatCurrencyNumber($bottle->items->sum(fn ($item) => $item->order?->total ?? 0), 'Rp'), - ]) - ->sortByDesc('total_sales') - ->filter(fn ($product) => $product['total_sold'] > 0) + $this->topBottles = OrderItem::where('orderable_type', Bottle::class) + ->whereHas('order', function ($q) use ($outletIds, $dateQuery, $startDate, $endDate, $filterDate) { + $q->when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds)) + ->when($dateQuery || ($startDate && $endDate), $filterDate); + }) + ->with('orderable') + ->select('orderable_id', 'orderable_type', DB::raw('SUM(quantity) as total_sold'), DB::raw('SUM(unit_price * quantity) as total_sales_value')) + ->groupBy('orderable_id', 'orderable_type') + ->orderByDesc('total_sales_value') ->take(5) + ->get() + ->map(fn ($item) => [ + 'name' => $item->orderable?->name ?? 'Unknown', + 'sale_price' => formatCurrencyNumber($item->orderable?->sale_price ?? 0, 'Rp'), + 'total_sold' => formatCurrencyNumber($item->total_sold), + 'total_sales' => formatCurrencyNumber($item->total_sales_value, 'Rp'), + ]) ->toArray(); } diff --git a/app/Livewire/Studio/Dashboard/Overview.php b/app/Livewire/Studio/Dashboard/Overview.php index 49944a5..69ee4f2 100644 --- a/app/Livewire/Studio/Dashboard/Overview.php +++ b/app/Livewire/Studio/Dashboard/Overview.php @@ -91,13 +91,9 @@ protected function loadStats() ->yesterday() ->sum('amount'); - $todayPurchase = Purchase::when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds)) - ->today() - ->sum('total'); + $todayPurchase = Purchase::query()->today()->sum('total'); - $yesterdayPurchase = Purchase::when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds)) - ->yesterday() - ->sum('total'); + $yesterdayPurchase = Purchase::query()->yesterday()->sum('total'); $todayGrossProfit = $todayIncome - $todayCogs - $todayDiscount; $yesterdayGrossProfit = $yesterdayIncome - $yesterdayCogs - $yesterdayDiscount; diff --git a/app/Models/OrderItem.php b/app/Models/OrderItem.php index ced3b0a..0a81339 100644 --- a/app/Models/OrderItem.php +++ b/app/Models/OrderItem.php @@ -25,7 +25,7 @@ protected function casts(): array { return [ 'cogs' => 'int', - 'price' => 'int', + 'unit_price' => 'int', 'quantity' => 'int', ]; } diff --git a/database/factories/ExpenseFactory.php b/database/factories/ExpenseFactory.php index 13d088c..699ce19 100644 --- a/database/factories/ExpenseFactory.php +++ b/database/factories/ExpenseFactory.php @@ -37,4 +37,14 @@ public function salary(): Factory { return $this->state(fn () => ['type' => ExpenseType::SALARY]); } + + public function today(): Factory + { + return $this->state(fn () => ['created_at' => now()]); + } + + public function yesterday(): Factory + { + return $this->state(fn () => ['created_at' => now()->subDay()]); + } } diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php index 8d269cc..500f367 100644 --- a/database/factories/OrderFactory.php +++ b/database/factories/OrderFactory.php @@ -179,10 +179,14 @@ public function today(): Factory public function yesterday(): Factory { - return $this->state(fn () => tap(now()->subDay(), fn ($d) => null) && [ - 'ordered_at' => $d = now()->subDay(), - 'created_at' => $d, - ]); + return $this->state(function () { + $yesterday = now()->subDay(); + + return [ + 'ordered_at' => $yesterday, + 'created_at' => $yesterday, + ]; + }); } public function thisWeek(): Factory diff --git a/database/factories/PurchaseFactory.php b/database/factories/PurchaseFactory.php index 201e55f..2e88975 100644 --- a/database/factories/PurchaseFactory.php +++ b/database/factories/PurchaseFactory.php @@ -2,8 +2,8 @@ namespace Database\Factories; -use App\Models\Outlet; use App\Models\Purchase; +use App\Models\Warehouse; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Str; @@ -14,11 +14,21 @@ class PurchaseFactory extends Factory public function definition(): array { return [ - 'outlet_id' => Outlet::factory(), + 'warehouse_id' => Warehouse::factory(), 'invoice_number' => Str::upper($this->faker->bothify('PO-########')), 'purchase_date' => $this->faker->date(), 'total' => $this->faker->numberBetween(50000, 500000), 'note' => $this->faker->optional()->sentence(6), ]; } + + public function today(): Factory + { + return $this->state(fn () => ['created_at' => now()]); + } + + public function yesterday(): Factory + { + return $this->state(fn () => ['created_at' => now()->subDay()]); + } } diff --git a/tests/Feature/Studio/Dashboard/AnalysisTest.php b/tests/Feature/Studio/Dashboard/AnalysisTest.php new file mode 100644 index 0000000..ad92324 --- /dev/null +++ b/tests/Feature/Studio/Dashboard/AnalysisTest.php @@ -0,0 +1,119 @@ +setupUser(); + + $role = Role::firstOrCreate(['name' => 'Developer']); + Permission::firstOrCreate(['name' => 'view analysis']); + $role->givePermissionTo('view analysis'); + $this->user->assignRole($role); +}); + +it('renders the analysis page correctly', function () { + $this->actingAs($this->user) + ->get(route('studio.dashboard.analysis')) + ->assertOk() + ->assertSeeLivewire(Analysis::class); +}); + +it('calculates counting data correctly', function () { + // Basic counts + Outlet::factory()->count(2)->create(); + Category::factory()->count(3)->create(); + + // Member count (User with customer relation) + $customerUser = User::factory()->create(); + Customer::factory()->create(['user_id' => $customerUser->id]); + + // Employee count (User with employee relation) + $employeeUser = User::factory()->create(); + \App\Models\Employee::factory()->create(['user_id' => $employeeUser->id]); + + Livewire::actingAs($this->user) + ->test(Analysis::class) + ->assertSet('countingData.0.title', 'Outlet') + ->assertSet('countingData.0.value', 3) // 1 from setup + 2 created + ->assertSet('countingData.4.title', 'Member') + ->assertSet('countingData.4.value', 1); +}); + +it('filters data by pre-defined periods', function () { + // Order from 10 days ago + Order::factory()->paid()->create([ + 'total' => 100000, + 'created_at' => now()->subDays(10), + ]); + + // Order from today + Order::factory()->paid()->create([ + 'total' => 50000, + 'created_at' => now(), + ]); + + Livewire::actingAs($this->user) + ->test(Analysis::class) + // All period (default) + ->assertSet('orders.1.value', 'Rp150.000') + // Last 7 days (only shows today's order) + ->set('period', '7') + ->assertSet('orders.1.value', 'Rp50.000'); +}); + +it('filters data by custom date range', function () { + // Order in range + Order::factory()->paid()->create([ + 'total' => 200000, + 'created_at' => Carbon::parse('2024-01-15'), + ]); + + // Order out of range + Order::factory()->paid()->create([ + 'total' => 100000, + 'created_at' => Carbon::parse('2024-02-15'), + ]); + + Livewire::actingAs($this->user) + ->test(Analysis::class) + ->set('range.start', '2024-01-01') + ->set('range.end', '2024-01-31') + ->assertSet('orders.1.value', 'Rp200.000'); +}); + +it('shows top performers correctly with aggregation', function () { + $p1 = Perfume::factory()->create(['name' => 'Perfume A', 'sale_price' => 1000]); + $p2 = Perfume::factory()->create(['name' => 'Perfume B', 'sale_price' => 2000]); + + // Sale of Perfume A (Total sales: 1500) + $o1 = Order::factory()->paid()->create(); + OrderItem::factory()->forOrder($o1)->forPerfume($p1)->create([ + 'unit_price' => 500, + 'quantity' => 3, + ]); + + // Sale of Perfume B (Total sales: 4000) + $o2 = Order::factory()->paid()->create(); + OrderItem::factory()->forOrder($o2)->forPerfume($p2)->create([ + 'unit_price' => 2000, + 'quantity' => 2, + ]); + + Livewire::actingAs($this->user) + ->test(Analysis::class) + ->assertSet('topPerfumes.0.name', 'Perfume B') + ->assertSet('topPerfumes.0.total_sales', 'Rp4.000') + ->assertSet('topPerfumes.1.name', 'Perfume A') + ->assertSet('topPerfumes.1.total_sales', 'Rp1.500'); +}); diff --git a/tests/Feature/Studio/Dashboard/OverviewTest.php b/tests/Feature/Studio/Dashboard/OverviewTest.php new file mode 100644 index 0000000..e1d7589 --- /dev/null +++ b/tests/Feature/Studio/Dashboard/OverviewTest.php @@ -0,0 +1,92 @@ +setupUser(); + + // Ensure the user has Developer role and required permissions + $role = Role::firstOrCreate(['name' => 'Developer']); + Permission::firstOrCreate(['name' => 'view studio overview']); + $role->givePermissionTo('view studio overview'); + $this->user->assignRole($role); +}); + +it('renders the overview page correctly', function () { + $this->actingAs($this->user) + ->get(route('studio.dashboard.overview')) + ->assertOk() + ->assertSeeLivewire(Overview::class); +}); + +it('calculates stats correctly for today and yesterday', function () { + // Today's data + Order::factory()->count(2)->today()->paid()->withOutlet($this->outlet)->create([ + 'total' => 100000, + 'cogs' => 40000, + 'discount' => 10000, + ]); + Expense::factory()->today()->create(['amount' => 5000, 'outlet_id' => $this->outlet->id]); + Purchase::factory()->today()->create(['total' => 50000]); + + // Yesterday's data + Order::factory()->count(1)->yesterday()->paid()->withOutlet($this->outlet)->create([ + 'total' => 80000, + 'cogs' => 30000, + 'discount' => 5000, + ]); + Expense::factory()->yesterday()->create(['amount' => 2000, 'outlet_id' => $this->outlet->id]); + Purchase::factory()->yesterday()->create(['total' => 30000]); + + Livewire::actingAs($this->user) + ->test(Overview::class) + ->assertSet('stats.0.value', 2) // Total Order Today + ->assertSet('stats.0.previous', 1) // Total Order Yesterday + ->assertSet('stats.1.value', 'Rp200.000') // Pendapatan Today (2 * 100k) + ->assertSet('stats.1.previous', 'Rp80.000'); // Pendapatan Yesterday (1 * 80k) +}); + +it('filters stats by selected outlets', function () { + $anotherOutlet = \App\Models\Outlet::factory()->create(); + $this->user->outlets()->attach($anotherOutlet); + + // Order in main outlet + Order::factory()->today()->paid()->withOutlet($this->outlet)->create(['total' => 100000]); + // Order in another outlet + Order::factory()->today()->paid()->withOutlet($anotherOutlet)->create(['total' => 50000]); + + Livewire::actingAs($this->user) + ->test(Overview::class) + // No filter (shows both) + ->assertSet('stats.1.value', 'Rp150.000') + // Filter by main outlet only + ->set('selectedOutletIds', [$this->outlet->id]) + ->assertSet('stats.1.value', 'Rp100.000') + // Filter by another outlet only + ->set('selectedOutletIds', [$anotherOutlet->id]) + ->assertSet('stats.1.value', 'Rp50.000'); +}); + +it('shows top perfume correctly', function () { + $perfume = Perfume::factory()->create(['name' => 'Royal Oud']); + + $order = Order::factory()->today()->paid()->withOutlet($this->outlet)->create(); + OrderItem::factory()->create([ + 'order_id' => $order->id, + 'orderable_type' => Perfume::class, + 'orderable_id' => $perfume->id, + 'quantity' => 100, + ]); + + Livewire::actingAs($this->user) + ->test(Overview::class) + ->assertSee('Royal Oud (100ml)'); +});