feat: Implement comprehensive order and stock management with membership, vouchers, and dedicated tests.
This commit is contained in:
parent
99c83e08f8
commit
b47a7ff0a3
@ -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(),
|
||||
]);
|
||||
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
354
tests/Feature/Studio/Manage/OrderTest.php
Normal file
354
tests/Feature/Studio/Manage/OrderTest.php
Normal 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();
|
||||
});
|
||||
190
tests/Feature/Studio/Manage/RestockTest.php
Normal file
190
tests/Feature/Studio/Manage/RestockTest.php
Normal 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']);
|
||||
});
|
||||
134
tests/Feature/Studio/Manage/StockOpnameTest.php
Normal file
134
tests/Feature/Studio/Manage/StockOpnameTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user