feat: add feature tests for Studio dashboard components and update Analysis component's purchase filtering and top product aggregation logic.

This commit is contained in:
Yoga Pangestu 2026-01-02 16:27:09 +07:00
parent fd409af9c6
commit 0733bd0d3e
8 changed files with 294 additions and 66 deletions

View File

@ -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();
}

View File

@ -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;

View File

@ -25,7 +25,7 @@ protected function casts(): array
{
return [
'cogs' => 'int',
'price' => 'int',
'unit_price' => 'int',
'quantity' => 'int',
];
}

View File

@ -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()]);
}
}

View File

@ -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

View File

@ -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()]);
}
}

View File

@ -0,0 +1,119 @@
<?php
use App\Livewire\Studio\Dashboard\Analysis;
use App\Models\Category;
use App\Models\Customer;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Outlet;
use App\Models\Perfume;
use App\Models\User;
use Carbon\Carbon;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
beforeEach(function () {
$this->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');
});

View File

@ -0,0 +1,92 @@
<?php
use App\Livewire\Studio\Dashboard\Overview;
use App\Models\Expense;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Perfume;
use App\Models\Purchase;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
beforeEach(function () {
$this->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)');
});