feat: Implement comprehensive order and stock management with membership, vouchers, and dedicated tests.

This commit is contained in:
Yoga Pangestu 2026-01-02 22:15:57 +07:00
parent 99c83e08f8
commit b47a7ff0a3
9 changed files with 824 additions and 144 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -76,131 +76,138 @@
<div class="flex flex-col lg:flex-row gap-4">
<div class="w-full lg:w-3/4 space-y-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-start">
<flux:card class="space-y-6 p-6 md:col-span-2">
<flux:tab.group>
<flux:tabs wire:model="tab" variant="pills">
<flux:tab name="new">Baru</flux:tab>
<flux:tab name="refill">Isi Ulang</flux:tab>
</flux:tabs>
@if ($form->outlet_id)
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-start">
<flux:card class="space-y-6 p-6 md:col-span-2">
<flux:tab.group>
<flux:tabs wire:model="tab" variant="pills">
<flux:tab name="new">Baru</flux:tab>
<flux:tab name="refill">Isi Ulang</flux:tab>
</flux:tabs>
<flux:tab.panel name="new">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<flux:select label="Parfum" variant="listbox" searchable
placeholder="Cari parfum..." wire:model.live="form.perfume_id">
@foreach ($perfumes as $key => $perfume)
<flux:select.option value="{{ $key }}"
wire:key="new-{{ $key }}">
{{ $perfume }}
</flux:select.option>
@endforeach
</flux:select>
<flux:tab.panel name="new">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<flux:select label="Parfum" variant="listbox" searchable
placeholder="Cari parfum..." wire:model.live="form.perfume_id">
@foreach ($perfumes as $key => $perfume)
<flux:select.option value="{{ $key }}"
wire:key="new-{{ $key }}">
{{ $perfume }}
</flux:select.option>
@endforeach
</flux:select>
<flux:select label="Botol" variant="listbox" searchable placeholder="Cari botol..."
wire:model.live="form.bottle_id">
@foreach ($bottles as $key => $bottle)
<flux:select.option value="{{ $key }}"
wire:key="new-{{ $key }}">{{ $bottle }}
</flux:select.option>
@endforeach
</flux:select>
<flux:select label="Botol" variant="listbox" searchable
placeholder="Cari botol..." wire:model.live="form.bottle_id">
@foreach ($bottles as $key => $bottle)
<flux:select.option value="{{ $key }}"
wire:key="new-{{ $key }}">{{ $bottle }}
</flux:select.option>
@endforeach
</flux:select>
<div class="col-span-1 md:col-span-2">
@if (!empty($qualities))
<flux:radio.group label="Kualitas" wire:model.live="form.quality_id"
variant="buttons" class="w-full *:flex-1">
@foreach ($qualities as $key => $value)
<flux:radio value="{{ $key }}"
wire:key="new-{{ $key }}">
{{ $value }}
</flux:radio>
@endforeach
</flux:radio.group>
@else
<flux:field>
<flux:label>Kualitas</flux:label>
<br />
<span class="text-sm text-zinc-500 italic">Silakan pilih botol...</span>
</flux:field>
@endif
<div class="col-span-1 md:col-span-2">
@if (!empty($qualities))
<flux:radio.group label="Kualitas" wire:model.live="form.quality_id"
variant="buttons" class="w-full *:flex-1">
@foreach ($qualities as $key => $value)
<flux:radio value="{{ $key }}"
wire:key="new-{{ $key }}">
{{ $value }}
</flux:radio>
@endforeach
</flux:radio.group>
@else
<flux:field>
<flux:label>Kualitas</flux:label>
<br />
<span class="text-sm text-zinc-500 italic">Silakan pilih
botol...</span>
</flux:field>
@endif
</div>
</div>
</div>
</flux:tab.panel>
</flux:tab.panel>
<flux:tab.panel name="refill">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<flux:select label="Parfum" variant="listbox" searchable
placeholder="Cari parfum..." wire:model.live="form.perfume_id">
@foreach ($perfumes as $key => $perfume)
<flux:select.option value="{{ $key }}"
wire:key="refill-{{ $key }}">
{{ $perfume }}
</flux:select.option>
@endforeach
</flux:select>
<flux:tab.panel name="refill">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<flux:select label="Parfum" variant="listbox" searchable
placeholder="Cari parfum..." wire:model.live="form.perfume_id">
@foreach ($perfumes as $key => $perfume)
<flux:select.option value="{{ $key }}"
wire:key="refill-{{ $key }}">
{{ $perfume }}
</flux:select.option>
@endforeach
</flux:select>
<flux:field>
<flux:label>Ukuran Botol</flux:label>
<flux:input.group>
<flux:input placeholder="Masukkan ukuran botol"
x-mask:dynamic="$money($input, ',')" wire:model.live="form.bottle_size"
autocomplete="off" />
<flux:input.group.suffix>ml</flux:input.group.suffix>
</flux:input.group>
</flux:field>
<flux:field>
<flux:label>Ukuran Botol</flux:label>
<flux:input.group>
<flux:input placeholder="Masukkan ukuran botol"
x-mask:dynamic="$money($input, ',')"
wire:model.live="form.bottle_size" autocomplete="off" />
<flux:input.group.suffix>ml</flux:input.group.suffix>
</flux:input.group>
</flux:field>
<div class="col-span-1 md:col-span-2">
@if (!empty($qualities))
<flux:radio.group label="Kualitas" wire:model.live="form.quality_id"
variant="buttons" class="w-full *:flex-1">
@foreach ($qualities as $key => $value)
<flux:radio value="{{ $key }}"
wire:key="refill-{{ $key }}">
{{ $value }}
</flux:radio>
@endforeach
</flux:radio.group>
@else
<flux:field>
<flux:label>Kualitas</flux:label>
<br />
<flux:text class="text-sm italic">Silakan masukan ukuran
botol...</flux:text>
</flux:field>
@endif
<div class="col-span-1 md:col-span-2">
@if (!empty($qualities))
<flux:radio.group label="Kualitas" wire:model.live="form.quality_id"
variant="buttons" class="w-full *:flex-1">
@foreach ($qualities as $key => $value)
<flux:radio value="{{ $key }}"
wire:key="refill-{{ $key }}">
{{ $value }}
</flux:radio>
@endforeach
</flux:radio.group>
@else
<flux:field>
<flux:label>Kualitas</flux:label>
<br />
<flux:text class="text-sm italic">Silakan masukan ukuran
botol...</flux:text>
</flux:field>
@endif
</div>
</div>
</div>
</flux:tab.panel>
</flux:tab.group>
</flux:tab.panel>
</flux:tab.group>
<div class="flex justify-end">
<flux:button variant="primary" color="zinc" wire:click="addPerfume">
Tambah
</flux:button>
</div>
<div class="flex justify-end">
<flux:button variant="primary" color="zinc" wire:click="addPerfume">
Tambah
</flux:button>
</div>
</flux:card>
<flux:card class="space-y-6 p-6">
<flux:select label="Produk" variant="listbox" searchable placeholder="Cari produk..."
wire:model.live="form.product_id">
@foreach ($products as $key => $product)
<flux:select.option value="{{ $key }}"
wire:key="product-{{ $key }}">{{ $product }}
</flux:select.option>
@endforeach
</flux:select>
<flux:input label="Kuantitas" placeholder="Masukkan kuantitas produk"
wire:model.live="form.quantity_product" autocomplete="off"
x-mask:dynamic="$money($input, ',')" />
<div class="flex justify-end">
<flux:button variant="primary" color="zinc" wire:click="addProduct">
Tambah
</flux:button>
</div>
</flux:card>
</div>
@else
<flux:card class="p-6 text-center text-zinc-500 italic">
Silakan pilih Outlet terlebih dahulu untuk menampilkan daftar item.
</flux:card>
<flux:card class="space-y-6 p-6">
<flux:select label="Produk" variant="listbox" searchable placeholder="Cari produk..."
wire:model.live="form.product_id">
@foreach ($products as $key => $product)
<flux:select.option value="{{ $key }}"
wire:key="product-{{ $key }}">{{ $product }}
</flux:select.option>
@endforeach
</flux:select>
<flux:input label="Kuantitas" placeholder="Masukkan kuantitas produk"
wire:model.live="form.quantity_product" autocomplete="off"
x-mask:dynamic="$money($input, ',')" />
<div class="flex justify-end">
<flux:button variant="primary" color="zinc" wire:click="addProduct">
Tambah
</flux:button>
</div>
</flux:card>
</div>
@endif
</div>
<div class="w-full lg:w-1/3 space-y-4">

View File

@ -0,0 +1,354 @@
<?php
use App\Enums\Gender;
use App\Enums\PaymentStatus;
use App\Enums\VoucherType;
use App\Livewire\Studio\Manage\Order\Create;
use App\Livewire\Studio\Manage\Order\Index;
use App\Models\Bottle;
use App\Models\Formula;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Perfume;
use App\Models\Product;
use App\Models\User;
use App\Models\Voucher;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
beforeEach(function () {
$this->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();
});

View File

@ -0,0 +1,190 @@
<?php
use App\Livewire\Studio\Manage\Restock\Create;
use App\Livewire\Studio\Manage\Restock\Index;
use App\Models\Bottle;
use App\Models\Perfume;
use App\Models\Product;
use App\Models\Restock;
use App\Models\Warehouse;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
beforeEach(function () {
$this->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']);
});

View File

@ -0,0 +1,134 @@
<?php
use App\Enums\StockOpnameStatus;
use App\Livewire\Studio\Manage\StockOpname\Index;
use App\Livewire\Studio\Manage\StockOpname\Manage;
use App\Models\Perfume;
use App\Models\StockOpname;
use App\Models\StockOpnameItem;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
beforeEach(function () {
$this->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();
});