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:
parent
fd409af9c6
commit
0733bd0d3e
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -25,7 +25,7 @@ protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'cogs' => 'int',
|
||||
'price' => 'int',
|
||||
'unit_price' => 'int',
|
||||
'quantity' => 'int',
|
||||
];
|
||||
}
|
||||
|
||||
@ -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()]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()]);
|
||||
}
|
||||
}
|
||||
|
||||
119
tests/Feature/Studio/Dashboard/AnalysisTest.php
Normal file
119
tests/Feature/Studio/Dashboard/AnalysisTest.php
Normal 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');
|
||||
});
|
||||
92
tests/Feature/Studio/Dashboard/OverviewTest.php
Normal file
92
tests/Feature/Studio/Dashboard/OverviewTest.php
Normal 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)');
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user