feat: implement transaction management in OrderService for create, update, delete, and status transition operations; refactor error handling for improved clarity; add comprehensive tests for order management functionalities to ensure proper permission checks and validation

This commit is contained in:
Yoga Pangestu 2026-07-04 18:46:04 +07:00
parent 8e306c8f73
commit b52b9ce08c
2 changed files with 615 additions and 64 deletions

View File

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

View File

@ -0,0 +1,585 @@
<?php
use App\Enums\OrderChannel;
use App\Enums\OrderStatus;
use App\Enums\PaymentType;
use App\Enums\Permission as PermissionEnum;
use App\Enums\PriceType;
use App\Models\Customer;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Models\ProductPrice;
use App\Models\ProductVariant;
use App\Models\User;
use Database\Seeders\CashAccountSeeder;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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();
});
});