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\PaymentMethod;
|
||||||
use App\Enums\PaymentStatus;
|
use App\Enums\PaymentStatus;
|
||||||
use App\Enums\PointRecordType;
|
use App\Enums\PointRecordType;
|
||||||
|
use App\Models\Customer;
|
||||||
use App\Models\Order;
|
use App\Models\Order;
|
||||||
use App\Models\Payment;
|
use App\Models\Payment;
|
||||||
use App\Models\PointRecord;
|
use App\Models\PointRecord;
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Voucher;
|
use App\Models\Voucher;
|
||||||
use App\Rules\UnsignedInteger;
|
use App\Rules\UnsignedInteger;
|
||||||
use App\Traits\Media\WithMediaHandler;
|
use App\Traits\Media\WithMediaHandler;
|
||||||
@ -119,23 +119,21 @@ public function store(): array
|
|||||||
// Ensures atomicity — if any step fails, all changes are rolled back
|
// Ensures atomicity — if any step fails, all changes are rolled back
|
||||||
try {
|
try {
|
||||||
DB::transaction(function () use (&$order, $invoiceNumber, $cogs, $subtotal, $discount, $total, $items, $pointsEarned) {
|
DB::transaction(function () use (&$order, $invoiceNumber, $cogs, $subtotal, $discount, $total, $items, $pointsEarned) {
|
||||||
$member = User::whereHas('customer', fn ($q) => $q->where('id', $this->member_id))
|
$customer = $this->member_id ? Customer::find($this->member_id) : null;
|
||||||
->with(['membership' => fn ($q) => $q->lockForUpdate()])
|
$member = $customer?->user()->with(['membership' => fn ($q) => $q->lockForUpdate()])->first();
|
||||||
->first();
|
|
||||||
|
|
||||||
$order = Order::create([
|
$order = Order::create([
|
||||||
'outlet_id' => $this->outlet_id,
|
'outlet_id' => $this->outlet_id,
|
||||||
'user_id' => auth()->id(),
|
'user_id' => auth()->id(),
|
||||||
'voucher_id' => $this->voucher_id == '' ? null : $this->voucher_id,
|
'voucher_id' => $this->voucher_id ?: null,
|
||||||
'customer_id' => $this->member_id == '' ? null : $this->member_id,
|
'customer_id' => $customer?->id,
|
||||||
'invoice_number' => $invoiceNumber,
|
'invoice_number' => $invoiceNumber,
|
||||||
'cogs' => $cogs,
|
'cogs' => $cogs,
|
||||||
'subtotal' => $subtotal,
|
'subtotal' => $subtotal,
|
||||||
'discount' => $discount,
|
'discount' => $discount,
|
||||||
'total' => $total,
|
'total' => $total,
|
||||||
'channel' => $this->channel,
|
'channel' => (int) $this->channel,
|
||||||
'status' => $this->status,
|
'status' => (int) $this->status,
|
||||||
'payment_method' => $this->payment_method,
|
|
||||||
'ordered_at' => now(),
|
'ordered_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
namespace App\Livewire\Studio\Manage\Order;
|
namespace App\Livewire\Studio\Manage\Order;
|
||||||
|
|
||||||
use App\Models\Order;
|
use App\Models\Order;
|
||||||
|
use App\Traits\Authorization\WithAuthorization;
|
||||||
use App\Traits\Components\WithCloseModal;
|
use App\Traits\Components\WithCloseModal;
|
||||||
use App\Traits\Components\WithConfirmation;
|
use App\Traits\Components\WithConfirmation;
|
||||||
use App\Traits\Components\WithToast;
|
use App\Traits\Components\WithToast;
|
||||||
@ -17,7 +18,7 @@
|
|||||||
#[Title('Order')]
|
#[Title('Order')]
|
||||||
class Index extends Component
|
class Index extends Component
|
||||||
{
|
{
|
||||||
use WithCloseModal, WithConfirmation, WithSubscribeNotification, WithToast, WithUpdateStock;
|
use WithAuthorization, WithCloseModal, WithConfirmation, WithSubscribeNotification, WithToast, WithUpdateStock;
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
use App\Traits\Restock\WithRestockUpdateItem;
|
use App\Traits\Restock\WithRestockUpdateItem;
|
||||||
use App\Traits\Utilities\WithUpdatedData;
|
use App\Traits\Utilities\WithUpdatedData;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use Livewire\Attributes\Title;
|
use Livewire\Attributes\Title;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
@ -107,6 +108,8 @@ public function save(): void
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$this->form->store();
|
$this->form->store();
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
throw $e;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->toast($e->getMessage(), 'Gagal', 'danger');
|
$this->toast($e->getMessage(), 'Gagal', 'danger');
|
||||||
|
|
||||||
|
|||||||
@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Attributes\Scope;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
@ -23,15 +21,18 @@ protected function casts(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Scope]
|
public function recalculateTier(): void
|
||||||
protected function scopeForSpending(Builder $query, $totalSpending): void
|
|
||||||
{
|
{
|
||||||
$query
|
$newTier = Tier::where('min_spending', '<=', $this->total_spending)
|
||||||
->where('min_spending', '<=', $totalSpending)
|
->where(function ($query) {
|
||||||
->where(function ($q) use ($totalSpending) {
|
$query->whereNull('max_spending')
|
||||||
$q->whereNull('max_spending')
|
->orWhere('max_spending', '>=', $this->total_spending);
|
||||||
->orWhere('max_spending', '>=', $totalSpending);
|
})
|
||||||
});
|
->first();
|
||||||
|
|
||||||
|
if ($newTier && $newTier->id !== $this->tier_id) {
|
||||||
|
$this->update(['tier_id' => $newTier->id]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function tier(): BelongsTo
|
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))
|
$this->vouchers = Voucher::whereHas('users', fn ($q) => $q->where('user_id', $memberId))
|
||||||
->whereHas('outlets', fn ($q) => $q->where('outlet_id', $outletId))
|
->whereHas('outlets', fn ($q) => $q->where('outlet_id', $outletId))
|
||||||
->active()
|
->active()
|
||||||
->select('id', 'name', 'type', 'discount_amount', 'max_discount')
|
->orderBy('discount_amount', 'desc')
|
||||||
->orderByRaw("
|
|
||||||
CASE
|
|
||||||
WHEN type = 'percentage' THEN
|
|
||||||
LEAST(discount_amount / 100 * COALESCE(max_discount, 99999999), COALESCE(max_discount, 99999999))
|
|
||||||
ELSE
|
|
||||||
discount_amount
|
|
||||||
END DESC
|
|
||||||
")
|
|
||||||
->pluck('name', 'id')
|
->pluck('name', 'id')
|
||||||
->toArray();
|
->toArray();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -76,6 +76,7 @@
|
|||||||
|
|
||||||
<div class="flex flex-col lg:flex-row gap-4">
|
<div class="flex flex-col lg:flex-row gap-4">
|
||||||
<div class="w-full lg:w-3/4 space-y-4">
|
<div class="w-full lg:w-3/4 space-y-4">
|
||||||
|
@if ($form->outlet_id)
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-start">
|
<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:card class="space-y-6 p-6 md:col-span-2">
|
||||||
<flux:tab.group>
|
<flux:tab.group>
|
||||||
@ -96,8 +97,8 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</flux:select>
|
</flux:select>
|
||||||
|
|
||||||
<flux:select label="Botol" variant="listbox" searchable placeholder="Cari botol..."
|
<flux:select label="Botol" variant="listbox" searchable
|
||||||
wire:model.live="form.bottle_id">
|
placeholder="Cari botol..." wire:model.live="form.bottle_id">
|
||||||
@foreach ($bottles as $key => $bottle)
|
@foreach ($bottles as $key => $bottle)
|
||||||
<flux:select.option value="{{ $key }}"
|
<flux:select.option value="{{ $key }}"
|
||||||
wire:key="new-{{ $key }}">{{ $bottle }}
|
wire:key="new-{{ $key }}">{{ $bottle }}
|
||||||
@ -120,7 +121,8 @@
|
|||||||
<flux:field>
|
<flux:field>
|
||||||
<flux:label>Kualitas</flux:label>
|
<flux:label>Kualitas</flux:label>
|
||||||
<br />
|
<br />
|
||||||
<span class="text-sm text-zinc-500 italic">Silakan pilih botol...</span>
|
<span class="text-sm text-zinc-500 italic">Silakan pilih
|
||||||
|
botol...</span>
|
||||||
</flux:field>
|
</flux:field>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@ -143,8 +145,8 @@
|
|||||||
<flux:label>Ukuran Botol</flux:label>
|
<flux:label>Ukuran Botol</flux:label>
|
||||||
<flux:input.group>
|
<flux:input.group>
|
||||||
<flux:input placeholder="Masukkan ukuran botol"
|
<flux:input placeholder="Masukkan ukuran botol"
|
||||||
x-mask:dynamic="$money($input, ',')" wire:model.live="form.bottle_size"
|
x-mask:dynamic="$money($input, ',')"
|
||||||
autocomplete="off" />
|
wire:model.live="form.bottle_size" autocomplete="off" />
|
||||||
<flux:input.group.suffix>ml</flux:input.group.suffix>
|
<flux:input.group.suffix>ml</flux:input.group.suffix>
|
||||||
</flux:input.group>
|
</flux:input.group>
|
||||||
</flux:field>
|
</flux:field>
|
||||||
@ -201,6 +203,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</flux:card>
|
</flux:card>
|
||||||
</div>
|
</div>
|
||||||
|
@else
|
||||||
|
<flux:card class="p-6 text-center text-zinc-500 italic">
|
||||||
|
Silakan pilih Outlet terlebih dahulu untuk menampilkan daftar item.
|
||||||
|
</flux:card>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full lg:w-1/3 space-y-4">
|
<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