diff --git a/app/Services/Manage/OrderService.php b/app/Services/Manage/OrderService.php index f09319d..2969ee7 100644 --- a/app/Services/Manage/OrderService.php +++ b/app/Services/Manage/OrderService.php @@ -13,6 +13,7 @@ use App\Models\Product; use App\Models\ProductVariant; use App\Models\User; +use App\Services\Concerns\RunsInTransaction; use App\Services\Finance\CashService; use App\Services\Media\MediaService; use App\Services\System\PushNotificationService; @@ -23,11 +24,12 @@ use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; class OrderService { + use RunsInTransaction; + private const MAX_PHOTOS = 1; public function __construct( @@ -388,16 +390,16 @@ public function resyncDraftPrices(User $user, string $priceTypeValue): array public function create(array $validated, User $user): Order { - try { - // Force cashier settings - if ($user->hasRole('cashier')) { - $validated['channel'] = 'store'; - $validated['price_type'] = 'retail'; - $validated['payment_type'] = 'cash'; - unset($validated['customer_id']); - } + // Force cashier settings + if ($user->hasRole('cashier')) { + $validated['channel'] = 'store'; + $validated['price_type'] = 'retail'; + $validated['payment_type'] = 'cash'; + unset($validated['customer_id']); + } - $order = DB::transaction(function () use ($validated, $user): Order { + $order = $this->runInTransaction( + function () use ($validated, $user): Order { $priceType = $this->resolvePriceType($validated['channel'], $validated['price_type']); $draftItems = $this->draftItemsQuery($user) @@ -482,18 +484,9 @@ public function create(array $validated, User $user): Order } return $order; - }); - } catch (ValidationException $e) { - throw $e; - } catch (\Throwable $e) { - Log::error('Gagal membuat pesanan: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + }, + 'Gagal membuat pesanan', + ); $this->pushNotificationService->sendToRoles( '📦 Pesanan Baru', @@ -509,8 +502,8 @@ public function update(Order $order, array $validated): void { $order->ensureEditable(); - try { - DB::transaction(function () use ($order, $validated): void { + $this->runInTransaction( + function () use ($order, $validated): void { $order->load('items'); foreach ($order->items as $item) { @@ -580,18 +573,9 @@ public function update(Order $order, array $validated): void $orderItem = $order->items()->create($itemData); $this->decrementStock($orderItem); } - }); - } catch (ValidationException $e) { - throw $e; - } catch (\Throwable $e) { - Log::error('Gagal memperbarui pesanan: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + }, + 'Gagal memperbarui pesanan', + ); $this->pushNotificationService->sendToRoles( '✏️ Pesanan Diperbarui', @@ -606,8 +590,8 @@ public function delete(Order $order): void $orderNumber = $order->order_number; $totalAmount = $order->total_amount; - try { - DB::transaction(function () use ($order): void { + $this->runInTransaction( + function () use ($order): void { $order->load('items'); if ($order->status->isEditable()) { @@ -622,18 +606,9 @@ public function delete(Order $order): void $order->items()->delete(); $order->delete(); - }); - } catch (ValidationException $e) { - throw $e; - } catch (\Throwable $e) { - Log::error('Gagal menghapus pesanan: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + }, + 'Gagal menghapus pesanan', + ); $this->pushNotificationService->sendToRoles( '🗑️ Pesanan Dihapus', @@ -651,8 +626,8 @@ public function transitionStatus(Order $order, OrderStatus $status): void ]); } - try { - DB::transaction(function () use ($order, $status): void { + $this->runInTransaction( + function () use ($order, $status): void { if ($status === OrderStatus::CANCELLED) { $order->load('items'); @@ -668,18 +643,9 @@ public function transitionStatus(Order $order, OrderStatus $status): void $order->status = $status; $order->save(); - }); - } catch (ValidationException $e) { - throw $e; - } catch (\Throwable $e) { - Log::error('Gagal mengubah status pesanan: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + }, + 'Gagal mengubah status pesanan', + ); $this->pushNotificationService->sendToRoles( '📦 Status Pesanan Diubah', diff --git a/tests/Feature/Admin/Manage/OrderTest.php b/tests/Feature/Admin/Manage/OrderTest.php new file mode 100644 index 0000000..658b089 --- /dev/null +++ b/tests/Feature/Admin/Manage/OrderTest.php @@ -0,0 +1,585 @@ +seed(RolePermissionSeeder::class); + User::factory()->create(); + $this->seed(CashAccountSeeder::class); + Storage::fake('public'); +}); + +// ─── Helper ─────────────────────────────────────────────── + +function createOrderUserWithPermission(PermissionEnum ...$permissions): User +{ + $user = User::factory()->create(); + + $user->givePermissionTo( + array_merge( + [PermissionEnum::DASHBOARD_VIEW->value], + array_map(fn (PermissionEnum $p) => $p->value, $permissions) + ) + ); + + $user->forgetCachedPermissions(); + + return $user; +} + +function createOrderWithItems(?User $user = null): Order +{ + $user ??= User::factory()->create(); + + $order = Order::factory()->create([ + 'created_by_id' => $user->id, + 'channel' => OrderChannel::STORE->value, + 'price_type' => PriceType::RETAIL->value, + 'payment_type' => PaymentType::CASH->value, + ]); + + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'stock' => 100, + ]); + + OrderItem::factory()->create([ + 'order_id' => $order->id, + 'product_variant_id' => $variant->id, + 'quantity' => 5, + 'unit_price' => 50000, + 'subtotal' => 250000, + ]); + + return $order; +} + +function setupOrderDraftItems(User $user): ProductVariant +{ + $product = Product::factory()->create(); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'stock' => 100, + ]); + + // Create product price so CuttingResultPriceResolver can resolve it + ProductPrice::factory()->create([ + 'variant_id' => $variant->id, + 'type' => PriceType::RETAIL, + 'price' => 50000, + ]); + + // Create draft item via API + test()->actingAs($user) + ->postJson(route('admin.manage.orders.draft_items.store'), [ + 'product_variant_id' => $variant->id, + 'price_type' => PriceType::RETAIL->value, + 'stock_quality' => 'good', + 'quantity' => 5, + ]) + ->assertOk(); + + return $variant; +} + +// ─── Index ──────────────────────────────────────────────── + +describe('Order Index', function () { + test('authenticated user with permission can view order index', function () { + $user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW); + + $this->actingAs($user) + ->get(route('admin.manage.orders.index')) + ->assertOk(); + }); + + test('guest is redirected to login', function () { + $this->get(route('admin.manage.orders.index')) + ->assertRedirect(route('login')); + }); + + test('user without permission is forbidden', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('admin.manage.orders.index')) + ->assertForbidden(); + }); + + test('index displays orders', function () { + $user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW); + + createOrderWithItems($user); + createOrderWithItems($user); + + $this->actingAs($user) + ->get(route('admin.manage.orders.index')) + ->assertOk(); + }); + + test('index can search orders by order number', function () { + $user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW); + + $order = Order::factory()->create([ + 'created_by_id' => $user->id, + 'order_number' => 'ORD-20260704-TEST', + ]); + + $this->actingAs($user) + ->get(route('admin.manage.orders.index', ['search' => 'TEST'])) + ->assertOk(); + }); +}); + +// ─── Create ─────────────────────────────────────────────── + +describe('Order Create', function () { + test('authenticated user with permission can view create form', function () { + $user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_CREATE); + + $this->actingAs($user) + ->get(route('admin.manage.orders.create')) + ->assertOk(); + }); + + test('guest is redirected to login', function () { + $this->get(route('admin.manage.orders.create')) + ->assertRedirect(route('login')); + }); + + test('user without create permission is forbidden', function () { + $user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW); + + $this->actingAs($user) + ->get(route('admin.manage.orders.create')) + ->assertForbidden(); + }); +}); + +// ─── Store ──────────────────────────────────────────────── + +describe('Order Store', function () { + test('authenticated user with permission can create an order', function () { + $user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_CREATE); + + $customer = Customer::factory()->create(); + setupOrderDraftItems($user); + + $this->actingAs($user) + ->post(route('admin.manage.orders.store'), [ + 'customer_id' => $customer->id, + 'channel' => OrderChannel::STORE->value, + 'price_type' => PriceType::RETAIL->value, + 'payment_type' => PaymentType::CASH->value, + 'discount' => 0, + ]) + ->assertRedirect(route('admin.manage.orders.index')); + + $order = Order::query()->latest()->first(); + expect($order)->not->toBeNull(); + expect($order->status)->toBe(OrderStatus::PENDING); + expect($order->items)->toHaveCount(1); + }); + + test('guest cannot create an order', function () { + $this->post(route('admin.manage.orders.store'), [ + 'channel' => OrderChannel::STORE->value, + 'price_type' => PriceType::RETAIL->value, + 'payment_type' => PaymentType::CASH->value, + ])->assertRedirect(route('login')); + }); + + test('user without create permission is forbidden', function () { + $user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW); + + $this->actingAs($user) + ->post(route('admin.manage.orders.store'), [ + 'channel' => OrderChannel::STORE->value, + 'price_type' => PriceType::RETAIL->value, + 'payment_type' => PaymentType::CASH->value, + ]) + ->assertForbidden(); + }); + + test('store fails without draft items', function () { + $user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_CREATE); + + $this->actingAs($user) + ->post(route('admin.manage.orders.store'), [ + 'channel' => OrderChannel::STORE->value, + 'price_type' => PriceType::RETAIL->value, + 'payment_type' => PaymentType::CASH->value, + 'discount' => 0, + ]) + ->assertSessionHasErrors('items'); + }); + + test('creating order decrements stock', function () { + $user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_CREATE); + + $variant = setupOrderDraftItems($user); + $initialStock = $variant->stock; + + $this->actingAs($user) + ->post(route('admin.manage.orders.store'), [ + 'channel' => OrderChannel::STORE->value, + 'price_type' => PriceType::RETAIL->value, + 'payment_type' => PaymentType::CASH->value, + 'discount' => 0, + ]); + + expect($variant->fresh()->stock)->toBe($initialStock - 5); + }); +}); + +// ─── Show ───────────────────────────────────────────────── + +describe('Order Show', function () { + test('authenticated user with permission can view order details', function () { + $user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW); + + $order = createOrderWithItems($user); + + $this->actingAs($user) + ->get(route('admin.manage.orders.show', $order)) + ->assertOk(); + }); + + test('guest is redirected to login', function () { + $order = createOrderWithItems(); + + $this->get(route('admin.manage.orders.show', $order)) + ->assertRedirect(route('login')); + }); +}); + +// ─── Edit ───────────────────────────────────────────────── + +describe('Order Edit', function () { + test('authenticated user with permission can view edit form', function () { + $user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_UPDATE); + + $order = createOrderWithItems($user); + + $this->actingAs($user) + ->get(route('admin.manage.orders.edit', $order)) + ->assertOk(); + }); + + test('guest is redirected to login', function () { + $order = createOrderWithItems(); + + $this->get(route('admin.manage.orders.edit', $order)) + ->assertRedirect(route('login')); + }); + + test('user without update permission is forbidden', function () { + $user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW); + + $order = createOrderWithItems($user); + + $this->actingAs($user) + ->get(route('admin.manage.orders.edit', $order)) + ->assertForbidden(); + }); + + test('completed order cannot be edited', function () { + $user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_UPDATE); + + $order = Order::factory()->completed()->create(['created_by_id' => $user->id]); + + $this->actingAs($user) + ->get(route('admin.manage.orders.edit', $order)) + ->assertRedirect(route('admin.manage.orders.index')); + }); +}); + +// ─── Destroy ────────────────────────────────────────────── + +describe('Order Destroy', function () { + test('authenticated user with permission can delete an order', function () { + $user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_DELETE); + + $order = createOrderWithItems($user); + + $this->actingAs($user) + ->delete(route('admin.manage.orders.destroy', $order)) + ->assertRedirect(route('admin.manage.orders.index')); + + $this->assertSoftDeleted('orders', ['id' => $order->id]); + }); + + test('guest cannot delete an order', function () { + $order = createOrderWithItems(); + + $this->delete(route('admin.manage.orders.destroy', $order)) + ->assertRedirect(route('login')); + + $this->assertNotSoftDeleted('orders', ['id' => $order->id]); + }); + + test('user without delete permission is forbidden', function () { + $user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW); + + $order = createOrderWithItems($user); + + $this->actingAs($user) + ->delete(route('admin.manage.orders.destroy', $order)) + ->assertForbidden(); + + $this->assertNotSoftDeleted('orders', ['id' => $order->id]); + }); + + test('deleting order increments stock back', function () { + $user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_DELETE); + + $order = createOrderWithItems($user); + $item = $order->items->first(); + $variant = $item->productVariant; + $stockBeforeDelete = $variant->fresh()->stock; + + $this->actingAs($user) + ->delete(route('admin.manage.orders.destroy', $order)); + + expect($variant->fresh()->stock)->toBe($stockBeforeDelete + $item->quantity); + }); +}); + +// ─── Status Transition ──────────────────────────────────── + +describe('Order Status Transition', function () { + test('authenticated user can send an order', function () { + $user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_SEND); + + $order = Order::factory()->create([ + 'created_by_id' => $user->id, + 'status' => OrderStatus::PENDING->value, + ]); + + $this->actingAs($user) + ->post(route('admin.manage.orders.transition_status', $order), [ + 'status' => OrderStatus::PROCESSING->value, + ]) + ->assertRedirect(route('admin.manage.orders.index')); + + expect($order->fresh()->status)->toBe(OrderStatus::PROCESSING); + }); + + test('authenticated user can complete an order', function () { + $user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_COMPLETE); + + $order = Order::factory()->create([ + 'created_by_id' => $user->id, + 'status' => OrderStatus::PROCESSING->value, + ]); + + $this->actingAs($user) + ->post(route('admin.manage.orders.transition_status', $order), [ + 'status' => OrderStatus::COMPLETED->value, + ]); + + expect($order->fresh()->status)->toBe(OrderStatus::COMPLETED); + }); + + test('authenticated user can cancel an order', function () { + $user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_CANCEL); + + $order = Order::factory()->create([ + 'created_by_id' => $user->id, + 'status' => OrderStatus::PENDING->value, + ]); + + $this->actingAs($user) + ->post(route('admin.manage.orders.transition_status', $order), [ + 'status' => OrderStatus::CANCELLED->value, + ]); + + expect($order->fresh()->status)->toBe(OrderStatus::CANCELLED); + }); + + test('guest cannot transition status', function () { + $order = Order::factory()->create(['status' => OrderStatus::PENDING->value]); + + $this->post(route('admin.manage.orders.transition_status', $order), [ + 'status' => OrderStatus::PROCESSING->value, + ])->assertRedirect(route('login')); + }); + + test('invalid status transition is rejected', function () { + $user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_COMPLETE); + + $order = Order::factory()->create([ + 'created_by_id' => $user->id, + 'status' => OrderStatus::PENDING->value, + ]); + + // PENDING cannot go directly to COMPLETED + $this->actingAs($user) + ->post(route('admin.manage.orders.transition_status', $order), [ + 'status' => OrderStatus::COMPLETED->value, + ]) + ->assertSessionHasErrors('status'); + }); + + test('cancelling order increments stock back', function () { + $user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_CANCEL); + + $order = createOrderWithItems($user); + $item = $order->items->first(); + $variant = $item->productVariant; + $stockBeforeCancel = $variant->fresh()->stock; + + $this->actingAs($user) + ->post(route('admin.manage.orders.transition_status', $order), [ + 'status' => OrderStatus::CANCELLED->value, + ]); + + expect($variant->fresh()->stock)->toBe($stockBeforeCancel + $item->quantity); + }); +}); + +// ─── Order Model ────────────────────────────────────────── + +describe('Order Model', function () { + test('order uses soft deletes', function () { + $order = Order::factory()->create(); + + $order->delete(); + + expect($order->trashed())->toBeTrue(); + }); + + test('order has amounts cast to integer', function () { + $order = Order::factory()->create([ + 'subtotal' => 100000, + 'discount' => 5000, + 'total_amount' => 95000, + ]); + + expect($order->subtotal)->toBeInt(); + expect($order->discount)->toBeInt(); + expect($order->total_amount)->toBeInt(); + }); + + test('order has formatted amount accessors', function () { + $order = Order::factory()->create([ + 'subtotal' => 100000, + 'discount' => 5000, + 'total_amount' => 95000, + ]); + + expect($order->subtotal_formatted)->toBe('Rp 100.000'); + expect($order->discount_formatted)->toBe('Rp 5.000'); + expect($order->total_amount_formatted)->toBe('Rp 95.000'); + }); + + test('order has status cast to enum', function () { + $order = Order::factory()->create(['status' => OrderStatus::PENDING->value]); + + expect($order->status)->toBe(OrderStatus::PENDING); + }); + + test('order has status label accessor', function () { + $order = Order::factory()->create(['status' => OrderStatus::PENDING->value]); + + expect($order->status_label)->toBe('Menunggu'); + }); + + test('order belongs to customer', function () { + $customer = Customer::factory()->create(); + $order = Order::factory()->create(['customer_id' => $customer->id]); + + expect($order->customer)->not->toBeNull(); + expect($order->customer->id)->toBe($customer->id); + }); + + test('order belongs to created by user', function () { + $user = User::factory()->create(); + $order = Order::factory()->create(['created_by_id' => $user->id]); + + expect($order->createdBy)->not->toBeNull(); + expect($order->createdBy->id)->toBe($user->id); + }); + + test('order can have items', function () { + $order = Order::factory()->create(); + OrderItem::factory()->count(3)->create(['order_id' => $order->id]); + + expect($order->fresh()->items)->toHaveCount(3); + }); + + test('order status is editable for pending and processing', function () { + $pending = Order::factory()->create(['status' => OrderStatus::PENDING->value]); + $processing = Order::factory()->create(['status' => OrderStatus::PROCESSING->value]); + + expect($pending->status->isEditable())->toBeTrue(); + expect($processing->status->isEditable())->toBeTrue(); + }); +}); + +// ─── OrderItem Model ────────────────────────────────────── + +describe('OrderItem Model', function () { + test('order item has amounts cast to integer', function () { + $order = Order::factory()->create(); + $item = OrderItem::factory()->create([ + 'order_id' => $order->id, + 'quantity' => 10, + 'unit_price' => 50000, + 'subtotal' => 500000, + ]); + + expect($item->quantity)->toBeInt(); + expect($item->unit_price)->toBeInt(); + expect($item->subtotal)->toBeInt(); + }); + + test('order item has formatted amount accessors', function () { + $order = Order::factory()->create(); + $item = OrderItem::factory()->create([ + 'order_id' => $order->id, + 'quantity' => 10, + 'unit_price' => 50000, + 'subtotal' => 500000, + ]); + + expect($item->quantity_formatted)->toBe('10 pcs'); + expect($item->unit_price_formatted)->toBe('Rp 50.000'); + expect($item->subtotal_formatted)->toBe('Rp 500.000'); + }); + + test('order item belongs to order', function () { + $order = Order::factory()->create(); + $item = OrderItem::factory()->create(['order_id' => $order->id]); + + expect($item->order)->not->toBeNull(); + expect($item->order->id)->toBe($order->id); + }); + + test('order item uses soft deletes', function () { + $order = Order::factory()->create(); + $item = OrderItem::factory()->create(['order_id' => $order->id]); + + $item->delete(); + + expect($item->trashed())->toBeTrue(); + }); +});