diff --git a/app/Livewire/Forms/Studio/Manage/OrderForm.php b/app/Livewire/Forms/Studio/Manage/OrderForm.php index 32a8307..7774f16 100644 --- a/app/Livewire/Forms/Studio/Manage/OrderForm.php +++ b/app/Livewire/Forms/Studio/Manage/OrderForm.php @@ -7,10 +7,10 @@ use App\Enums\PaymentMethod; use App\Enums\PaymentStatus; use App\Enums\PointRecordType; +use App\Models\Customer; use App\Models\Order; use App\Models\Payment; use App\Models\PointRecord; -use App\Models\User; use App\Models\Voucher; use App\Rules\UnsignedInteger; use App\Traits\Media\WithMediaHandler; @@ -119,23 +119,21 @@ public function store(): array // Ensures atomicity — if any step fails, all changes are rolled back try { DB::transaction(function () use (&$order, $invoiceNumber, $cogs, $subtotal, $discount, $total, $items, $pointsEarned) { - $member = User::whereHas('customer', fn ($q) => $q->where('id', $this->member_id)) - ->with(['membership' => fn ($q) => $q->lockForUpdate()]) - ->first(); + $customer = $this->member_id ? Customer::find($this->member_id) : null; + $member = $customer?->user()->with(['membership' => fn ($q) => $q->lockForUpdate()])->first(); $order = Order::create([ 'outlet_id' => $this->outlet_id, 'user_id' => auth()->id(), - 'voucher_id' => $this->voucher_id == '' ? null : $this->voucher_id, - 'customer_id' => $this->member_id == '' ? null : $this->member_id, + 'voucher_id' => $this->voucher_id ?: null, + 'customer_id' => $customer?->id, 'invoice_number' => $invoiceNumber, 'cogs' => $cogs, 'subtotal' => $subtotal, 'discount' => $discount, 'total' => $total, - 'channel' => $this->channel, - 'status' => $this->status, - 'payment_method' => $this->payment_method, + 'channel' => (int) $this->channel, + 'status' => (int) $this->status, 'ordered_at' => now(), ]); diff --git a/app/Livewire/Studio/Manage/Order/Index.php b/app/Livewire/Studio/Manage/Order/Index.php index 09a3d2b..b94a50b 100644 --- a/app/Livewire/Studio/Manage/Order/Index.php +++ b/app/Livewire/Studio/Manage/Order/Index.php @@ -3,6 +3,7 @@ namespace App\Livewire\Studio\Manage\Order; use App\Models\Order; +use App\Traits\Authorization\WithAuthorization; use App\Traits\Components\WithCloseModal; use App\Traits\Components\WithConfirmation; use App\Traits\Components\WithToast; @@ -17,7 +18,7 @@ #[Title('Order')] class Index extends Component { - use WithCloseModal, WithConfirmation, WithSubscribeNotification, WithToast, WithUpdateStock; + use WithAuthorization, WithCloseModal, WithConfirmation, WithSubscribeNotification, WithToast, WithUpdateStock; public function mount(): void { diff --git a/app/Livewire/Studio/Manage/Restock/Create.php b/app/Livewire/Studio/Manage/Restock/Create.php index 626c113..ed1a1df 100644 --- a/app/Livewire/Studio/Manage/Restock/Create.php +++ b/app/Livewire/Studio/Manage/Restock/Create.php @@ -13,6 +13,7 @@ use App\Traits\Restock\WithRestockUpdateItem; use App\Traits\Utilities\WithUpdatedData; use Illuminate\Contracts\View\View; +use Illuminate\Validation\ValidationException; use Livewire\Attributes\Title; use Livewire\Component; @@ -107,6 +108,8 @@ public function save(): void try { $this->form->store(); + } catch (ValidationException $e) { + throw $e; } catch (\Exception $e) { $this->toast($e->getMessage(), 'Gagal', 'danger'); diff --git a/app/Models/Membership.php b/app/Models/Membership.php index fd64235..ee7283a 100644 --- a/app/Models/Membership.php +++ b/app/Models/Membership.php @@ -2,8 +2,6 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Attributes\Scope; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; @@ -23,15 +21,18 @@ protected function casts(): array ]; } - #[Scope] - protected function scopeForSpending(Builder $query, $totalSpending): void + public function recalculateTier(): void { - $query - ->where('min_spending', '<=', $totalSpending) - ->where(function ($q) use ($totalSpending) { - $q->whereNull('max_spending') - ->orWhere('max_spending', '>=', $totalSpending); - }); + $newTier = Tier::where('min_spending', '<=', $this->total_spending) + ->where(function ($query) { + $query->whereNull('max_spending') + ->orWhere('max_spending', '>=', $this->total_spending); + }) + ->first(); + + if ($newTier && $newTier->id !== $this->tier_id) { + $this->update(['tier_id' => $newTier->id]); + } } public function tier(): BelongsTo diff --git a/app/Traits/Order/WithMember.php b/app/Traits/Order/WithMember.php index b14d79a..cb638e1 100644 --- a/app/Traits/Order/WithMember.php +++ b/app/Traits/Order/WithMember.php @@ -82,15 +82,7 @@ private function loadVouchers(?string $memberId, ?string $outletId): void $this->vouchers = Voucher::whereHas('users', fn ($q) => $q->where('user_id', $memberId)) ->whereHas('outlets', fn ($q) => $q->where('outlet_id', $outletId)) ->active() - ->select('id', 'name', 'type', 'discount_amount', 'max_discount') - ->orderByRaw(" - CASE - WHEN type = 'percentage' THEN - LEAST(discount_amount / 100 * COALESCE(max_discount, 99999999), COALESCE(max_discount, 99999999)) - ELSE - discount_amount - END DESC - ") + ->orderBy('discount_amount', 'desc') ->pluck('name', 'id') ->toArray(); } diff --git a/resources/views/livewire/studio/manage/order/form.blade.php b/resources/views/livewire/studio/manage/order/form.blade.php index 8b0596e..15761b0 100644 --- a/resources/views/livewire/studio/manage/order/form.blade.php +++ b/resources/views/livewire/studio/manage/order/form.blade.php @@ -76,131 +76,138 @@
-
- - - - Baru - Isi Ulang - + @if ($form->outlet_id) +
+ + + + Baru + Isi Ulang + - -
- - @foreach ($perfumes as $key => $perfume) - - {{ $perfume }} - - @endforeach - + +
+ + @foreach ($perfumes as $key => $perfume) + + {{ $perfume }} + + @endforeach + - - @foreach ($bottles as $key => $bottle) - {{ $bottle }} - - @endforeach - + + @foreach ($bottles as $key => $bottle) + {{ $bottle }} + + @endforeach + -
- @if (!empty($qualities)) - - @foreach ($qualities as $key => $value) - - {{ $value }} - - @endforeach - - @else - - Kualitas -
- Silakan pilih botol... -
- @endif +
+ @if (!empty($qualities)) + + @foreach ($qualities as $key => $value) + + {{ $value }} + + @endforeach + + @else + + Kualitas +
+ Silakan pilih + botol... +
+ @endif +
-
-
+ - -
- - @foreach ($perfumes as $key => $perfume) - - {{ $perfume }} - - @endforeach - + +
+ + @foreach ($perfumes as $key => $perfume) + + {{ $perfume }} + + @endforeach + - - Ukuran Botol - - - ml - - + + Ukuran Botol + + + ml + + -
- @if (!empty($qualities)) - - @foreach ($qualities as $key => $value) - - {{ $value }} - - @endforeach - - @else - - Kualitas -
- Silakan masukan ukuran - botol... -
- @endif +
+ @if (!empty($qualities)) + + @foreach ($qualities as $key => $value) + + {{ $value }} + + @endforeach + + @else + + Kualitas +
+ Silakan masukan ukuran + botol... +
+ @endif +
-
-
- + + -
- - Tambah - -
+
+ + Tambah + +
+ + + + + @foreach ($products as $key => $product) + {{ $product }} + + @endforeach + + + + +
+ + Tambah + +
+
+
+ @else + + Silakan pilih Outlet terlebih dahulu untuk menampilkan daftar item. - - - - @foreach ($products as $key => $product) - {{ $product }} - - @endforeach - - - - -
- - Tambah - -
-
-
+ @endif
diff --git a/tests/Feature/Studio/Manage/OrderTest.php b/tests/Feature/Studio/Manage/OrderTest.php new file mode 100644 index 0000000..c9d3e48 --- /dev/null +++ b/tests/Feature/Studio/Manage/OrderTest.php @@ -0,0 +1,354 @@ +setupUser(); + + $role = Role::firstOrCreate(['name' => 'Owner']); + $permissions = ['view order', 'create order', 'delete order']; + foreach ($permissions as $p) { + Permission::firstOrCreate(['name' => $p]); + } + $role->syncPermissions($permissions); + $this->user->assignRole($role); + + // Ensure user has an outlet + $this->user->outlets()->sync([$this->outlet->id]); + + $this->tier = \App\Models\Tier::factory()->create(); + + // Create other roles required by the component logic + Role::firstOrCreate(['name' => 'Developer']); + Role::firstOrCreate(['name' => 'Leader']); +}); + +it('renders the order index page', function () { + $this->actingAs($this->user) + ->get(route('studio.manage.order.index')) + ->assertOk() + ->assertSeeLivewire(Index::class); +}); + +it('renders the order create page', function () { + $this->actingAs($this->user) + ->get(route('studio.manage.order.create')) + ->assertOk() + ->assertSeeLivewire(Create::class); +}); + +it('can add a product to the cart', function () { + $product = Product::factory()->create(['sale_price' => 50000]); + $this->outlet->products()->attach($product->id, ['stock' => 100]); + + Livewire::actingAs($this->user) + ->test(Create::class) + ->set('form.outlet_id', $this->outlet->id) + ->set('form.product_id', $product->id) + ->set('form.quantity_product', '2') + ->call('addProduct') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('order_items', [ + 'user_id' => $this->user->id, + 'orderable_type' => Product::class, + 'orderable_id' => $product->id, + 'quantity' => 2, + 'unit_price' => 50000, + 'order_id' => null, + ]); +}); + +it('can add a new perfume (perfume + bottle) to the cart', function () { + $perfume = Perfume::factory()->create(['sale_price' => 1000]); // per ml + $bottle = Bottle::factory()->create(['size' => '30ml', 'sale_price' => 15000]); + $formula = Formula::factory()->create(['size' => '30ml', 'volume' => 30, 'quality' => 'Premium']); + + $this->outlet->perfumes()->attach($perfume->id, ['stock' => 1000]); + $this->outlet->bottles()->attach($bottle->id, ['stock' => 50]); + + Livewire::actingAs($this->user) + ->test(Create::class) + ->set('form.outlet_id', $this->outlet->id) + ->set('tab', 'new') + ->set('form.perfume_id', $perfume->id) + ->set('form.bottle_id', $bottle->id) + ->set('form.quality_id', $formula->id) + ->call('addPerfume') + ->assertHasNoErrors(); + + // Should add both perfume and bottle + $this->assertDatabaseHas('order_items', [ + 'user_id' => $this->user->id, + 'orderable_type' => Perfume::class, + 'orderable_id' => $perfume->id, + 'quantity' => 30, + 'order_id' => null, + ]); + + $this->assertDatabaseHas('order_items', [ + 'user_id' => $this->user->id, + 'orderable_type' => Bottle::class, + 'orderable_id' => $bottle->id, + 'quantity' => 1, + 'order_id' => null, + ]); +}); + +it('can update item quantity in the cart', function () { + $product = Product::factory()->create(['sale_price' => 50000]); + $item = OrderItem::factory()->forProduct($product)->create([ + 'user_id' => $this->user->id, + 'order_id' => null, + 'quantity' => 1, + 'unit_price' => 50000, + ]); + + Livewire::actingAs($this->user) + ->test(Create::class) + ->set('form.item_id', $item->id) + ->set('form.quantity_edit', '5') + ->call('updateItem') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('order_items', [ + 'id' => $item->id, + 'quantity' => 5, + ]); +}); + +it('can delete an item from the cart', function () { + $product = Product::factory()->create(); + $item = OrderItem::factory()->forProduct($product)->create([ + 'user_id' => $this->user->id, + 'order_id' => null, + ]); + + Livewire::actingAs($this->user) + ->test(Create::class) + ->call('deleteItem', $item->id) + ->assertHasNoErrors(); + + $this->assertSoftDeleted('order_items', ['id' => $item->id]); +}); + +it('can store an order and update outlet stock', function () { + $product = Product::factory()->create(['sale_price' => 100000, 'cost_price' => 60000]); + $this->outlet->products()->attach($product->id, ['stock' => 10]); + + OrderItem::factory()->forProduct($product)->create([ + 'user_id' => $this->user->id, + 'order_id' => null, + 'quantity' => 2, + 'unit_price' => 100000, + 'cogs' => 60000, + ]); + + Livewire::actingAs($this->user) + ->test(Create::class) + ->set('form.outlet_id', $this->outlet->id) + ->set('form.payment_method', '1') // Cash + ->call('save') + ->assertHasNoErrors() + ->assertRedirect(); + + $this->assertDatabaseHas('orders', [ + 'outlet_id' => $this->outlet->id, + 'user_id' => $this->user->id, + 'total' => 200000, + 'subtotal' => 200000, + ]); + + // Check payment created + $order = Order::latest()->first(); + $this->assertDatabaseHas('payments', [ + 'order_id' => $order->id, + 'amount' => 200000, + 'status' => PaymentStatus::PAID->value, + ]); + + // Check stock updated + $this->assertDatabaseHas('outlet_product', [ + 'outlet_id' => $this->outlet->id, + 'product_id' => $product->id, + 'stock' => 8, + ]); +}); + +it('can store an order with member and earn points', function () { + $product = Product::factory()->create(['sale_price' => 100000]); + $this->outlet->products()->attach($product->id, ['stock' => 10]); + + $memberUser = User::factory()->create(); + $memberUser->customer()->create([ + 'name' => 'Member Test', + 'gender' => Gender::MALE->value, + ]); + $memberUser->membership()->create([ + 'total_spending' => 0, + 'reward_points' => 0, + 'tier_id' => $this->tier->id, + ]); + + OrderItem::factory()->forProduct($product)->create([ + 'user_id' => $this->user->id, + 'order_id' => null, + 'quantity' => 1, + 'unit_price' => 100000, + ]); + + Livewire::actingAs($this->user) + ->test(Create::class) + ->set('form.outlet_id', $this->outlet->id) + ->set('form.member_id', $memberUser->customer->id) + ->call('save') + ->assertHasNoErrors(); + + // Check points earned (100000 / 5000 = 20 points) + $this->assertDatabaseHas('memberships', [ + 'user_id' => $memberUser->id, + 'reward_points' => 20, + 'total_spending' => 100000, + ]); + + $this->assertDatabaseHas('point_records', [ + 'user_id' => $memberUser->id, + 'change' => 20, + 'is_addition' => true, + ]); +}); + +it('can store an order with voucher', function () { + $product = Product::factory()->create(['sale_price' => 100000]); + $this->outlet->products()->attach($product->id, ['stock' => 10]); + + $memberUser = User::factory()->create(); + $memberUser->customer()->create([ + 'name' => 'Voucher User', + 'gender' => Gender::MALE->value, + ]); + $memberUser->membership()->create(['tier_id' => $this->tier->id]); + + $voucher = Voucher::factory()->create([ + 'type' => VoucherType::FIXED->value, + 'discount_amount' => 20000, + ]); + + // Assign voucher to user + DB::table('user_voucher')->insert([ + 'user_id' => $memberUser->id, + 'voucher_id' => $voucher->id, + 'quantity' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + OrderItem::factory()->forProduct($product)->create([ + 'user_id' => $this->user->id, + 'order_id' => null, + 'quantity' => 1, + 'unit_price' => 100000, + ]); + + Livewire::actingAs($this->user) + ->test(Create::class) + ->set('form.outlet_id', $this->outlet->id) + ->set('form.member_id', $memberUser->customer->id) + ->set('form.voucher_id', $voucher->id) + ->call('save') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('orders', [ + 'voucher_id' => $voucher->id, + 'total' => 80000, + 'discount' => 20000, + ]); + + // Check voucher used up + $this->assertDatabaseMissing('user_voucher', [ + 'user_id' => $memberUser->id, + 'voucher_id' => $voucher->id, + ]); +}); + +it('can delete an order and restore outlet stock', function () { + $product = Product::factory()->create(); + $order = Order::factory()->create([ + 'outlet_id' => $this->outlet->id, + 'user_id' => $this->user->id, + 'total' => 100000, + ]); + + $item = OrderItem::factory()->forProduct($product)->create([ + 'order_id' => $order->id, + 'user_id' => $this->user->id, + 'quantity' => 2, + ]); + + // Initial stock (after order was supposedly created) + $this->outlet->products()->attach($product->id, ['stock' => 10]); + + Livewire::actingAs($this->user) + ->test(Index::class) + ->call('delete', $order->id) + ->assertHasNoErrors(); + + $this->assertSoftDeleted('orders', ['id' => $order->id]); + + // Check stock restored (10 + 2 = 12) + $this->assertDatabaseHas('outlet_product', [ + 'outlet_id' => $this->outlet->id, + 'product_id' => $product->id, + 'stock' => 12, + ]); +}); + +it('cannot create order with empty cart', function () { + Livewire::actingAs($this->user) + ->test(Create::class) + ->set('form.outlet_id', $this->outlet->id) + ->call('save') + ->assertHasNoErrors(); // Shows toast + + $this->assertDatabaseCount('orders', 0); +}); + +it('validates required fields', function () { + // Add something to cart first to pass empty cart check + OrderItem::factory()->forProduct()->create(['user_id' => $this->user->id, 'order_id' => null]); + + Livewire::actingAs($this->user) + ->test(Create::class) + ->set('form.outlet_id', '') + ->call('save') + ->assertHasErrors(['form.outlet_id' => 'required']); +}); + +it('cannot access order without permission', function () { + $this->user->roles()->detach(); + $this->user->permissions()->detach(); + + $this->actingAs($this->user) + ->get(route('studio.manage.order.index')) + ->assertForbidden(); + + $this->actingAs($this->user) + ->get(route('studio.manage.order.create')) + ->assertForbidden(); +}); diff --git a/tests/Feature/Studio/Manage/RestockTest.php b/tests/Feature/Studio/Manage/RestockTest.php new file mode 100644 index 0000000..283d355 --- /dev/null +++ b/tests/Feature/Studio/Manage/RestockTest.php @@ -0,0 +1,190 @@ +setupUser(); + $this->outlet = $this->user->outlets->first(); + + // Create permissions + $permissions = [ + 'view restock', + 'create restock', + 'update restock', + 'delete restock', + ]; + + foreach ($permissions as $permission) { + Permission::firstOrCreate(['name' => $permission]); + } + + $role = Role::create(['name' => 'Owner']); + $role->givePermissionTo($permissions); + $this->user->assignRole($role); +}); + +it('renders the restock index page', function () { + Livewire::actingAs($this->user) + ->test(Index::class) + ->assertStatus(200); +}); + +it('renders the restock create page', function () { + Livewire::actingAs($this->user) + ->test(Create::class) + ->assertStatus(200); +}); + +it('can add items to restock cart', function () { + $warehouse = Warehouse::factory()->create(); + $perfume = Perfume::factory()->create(['name' => 'Test Perfume']); + + // Add stock to warehouse + $warehouse->perfumes()->attach($perfume->id, ['stock' => 100]); + + Livewire::actingAs($this->user) + ->test(Create::class) + ->set('form.warehouse_id', $warehouse->id) + ->set('form.outlet_id', $this->outlet->id) + ->set('form.perfume_id', $perfume->id) + ->set('form.quantity_perfume', 10) + ->call('addItem', 'parfum') + ->assertSee('Test Perfume') + ->assertSee(10); +}); + +it('can store a restock and transfer stock', function () { + $warehouse = Warehouse::factory()->create(); + $perfume = Perfume::factory()->create(); + $product = Product::factory()->create(); + $bottle = Bottle::factory()->create(); + + // Seed initial stock + $warehouse->perfumes()->attach($perfume->id, ['stock' => 100]); + $warehouse->products()->attach($product->id, ['stock' => 100]); + $warehouse->bottles()->attach($bottle->id, ['stock' => 100]); + + Livewire::actingAs($this->user) + ->test(Create::class) + ->set('form.warehouse_id', $warehouse->id) + ->set('form.outlet_id', $this->outlet->id) + ->set('form.restock_date', now()->format('Y-m-d')) + ->set('form.note', 'Test Restock') + + // Add items + ->set('form.perfume_id', $perfume->id) + ->set('form.quantity_perfume', 10) + ->call('addItem', 'parfum') + + ->set('form.product_id', $product->id) + ->set('form.quantity_product', 5) + ->call('addItem', 'produk') + + ->set('form.bottle_id', $bottle->id) + ->set('form.quantity_bottle', 20) + ->call('addItem', 'botol') + + ->call('save') + ->assertRedirect(route('studio.manage.restock.index', ['notification' => 'Restock berhasil diproses.'])); + + // Verify DB records + $this->assertDatabaseHas('restocks', [ + 'warehouse_id' => $warehouse->id, + 'outlet_id' => $this->outlet->id, + 'note' => 'Test Restock', + ]); + + // Verify Warehouse Stock Decreased + $this->assertEquals(90, $warehouse->perfumes()->find($perfume->id)->pivot->stock); + $this->assertEquals(95, $warehouse->products()->find($product->id)->pivot->stock); + $this->assertEquals(80, $warehouse->bottles()->find($bottle->id)->pivot->stock); + + // Verify Outlet Stock Increased + $this->assertEquals(10, $this->outlet->perfumes()->find($perfume->id)->pivot->stock); + $this->assertEquals(5, $this->outlet->products()->find($product->id)->pivot->stock); + $this->assertEquals(20, $this->outlet->bottles()->find($bottle->id)->pivot->stock); +}); + +it('cannot restock more than available warehouse stock', function () { + $warehouse = Warehouse::factory()->create(); + $perfume = Perfume::factory()->create(); + $warehouse->perfumes()->attach($perfume->id, ['stock' => 10]); + + Livewire::actingAs($this->user) + ->test(Create::class) + ->set('form.warehouse_id', $warehouse->id) + ->set('form.outlet_id', $this->outlet->id) + ->set('form.restock_date', now()->format('Y-m-d')) + ->set('form.perfume_id', $perfume->id) + ->set('form.quantity_perfume', 20) // Requesting more than 10 + ->call('addItem', 'parfum'); + + // Assert that the item was NOT added to the cart + $this->assertDatabaseMissing('restock_items', [ + 'restockable_id' => $perfume->id, + 'quantity' => 20, + ]); +}); + +it('can delete a restock and reverse stock transfer', function () { + $warehouse = Warehouse::factory()->create(); + $perfume = Perfume::factory()->create(); + + // Initial state: Warehouse 90, Outlet 10 (simulating after a restock of 10) + $warehouse->perfumes()->attach($perfume->id, ['stock' => 90]); + $this->outlet->perfumes()->attach($perfume->id, ['stock' => 10]); + + $restock = Restock::create([ + 'warehouse_id' => $warehouse->id, + 'outlet_id' => $this->outlet->id, + 'restock_date' => now(), + 'note' => 'To be deleted', + ]); + + $restock->items()->create([ + 'user_id' => $this->user->id, + 'restockable_type' => Perfume::class, + 'restockable_id' => $perfume->id, + 'quantity' => 10, + ]); + + Livewire::actingAs($this->user) + ->test(Index::class) + ->call('delete', $restock->id); + + $this->assertSoftDeleted('restocks', ['id' => $restock->id]); + + // Verify Stock Reversal + $this->assertEquals(100, $warehouse->perfumes()->find($perfume->id)->pivot->stock); // 90 + 10 + $this->assertEquals(0, $this->outlet->perfumes()->find($perfume->id)->pivot->stock); // 10 - 10 +}); + +it('validates restock input', function () { + $warehouse = Warehouse::factory()->create(); + $perfume = Perfume::factory()->create(); + $warehouse->perfumes()->attach($perfume->id, ['stock' => 100]); + + Livewire::actingAs($this->user) + ->test(Create::class) + ->set('form.warehouse_id', $warehouse->id) + ->set('form.outlet_id', $this->outlet->id) + ->set('form.perfume_id', $perfume->id) + ->set('form.quantity_perfume', 10) + ->call('addItem', 'parfum') + + // Reset required fields + ->set('form.restock_date', '') + ->set('form.outlet_id', '') // Warehouse ID is needed for item adding, but outlet ID is validated on save + + ->call('save') + ->assertHasErrors(['form.restock_date', 'form.outlet_id']); +}); diff --git a/tests/Feature/Studio/Manage/StockOpnameTest.php b/tests/Feature/Studio/Manage/StockOpnameTest.php new file mode 100644 index 0000000..f85555c --- /dev/null +++ b/tests/Feature/Studio/Manage/StockOpnameTest.php @@ -0,0 +1,134 @@ +setupUser(); + $this->outlet = $this->user->outlets->first(); + + // Create permissions + $permissions = [ + 'view stock opname', + 'create stock opname', + 'manage stock opname', + 'show stock opname', + 'delete stock opname', + ]; + + foreach ($permissions as $permission) { + Permission::firstOrCreate(['name' => $permission]); + } + + $role = Role::create(['name' => 'Owner']); + $role->givePermissionTo($permissions); + $this->user->assignRole($role); +}); + +it('renders the stock opname index page', function () { + Livewire::actingAs($this->user) + ->test(Index::class) + ->assertStatus(200); +}); + +it('can update physical quantity in manage page', function () { + // Create pre-existing StockOpname + $stockOpname = StockOpname::create([ + 'outlet_id' => $this->outlet->id, + 'period_month' => now()->format('Y-m'), + 'status' => StockOpnameStatus::PROCESS, + ]); + + $perfume = Perfume::factory()->create(); + $item = StockOpnameItem::create([ + 'stock_opname_id' => $stockOpname->id, + 'user_id' => $this->user->id, + 'itemable_type' => Perfume::class, + 'itemable_id' => $perfume->id, + 'qty_system' => 10, + ]); + + Livewire::actingAs($this->user) + ->test(Manage::class, ['stockOpname' => $stockOpname]) + ->set('form.outlet_stock.'.$item->id, 8) // Physical count is 8 + ->assertSet('form.outlet_stock.'.$item->id, 8); + + // Manage component updates via updated hook, or explicitly called? + // Manage.php: updated(...) calls form->update(). + + $this->assertDatabaseHas('stock_opname_items', [ + 'id' => $item->id, + 'qty_physical' => 8, + ]); +}); + +it('can request approval for stock opname', function () { + $stockOpname = StockOpname::create([ + 'outlet_id' => $this->outlet->id, + 'period_month' => now()->format('Y-m'), + 'status' => StockOpnameStatus::PROCESS, + 'note' => 'Ready for approval', + ]); + + Livewire::actingAs($this->user) + ->test(Index::class) + ->call('requestApproval', $stockOpname->id); // Passing ID or Model? Method signature says Model. Livewire handles ID binding. + + $this->assertDatabaseHas('stock_opname', [ + 'id' => $stockOpname->id, + 'status' => StockOpnameStatus::PENDING_APPROVAL->value, + ]); +}); + +it('can approve stock opname and update outlet stock', function () { + $stockOpname = StockOpname::create([ + 'outlet_id' => $this->outlet->id, + 'period_month' => now()->format('Y-m'), + 'status' => StockOpnameStatus::PENDING_APPROVAL, + ]); + + $perfume = Perfume::factory()->create(); + // Verify initial stock of perfume in outlet (empty or 0) + // Attach to outlet first to mimic existing stock + $this->outlet->perfumes()->attach($perfume->id, ['stock' => 10]); + + $item = StockOpnameItem::create([ + 'stock_opname_id' => $stockOpname->id, + 'user_id' => $this->user->id, + 'itemable_type' => Perfume::class, + 'itemable_id' => $perfume->id, + 'qty_system' => 10, + 'qty_physical' => 8, // Found 8, so stock should become 8 (loss of 2) + ]); + + Livewire::actingAs($this->user) + ->test(Index::class) + ->call('approveApproval', $stockOpname->id); + + $this->assertDatabaseHas('stock_opname', [ + 'id' => $stockOpname->id, + 'status' => StockOpnameStatus::COMPLETED->value, + ]); + + // Verify outlet stock updated + $this->assertEquals(8, $this->outlet->perfumes()->find($perfume->id)->pivot->stock); +}); + +it('cannot manage stock opname if not in process status', function () { + $stockOpname = StockOpname::create([ + 'outlet_id' => $this->outlet->id, + 'period_month' => now()->format('Y-m'), + 'status' => StockOpnameStatus::COMPLETED, + ]); + + Livewire::actingAs($this->user) + ->test(Manage::class, ['stockOpname' => $stockOpname]) + ->assertForbidden(); +});