self::PRODUCT_ID, 'code' => 'BERAS-001', 'name' => 'Beras Premium', 'cost_price' => 13000, 'is_active' => true, ]); InsForgeFake::seed('customers', [ 'id' => self::CUSTOMER_ID, 'customer_code' => 'CUST-0001', 'name' => 'Andi', ]); } private function actor(string $role, string $idSuffix = '0001'): User { $user = new User; $user->forceFill([ 'id' => "eeee5555-0000-0000-0000-{$role}{$idSuffix}", 'name' => ucfirst($role), 'email' => "{$role}@beraspro.test", 'role' => $role, 'is_active' => true, ]); return $user; } private function seedStock(int $quantity): void { InsForgeFake::seed('stocks', [ 'id' => 'cccc3333-0000-0000-0000-000000000010', 'product_id' => self::PRODUCT_ID, 'quantity' => $quantity, ]); } private function seedOrder(array $overrides = []): array { $order = array_merge([ 'id' => 'ffff6666-0000-0000-0000-000000000001', 'order_number' => 'ORD-20260825-0001', 'customer_id' => self::CUSTOMER_ID, 'created_by' => $this->actor('operator')->id, 'order_status' => OrderService::STATUS_PENDING, 'payment_method' => 'cash', 'payment_status' => 'unpaid', 'deleted_at' => null, 'order_date' => '2026-08-25', 'customers' => ['code' => 'CUST-0001', 'name' => 'Andi'], 'drivers' => null, ], $overrides); if (! array_key_exists('order_items', $order)) { $order['order_items'] = [ [ 'quantity' => 5, 'unit_price' => 15000, 'subtotal' => 75000, 'products' => ['code' => 'BERAS-001', 'name' => 'Beras Premium'], ], ]; } InsForgeFake::seed('orders', $order); InsForgeFake::seed('order_items', [ 'id' => 'aaaa7777-0000-0000-0000-000000000001', 'order_id' => $order['id'], 'product_id' => self::PRODUCT_ID, 'quantity' => 5, 'unit_price' => 15000, 'subtotal' => 75000, 'products' => ['code' => 'BERAS-001', 'name' => 'Beras Premium'], ]); return $order; } public function test_create_generates_number_history_and_items(): void { $this->seedStock(100); $result = app(OrderService::class)->createOrder([ 'customer_id' => self::CUSTOMER_ID, 'order_date' => '2026-08-25', ], [ ['product_id' => self::PRODUCT_ID, 'quantity' => 3, 'unit_price' => 15000], ], $this->actor('operator')); $this->assertSame('ORD-'.now()->format('Ymd').'-0001', $result['order_number']); $item = end(InsForgeFake::$store['order_items']); $this->assertSame(45000, $item['subtotal']); $this->assertSame(13000, $item['cost_price']); $history = end(InsForgeFake::$store['order_status_histories']); $this->assertSame(OrderService::STATUS_PENDING, $history['new_status']); $log = end(InsForgeFake::$store['activity_logs']); $this->assertSame('order_created', $log['action']); } public function test_create_rejects_when_stock_insufficient(): void { $this->seedStock(2); $this->expectException(ValidationException::class); app(OrderService::class)->createOrder([ 'customer_id' => self::CUSTOMER_ID, ], [ ['product_id' => self::PRODUCT_ID, 'quantity' => 10, 'unit_price' => 15000], ], $this->actor('operator')); } public function test_valid_transition_pending_to_processing(): void { $this->seedOrder(); app(OrderService::class)->transition($this->seedOrder()['id'], OrderService::STATUS_PROCESSING, null, $this->actor('admin')); $history = end(InsForgeFake::$store['order_status_histories']); $this->assertSame(OrderService::STATUS_PROCESSING, $history['new_status']); } public function test_invalid_jump_is_rejected(): void { $order = $this->seedOrder(); $this->expectException(ValidationException::class); app(OrderService::class)->transition($order['id'], OrderService::STATUS_DELIVERING, null, $this->actor('admin')); } public function test_cancel_by_creator_allowed_but_other_operator_denied(): void { $creator = $this->actor('operator'); $other = new User; $other->forceFill([ 'id' => 'eeee5555-9999-9999-9999-operatorX', 'name' => 'Other', 'email' => 'o@x.test', 'role' => 'operator', 'is_active' => true, ]); $order = $this->seedOrder(['created_by' => $creator->id]); app(OrderService::class)->transition($order['id'], OrderService::STATUS_CANCELLED, 'batal', $creator); $second = $this->seedOrder([ 'id' => 'ffff6666-0000-0000-0000-000000000002', 'order_number' => 'ORD-20260825-0002', 'created_by' => $creator->id, ]); $this->expectException(AuthorizationException::class); app(OrderService::class)->transition($second['id'], OrderService::STATUS_CANCELLED, null, $other); } public function test_completed_calls_rpc_once_and_stock_moves_once(): void { $this->seedStock(10); $order = $this->seedOrder(['order_status' => OrderService::STATUS_DELIVERING]); app(OrderService::class)->transition($order['id'], OrderService::STATUS_COMPLETED, null, $this->actor('admin')); $this->assertSame(1, count(InsForgeFake::$rpcCalls)); $this->assertSame('complete_order', InsForgeFake::$rpcCalls[0]['function']); // Stok berkurang sekali + satu baris mutasi order_out. $stock = array_values(array_filter(InsForgeFake::$store['stocks'], fn ($s) => $s['product_id'] === self::PRODUCT_ID))[0]; $this->assertSame(5, $stock['quantity']); $outs = array_filter(InsForgeFake::$store['stock_movements'], fn ($m) => $m['type'] === 'order_out' && $m['reference_id'] === $order['id']); $this->assertCount(1, $outs); // Ulangi penyelesaian: transisi lokal ditolak (status sudah completed), rpc TIDAK dipanggil lagi. try { app(OrderService::class)->transition($order['id'], OrderService::STATUS_COMPLETED, null, $this->actor('admin')); $this->fail('repeat completion should be rejected locally'); } catch (ValidationException) { } $this->assertSame(1, count(InsForgeFake::$rpcCalls)); $outsAfter = array_filter(InsForgeFake::$store['stock_movements'], fn ($m) => $m['type'] === 'order_out' && $m['reference_id'] === $order['id']); $this->assertCount(1, $outsAfter); } public function test_soft_delete_admin_only(): void { $order = $this->seedOrder(); app(OrderService::class)->softDelete($order['id'], $this->actor('admin')); $stored = InsForgeFake::$store['orders'][$order['id']]; $this->assertNotNull($stored['deleted_at']); $this->expectException(AuthorizationException::class); app(OrderService::class)->softDelete($order['id'], $this->actor('operator')); } public function test_orders_page_renders_for_operator(): void { $user = $this->actor('operator'); $this->actingAs($user); $this->seedOrder(); Livewire::test(OrdersPage::class) ->assertSuccessful() ->assertSee('ORD-20260825-0001') ->assertSee('Andi'); } public function test_delivery_note_streams_pdf_when_delivering(): void { $this->actingAs($this->actor('admin')); $this->seedOrder(['order_status' => OrderService::STATUS_DELIVERING]); $response = $this->get('/orders/ffff6666-0000-0000-0000-000000000001/delivery-note'); $response->assertOk(); $this->assertStringStartsWith('application/pdf', (string) $response->headers->get('Content-Type')); } public function test_delivery_note_forbidden_while_pending(): void { $this->actingAs($this->actor('admin')); $this->seedOrder(['order_status' => OrderService::STATUS_PENDING]); $this->get('/orders/ffff6666-0000-0000-0000-000000000001/delivery-note')->assertForbidden(); } }