From 029a4a21f84308eeb65d35ae6c2718305b4f57a0 Mon Sep 17 00:00:00 2001 From: sean Date: Tue, 25 Aug 2026 19:10:11 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20phase=204=20=E2=80=94=20orders=20with?= =?UTF-8?q?=20status=20workflow,=20idempotent=20complete=5Forder=20RPC,=20?= =?UTF-8?q?delivery=20note=20PDF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Filament/Pages/Transactions/Orders.php | 298 ++++++++++++ .../Controllers/DeliveryNoteController.php | 24 + app/Models/User.php | 1 + app/Services/CustomerService.php | 12 + app/Services/DriverService.php | 13 + app/Services/OrderService.php | 396 +++++++++++++++ app/Services/ProductService.php | 31 ++ composer.json | 1 + composer.lock | 457 +++++++++++++++++- ...260825100000_create_complete_order_rpc.sql | 78 +++ resources/views/pdf/delivery-note.blade.php | 83 ++++ routes/web.php | 7 +- tests/Feature/ExampleTest.php | 2 +- tests/Feature/Orders/OrderTest.php | 268 ++++++++++ tests/Support/InsForgeFake.php | 82 +++- 15 files changed, 1749 insertions(+), 4 deletions(-) create mode 100644 app/Filament/Pages/Transactions/Orders.php create mode 100644 app/Http/Controllers/DeliveryNoteController.php create mode 100644 app/Services/OrderService.php create mode 100644 migrations/20260825100000_create_complete_order_rpc.sql create mode 100644 resources/views/pdf/delivery-note.blade.php create mode 100644 tests/Feature/Orders/OrderTest.php diff --git a/app/Filament/Pages/Transactions/Orders.php b/app/Filament/Pages/Transactions/Orders.php new file mode 100644 index 0000000..77c7f47 --- /dev/null +++ b/app/Filament/Pages/Transactions/Orders.php @@ -0,0 +1,298 @@ +records(fn (?string $search, ?string $sortColumn, ?string $sortDirection, int|string $page, int|string $recordsPerPage, ?array $filters) => $service->paginate( + search: $search, + sortColumn: $sortColumn, + sortDirection: $sortDirection, + page: (int) $page, + perPage: (int) $recordsPerPage, + filters: ['deleted_at' => null, ...$this->statusFilter($filters)], + )) + ->columns([ + TextColumn::make('order_number')->label('No. Order')->badge()->weight(FontWeight::SemiBold)->sortable(), + TextColumn::make('customers.name')->label('Pelanggan'), + TextColumn::make('order_date')->label('Tgl Order')->date('d M Y')->sortable(), + TextColumn::make('drivers.name')->label('Pengemudi')->placeholder('—'), + TextColumn::make('total') + ->label('Total') + ->state(fn (array $record): int => collect($record['order_items'] ?? [])->sum(fn ($item) => (int) ($item['subtotal'] ?? 0))) + ->numeric()->prefix('Rp '), + TextColumn::make('payment_status') + ->label('Bayar') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'paid' => 'success', + 'partial' => 'warning', + default => 'gray', + }), + TextColumn::make('order_status') + ->label('Status') + ->badge() + ->color(fn (string $state): string => match ($state) { + OrderService::STATUS_PENDING => 'gray', + OrderService::STATUS_PROCESSING => 'info', + OrderService::STATUS_DELIVERING => 'warning', + OrderService::STATUS_COMPLETED => 'success', + OrderService::STATUS_CANCELLED => 'danger', + default => 'gray', + }) + ->formatStateUsing(fn (string $state): string => match ($state) { + OrderService::STATUS_PENDING => 'Pending', + OrderService::STATUS_PROCESSING => 'Diproses', + OrderService::STATUS_DELIVERING => 'Dikirim', + OrderService::STATUS_COMPLETED => 'Selesai', + OrderService::STATUS_CANCELLED => 'Batal', + default => $state, + }) + ->sortable(), + ]) + ->filters([ + Filter::make('order_status') + ->schema([ + Select::make('value')->label('Status')->options([ + OrderService::STATUS_PENDING => 'Pending', + OrderService::STATUS_PROCESSING => 'Diproses', + OrderService::STATUS_DELIVERING => 'Dikirim', + OrderService::STATUS_COMPLETED => 'Selesai', + OrderService::STATUS_CANCELLED => 'Batal', + ]), + ]), + ]) + ->recordActions([ + Action::make('detail') + ->label('Detail') + ->icon('heroicon-o-eye') + ->modalDescription(fn (array $record): string => $this->detailSummary($record)) + ->modalSubmitAction(false), + + Action::make('proses') + ->label('Proses') + ->color('info') + ->visible(fn (array $record): bool => ($record['order_status'] ?? '') === OrderService::STATUS_PENDING) + ->requiresConfirmation() + ->action(function (array $record) use ($service): void { + $this->runTransition($service, $record['id'], OrderService::STATUS_PROCESSING); + }), + + Action::make('kirim') + ->label('Kirim') + ->color('warning') + ->visible(fn (array $record): bool => ($record['order_status'] ?? '') === OrderService::STATUS_PROCESSING) + ->schema(fn (Schema $schema): Schema => $schema->components([ + Select::make('driver_id')->label('Pengemudi')->options(fn (): array => app(DriverService::class)->optionsForSelect())->required(), + ])) + ->action(function (array $data, array $record) use ($service): void { + $service->updateDetails($record['id'], ['driver_id' => $data['driver_id']], auth()->user()); + $this->runTransition($service, $record['id'], OrderService::STATUS_DELIVERING); + }), + + Action::make('selesai') + ->label('Selesaikan') + ->color('success') + ->icon('heroicon-o-check-circle') + ->visible(fn (array $record): bool => ($record['order_status'] ?? '') === OrderService::STATUS_DELIVERING) + ->requiresConfirmation() + ->modalDescription('Stok akan dikurangi otomatis dan tidak dapat diulang. Lanjutkan?') + ->action(function (array $record) use ($service): void { + $this->runTransition($service, $record['id'], OrderService::STATUS_COMPLETED); + }), + + Action::make('batalkan') + ->label('Batalkan') + ->color('danger') + ->visible(fn (array $record): bool => $service->canCancel($record, auth()->user())) + ->schema(fn (Schema $schema): Schema => $schema->components([ + Textarea::make('reason')->label('Alasan Pembatalan')->required(), + ])) + ->action(function (array $data, array $record) use ($service): void { + $this->runTransition($service, $record['id'], OrderService::STATUS_CANCELLED, $data['reason']); + }), + + Action::make('surat_jalan') + ->label('Surat Jalan') + ->icon('heroicon-o-printer') + ->url(fn (array $record): string => '/orders/'.$record['id'].'/delivery-note') + ->openUrlInNewTab() + ->visible(fn (array $record): bool => in_array($record['order_status'], [OrderService::STATUS_DELIVERING, OrderService::STATUS_COMPLETED], true)), + + Action::make('hapus') + ->label('Hapus') + ->color('danger') + ->icon('heroicon-o-trash') + ->visible(fn (array $record): bool => auth()->user()?->isAdmin() ?? false) + ->requiresConfirmation() + ->modalDescription('Order akan dihapus (soft delete). Lanjutkan?') + ->action(function (array $record) use ($service): void { + $service->softDelete($record['id'], auth()->user()); + $this->notifySuccess('Order dihapus.'); + }), + ]) + ->headerActions([ + CreateAction::make() + ->label('Buat Pesanan') + ->schema(fn (Schema $schema): Schema => $schema->components(self::orderForm())) + ->using(function (array $data): void { + app(OrderService::class)->createOrder($data, $data['items'] ?? [], auth()->user()); + $this->notifySuccess('Pesanan dibuat.'); + }), + ]) + ->paginated([10, 25, 50]); + } + + /** + * @return list + */ + private static function orderForm(): array + { + return [ + Select::make('customer_id')->label('Pelanggan')->options(fn (): array => app(CustomerService::class)->optionsForSelect())->required()->searchable(), + DatePicker::make('order_date')->label('Tgl Order')->default(now()->toDateString())->required(), + DatePicker::make('delivery_date')->label('Tgl Kirim'), + Select::make('payment_method')->label('Metode Bayar')->options(['cash' => 'Cash', 'transfer' => 'Transfer', 'qris' => 'QRIS'])->default('cash')->required(), + Select::make('payment_status')->label('Status Bayar')->options(['unpaid' => 'Belum Bayar', 'partial' => 'DP', 'paid' => 'Lunas'])->default('unpaid')->required(), + Repeater::make('items') + ->label('Item Pesanan') + ->schema([ + Select::make('product_id') + ->label('Produk') + ->options(fn (): array => app(ProductService::class)->optionsForSelect()) + ->required() + ->searchable() + ->live() + ->afterStateUpdated(function (?string $state, callable $set): void { + if ($state !== null && $state !== '') { + $product = app(ProductService::class)->find($state); + $set('unit_price', (int) ($product['cost_price'] ?? 0)); + } + }), + TextInput::make('quantity')->label('Qty')->numeric()->minValue(1)->required(), + TextInput::make('unit_price')->label('Harga/Satuan')->numeric()->minValue(0)->required(), + ]) + ->columns(3) + ->defaultItems(1) + ->minItems(1) + ->required() + ->columnSpanFull(), + Textarea::make('notes')->label('Catatan')->columnSpanFull(), + ]; + } + + private function runTransition(OrderService $service, string $orderId, string $to, ?string $notes = null): void + { + try { + $service->transition($orderId, $to, $notes, auth()->user()); + } catch (ValidationException $exception) { + Notification::make()->title('Gagal')->body(collect($exception->errors())->flatten()->implode(' '))->danger()->send(); + + return; + } catch (AuthorizationException) { + Notification::make()->title('Akses ditolak')->body('Hanya admin atau operator pembuat yang dapat membatalkan order ini.')->danger()->send(); + + return; + } + + $this->notifySuccess('Status order diperbarui.'); + } + + private function detailSummary(array $record): string + { + $lines = ["No: {$record['order_number']}"]; + + foreach ($record['order_items'] ?? [] as $item) { + $lines[] = sprintf('- %s x%d @ Rp%s = Rp%s', + data_get($item, 'products.name', '?'), + $item['quantity'], + number_format((int) $item['unit_price'], 0, ',', '.'), + number_format((int) $item['subtotal'], 0, ',', '.'), + ); + } + + $total = collect($record['order_items'] ?? [])->sum(fn ($item) => (int) ($item['subtotal'] ?? 0)); + + if (($record['survey_status'] ?? null) !== null) { + $lines[] = 'Survei: '.$record['survey_status']; + } + + $lines[] = 'Total: Rp '.number_format($total, 0, ',', '.'); + + return implode("\n", $lines); + } + + /** @return array */ + private function statusFilter(?array $filters): array + { + $status = $filters['order_status']['value'] ?? $filters['order_status'] ?? null; + + return is_array($status) ? [] : (filled($status) ? ['order_status' => $status] : []); + } + + private function notifySuccess(string $message): void + { + Notification::make()->title('Berhasil')->body($message)->success()->send(); + } + + public function content(Schema $schema): Schema + { + return $schema + ->components([ + EmbeddedTable::make(), + ]); + } +} diff --git a/app/Http/Controllers/DeliveryNoteController.php b/app/Http/Controllers/DeliveryNoteController.php new file mode 100644 index 0000000..c8028bf --- /dev/null +++ b/app/Http/Controllers/DeliveryNoteController.php @@ -0,0 +1,24 @@ +deliveryNoteData($orderId); + + $total = collect($order['order_items'] ?? [])->sum(fn ($item) => (int) ($item['subtotal'] ?? 0)); + + $pdf = Pdf::loadView('pdf.delivery-note', [ + 'order' => $order, + 'total' => $total, + ])->setPaper('a5', 'landscape'); + + return $pdf->stream(str_replace('/', '-', $order['note_number']).'.pdf'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 7eadaf1..e8147b2 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -28,6 +28,7 @@ class User extends Authenticatable implements FilamentUser protected function casts(): array { return [ + 'id' => 'string', 'is_active' => 'boolean', ]; } diff --git a/app/Services/CustomerService.php b/app/Services/CustomerService.php index 3759f8e..254fd94 100644 --- a/app/Services/CustomerService.php +++ b/app/Services/CustomerService.php @@ -33,4 +33,16 @@ class CustomerService extends InsForgeTableService return sprintf('CUST-%04d', ((int) ($matches[1] ?? 0)) + 1); } + + /** @return array id => "CUST-0001 — Nama" */ + public function optionsForSelect(): array + { + return collect($this->client->listRecords($this->table(), [ + 'select' => 'id,customer_code,name', + 'order' => 'customer_code.asc', + 'limit' => 500, + ])) + ->mapWithKeys(fn (array $row): array => [$row['id'] => trim(($row['customer_code'] ?? '').' — '.($row['name'] ?? ''), ' —')]) + ->all(); + } } diff --git a/app/Services/DriverService.php b/app/Services/DriverService.php index ad48da8..6559ab6 100644 --- a/app/Services/DriverService.php +++ b/app/Services/DriverService.php @@ -33,4 +33,17 @@ class DriverService extends InsForgeTableService return sprintf('DRV-%04d', ((int) ($matches[1] ?? 0)) + 1); } + + /** @return array id => "DRV-0001 — Nama" (hanya aktif) */ + public function optionsForSelect(): array + { + return collect($this->client->listRecords($this->table(), [ + 'select' => 'id,driver_code,name', + 'is_active' => 'eq.true', + 'order' => 'driver_code.asc', + 'limit' => 200, + ])) + ->mapWithKeys(fn (array $row): array => [$row['id'] => trim(($row['driver_code'] ?? '').' — '.($row['name'] ?? ''), ' —')]) + ->all(); + } } diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php new file mode 100644 index 0000000..ea83172 --- /dev/null +++ b/app/Services/OrderService.php @@ -0,0 +1,396 @@ +> */ + protected const TRANSITIONS = [ + self::STATUS_PENDING => [self::STATUS_PROCESSING, self::STATUS_CANCELLED], + self::STATUS_PROCESSING => [self::STATUS_DELIVERING, self::STATUS_CANCELLED], + self::STATUS_DELIVERING => [self::STATUS_COMPLETED], + self::STATUS_COMPLETED => [], + self::STATUS_CANCELLED => [], + ]; + + public function __construct( + InsForgeClient $client, + private readonly ProductService $products, + private readonly ActivityLogger $activity, + ) { + parent::__construct($client); + } + + protected function table(): string + { + return 'orders'; + } + + protected function select(): string + { + return '*,customers(code,name),drivers(code,name),order_items(quantity,unit_price,subtotal)'; + } + + protected function searchableColumns(): array + { + return ['order_number']; + } + + protected function sortableColumns(): array + { + return ['order_number', 'order_date', 'order_status', 'created_at']; + } + + public function findWithRelations(string $id): ?array + { + [$client, $asUser] = $this->scoped(); + + return collect($client->listRecords($this->table(), [ + 'select' => '*,customers(*),drivers(*),order_items(quantity,unit_price,subtotal,cost_price,products(code,name))', + 'id' => 'eq.'.$id, + 'limit' => 1, + ], asUser: $asUser))[0] ?? null; + } + + /** + * @param list $items + */ + public function createOrder(array $attributes, array $items, ?User $actor = null): array + { + $items = $this->normalizeItems($items); + + if ($items === []) { + throw ValidationException::withMessages(['items' => 'Order minimal memiliki satu item.']); + } + + $this->assertStockAvailable($items); + + [$client, $asUser] = $this->scoped(); + + $number = $this->nextOrderNumber(); + + $order = array_filter([ + 'order_number' => $number, + 'order_date' => $attributes['order_date'] ?? now()->toDateString(), + 'delivery_date' => $attributes['delivery_date'] ?? null, + 'customer_id' => $attributes['customer_id'], + 'driver_id' => $attributes['driver_id'] ?? null, + 'payment_method' => $attributes['payment_method'] ?? 'cash', + 'payment_status' => $attributes['payment_status'] ?? 'unpaid', + 'notes' => $attributes['notes'] ?? null, + 'order_status' => self::STATUS_PENDING, + ], fn ($value) => $value !== null && $value !== ''); + + $created = $client->insertRecord($this->table(), [$order], asUser: $asUser); + $orderId = is_array($created) ? data_get($created, '0.id') : null; + + $stored = $client->listRecords($this->table(), [ + 'select' => 'id', + 'order_number' => 'eq.'.$number, + 'order' => 'created_at.desc', + 'limit' => 1, + ], asUser: $asUser)[0] ?? null; + + $orderId = $orderId ?? ($stored['id'] ?? null); + + if ($orderId === null) { + throw new \RuntimeException('Gagal mengambil id order yang baru dibuat.'); + } + + $client->insertRecord('order_items', $this->itemPayloads($orderId, $items), asUser: false); + + $client->insertRecord('order_status_histories', [[ + 'order_id' => $orderId, + 'old_status' => null, + 'new_status' => self::STATUS_PENDING, + 'changed_by' => $actor?->id, + ]], asUser: false); + + $this->activity->log('order_created', 'orders', $orderId, "Order {$number} dibuat.", $actor); + + return ['id' => $orderId, 'order_number' => $number]; + } + + public function replaceItems(string $orderId, array $items, ?User $actor = null): void + { + [$client, $asUser] = $this->scoped(); + + $order = $this->findWithRelations($orderId) ?? throw ValidationException::withMessages(['order' => 'Order tidak ditemukan.']); + + if (($order['order_status'] ?? '') !== self::STATUS_PENDING) { + throw ValidationException::withMessages(['order' => 'Item hanya dapat diubah saat status masih pending.']); + } + + $items = $this->normalizeItems($items); + $this->assertStockAvailable($items); + + $client->deleteRecords('order_items', ['order_id' => $orderId], asUser: false); + $client->insertRecord('order_items', $this->itemPayloads($orderId, $items), asUser: false); + + $this->activity->log('order_items_updated', 'orders', $orderId, "Item order {$order['order_number']} diperbarui.", $actor); + } + + public function updateDetails(string $orderId, array $attributes, ?User $actor = null): void + { + [$client, $asUser] = $this->scoped(); + + $order = $this->findWithRelations($orderId) ?? throw ValidationException::withMessages(['order' => 'Order tidak ditemukan.']); + + if (in_array($order['order_status'], [self::STATUS_COMPLETED, self::STATUS_CANCELLED], true)) { + throw ValidationException::withMessages(['order' => 'Order yang sudah selesai atau dibatalkan tidak dapat diubah.']); + } + + $patch = array_filter([ + 'customer_id' => $attributes['customer_id'] ?? null, + 'driver_id' => $attributes['driver_id'] ?? null, + 'order_date' => $attributes['order_date'] ?? null, + 'delivery_date' => $attributes['delivery_date'] ?? null, + 'payment_method' => $attributes['payment_method'] ?? null, + 'payment_status' => $attributes['payment_status'] ?? null, + 'survey_status' => $attributes['survey_status'] ?? null, + 'notes' => $attributes['notes'] ?? null, + ], fn ($value) => $value !== null); + + if ($patch !== []) { + $client->updateRecords($this->table(), ['id' => $orderId], $patch, asUser: false); + } + } + + public function transition(string $orderId, string $to, ?string $notes = null, ?User $actor = null): void + { + [$client, $asUser] = $this->scoped(); + + $order = $client->getRecord($this->table(), $orderId, asUser: $asUser) + ?? throw ValidationException::withMessages(['order' => 'Order tidak ditemukan.']); + + $from = $order['order_status'] ?? ''; + + if (! in_array($to, self::TRANSITIONS[$from] ?? [], true)) { + throw ValidationException::withMessages([ + 'order' => "Transisi status {$from} ke {$to} tidak valid.", + ]); + } + + if ($to === self::STATUS_CANCELLED) { + $isOwner = $actor !== null && $actor->id === ($order['created_by'] ?? null); + + if (! ($actor?->isAdmin() ?? false) && ! $isOwner) { + throw new AuthorizationException('Hanya admin atau operator pembuat yang dapat membatalkan order.'); + } + } + + if ($to === self::STATUS_COMPLETED) { + $token = Session::get(InsForgeAuthService::SESSION_ACCESS_TOKEN); + $rpcClient = $token ? $this->client->withToken($token) : $this->client; + + try { + $rpcClient->rpc('complete_order', [ + 'p_order_id' => $orderId, + 'p_actor' => $actor?->id, + ], asUser: filled($token)); + } catch (InsForgeException $exception) { + $message = $exception->getMessage(); + + if (str_starts_with($message, 'INSUFFICIENT_STOCK:')) { + throw ValidationException::withMessages(['order' => 'Stok tidak mencukupi untuk menyelesaikan order ini.']); + } + + if ($message === 'INVALID_TRANSITION') { + throw ValidationException::withMessages(['order' => 'Transisi status tidak valid.']); + } + + throw $exception; + } + + return; + } + + $client->updateRecords($this->table(), ['id' => $orderId], ['order_status' => $to], asUser: false); + + $history = array_filter([ + 'order_id' => $orderId, + 'old_status' => $from, + 'new_status' => $to, + 'changed_by' => $actor?->id, + 'notes' => $notes, + ], fn ($value) => $value !== null && $value !== ''); + + $client->insertRecord('order_status_histories', [$history], asUser: false); + + $this->activity->log("order_{$to}", 'orders', $orderId, "Order {$order['order_number']} berstatus {$to}.", $actor); + } + + public function softDelete(string $orderId, ?User $actor = null): void + { + if (! ($actor?->isAdmin() ?? false)) { + throw new AuthorizationException('Hanya admin yang dapat menghapus order.'); + } + + [$client, $asUser] = $this->scoped(); + + $order = $client->getRecord($this->table(), $orderId, asUser: $asUser) + ?? throw ValidationException::withMessages(['order' => 'Order tidak ditemukan.']); + + if (($order['deleted_at'] ?? null) !== null) { + return; + } + + $client->updateRecords($this->table(), ['id' => $orderId], [ + 'deleted_at' => now()->toISOString(), + ], asUser: false); + + $this->activity->log('order_deleted', 'orders', $orderId, "Order {$order['order_number']} dihapus.", $actor); + } + + public function canCancel(array $record, ?User $actor): bool + { + $status = $record['order_status'] ?? ''; + + if (! in_array($status, [self::STATUS_PENDING, self::STATUS_PROCESSING], true)) { + return false; + } + + return $actor !== null && ($actor->isAdmin() || $actor->id === ($record['created_by'] ?? null)); + } + + public function deliveryNoteData(string $orderId): array + { + [$client, $asUser] = $this->scoped(); + + $order = collect($client->listRecords($this->table(), [ + 'select' => '*,customers(*),drivers(*),order_items(quantity,unit_price,subtotal,products(code,name,unit))', + 'id' => 'eq.'.$orderId, + 'limit' => 1, + ], asUser: $asUser))[0] ?? null; + + if ($order === null || ($order['deleted_at'] ?? null) !== null) { + abort(404); + } + + if (! in_array($order['order_status'], [self::STATUS_DELIVERING, self::STATUS_COMPLETED], true)) { + abort(403, 'Surat jalan tersedia saat status delivering atau completed.'); + } + + $order['note_number'] = $this->deliveryNoteNumber((string) $order['order_number']); + + return $order; + } + + public function deliveryNoteNumber(string $orderNumber): string + { + if (preg_match('/^ORD-(\d{4})(\d{2})(\d{2})-/', $orderNumber, $m) === 1) { + return sprintf('SJL-%s/%s/%s-%s', $m[1], $m[2], $m[3], $orderNumber); + } + + return 'SJL-'.now()->format('Y/m/d').'-'.$orderNumber; + } + + private function nextOrderNumber(): string + { + [$client, $asUser] = $this->scoped(); + + $prefix = 'ORD-'.now()->format('Ymd').'-'; + + $last = $client->listRecords($this->table(), [ + 'select' => 'order_number', + 'order_number' => 'like.'.$prefix.'*', + 'order' => 'order_number.desc', + 'limit' => 1, + ], asUser: $asUser)[0]['order_number'] ?? null; + + preg_match('/(\d+)$/', (string) $last, $matches); + + return $prefix.sprintf('%04d', ((int) ($matches[1] ?? 0)) + 1); + } + + private function normalizeItems(array $items): array + { + $normalized = []; + + foreach ($items as $item) { + $productId = (string) ($item['product_id'] ?? ''); + $quantity = max(1, (int) ($item['quantity'] ?? 0)); + $unitPrice = (int) round((float) ($item['unit_price'] ?? 0)); + + if ($productId === '' || $unitPrice < 0) { + continue; + } + + $normalized[$productId] = [ + 'product_id' => $productId, + 'quantity' => ($normalized[$productId]['quantity'] ?? 0) + $quantity, + 'unit_price' => $unitPrice, + ]; + } + + return array_values($normalized); + } + + private function assertStockAvailable(array $items): void + { + [$client, $asUser] = $this->scoped(); + + $ids = implode(',', array_map(fn (string $id): string => '"'.$id.'"', array_column($items, 'product_id'))); + + $stocks = $client->listRecords('stocks', [ + 'select' => 'product_id,quantity', + 'product_id' => 'in.('.$ids.')', + ], asUser: $asUser); + + $balances = []; + foreach ($stocks as $stock) { + $balances[$stock['product_id']] = ($balances[$stock['product_id']] ?? 0) + (int) $stock['quantity']; + } + + $codes = $this->products->codeMap(); + + $shortages = []; + + foreach ($items as $item) { + $available = $balances[$item['product_id']] ?? 0; + + if ($item['quantity'] > $available) { + $shortages[] = sprintf('%s (sisa %d)', $codes[$item['product_id']] ?? $item['product_id'], $available); + } + } + + if ($shortages !== []) { + throw ValidationException::withMessages([ + 'items' => 'Stok tidak cukup untuk: '.implode(', ', $shortages), + ]); + } + } + + private function itemPayloads(string $orderId, array $items): array + { + $costs = $this->products->costMap(); + + return array_map(fn (array $item): array => [ + 'order_id' => $orderId, + 'product_id' => $item['product_id'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'subtotal' => $item['quantity'] * $item['unit_price'], + 'cost_price' => $costs[$item['product_id']] ?? 0, + ], $items); + } +} diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php index e971da0..0e82b82 100644 --- a/app/Services/ProductService.php +++ b/app/Services/ProductService.php @@ -33,4 +33,35 @@ class ProductService extends InsForgeTableService return sprintf('BERAS-%03d', ((int) ($matches[1] ?? 0)) + 1); } + + /** @return array id => "CODE — Nama" */ + public function optionsForSelect(): array + { + return collect($this->client->listRecords($this->table(), [ + 'select' => 'id,code,name', + 'is_active' => 'eq.true', + 'order' => 'code.asc', + 'limit' => 200, + ])) + ->mapWithKeys(fn (array $row): array => [$row['id'] => trim(($row['code'] ?? '').' — '.($row['name'] ?? ''), ' —')]) + ->all(); + } + + /** @return array */ + public function codeMap(): array + { + return collect($this->client->listRecords($this->table(), [ + 'select' => 'id,code', + 'limit' => 200, + ]))->mapWithKeys(fn (array $row): array => [$row['id'] => $row['code']])->all(); + } + + /** @return array */ + public function costMap(): array + { + return collect($this->client->listRecords($this->table(), [ + 'select' => 'id,cost_price', + 'limit' => 200, + ]))->mapWithKeys(fn (array $row): array => [$row['id'] => (int) ($row['cost_price'] ?? 0)])->all(); + } } diff --git a/composer.json b/composer.json index 895381c..0e16296 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,7 @@ "license": "MIT", "require": { "php": "^8.2", + "barryvdh/laravel-dompdf": "^3.1", "filament/filament": "^5.0", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1" diff --git a/composer.lock b/composer.lock index 236fbfe..a1b2701 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "34aac26196943eefd8b2a4ea2a0b8ccd", + "content-hash": "3d051140d051bdd0d7c1f64bedf97964", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -71,6 +71,83 @@ }, "time": "2026-08-07T06:14:19+00:00" }, + { + "name": "barryvdh/laravel-dompdf", + "version": "v3.1.2", + "source": { + "type": "git", + "url": "https://github.com/barryvdh/laravel-dompdf.git", + "reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/ee3b72b19ccdf57d0243116ecb2b90261344dedc", + "reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc", + "shasum": "" + }, + "require": { + "dompdf/dompdf": "^3.0", + "illuminate/support": "^9|^10|^11|^12|^13.0", + "php": "^8.1" + }, + "require-dev": { + "larastan/larastan": "^2.7|^3.0", + "orchestra/testbench": "^7|^8|^9.16|^10|^11.0", + "phpro/grumphp": "^2.5", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "PDF": "Barryvdh\\DomPDF\\Facade\\Pdf", + "Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf" + }, + "providers": [ + "Barryvdh\\DomPDF\\ServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Barryvdh\\DomPDF\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Barry vd. Heuvel", + "email": "barryvdh@gmail.com" + } + ], + "description": "A DOMPDF Wrapper for Laravel", + "keywords": [ + "dompdf", + "laravel", + "pdf" + ], + "support": { + "issues": "https://github.com/barryvdh/laravel-dompdf/issues", + "source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.2" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2026-02-21T08:51:10+00:00" + }, { "name": "blade-ui-kit/blade-heroicons", "version": "2.7.0", @@ -859,6 +936,161 @@ ], "time": "2024-02-05T11:56:58+00:00" }, + { + "name": "dompdf/dompdf", + "version": "v3.1.6", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/6d4b4eb8500f7a786da8868ba463a71b725a4005", + "reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.6" + }, + "time": "2026-07-20T12:29:38+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" + }, + "time": "2026-01-20T14:10:26+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4 || ^9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" + }, + "time": "2026-01-02T16:01:13+00:00" + }, { "name": "dragonmantank/cron-expression", "version": "v3.6.0", @@ -5095,6 +5327,86 @@ ], "time": "2026-03-19T10:36:26+00:00" }, + { + "name": "sabberworm/php-css-parser", + "version": "v9.4.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", + "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/extension-installer": "1.4.3", + "phpstan/phpstan": "1.12.33 || 2.2.2", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.16", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11", + "phpunit/phpunit": "8.5.52", + "rawr/phpunit-data-provider": "3.3.1", + "rector/rector": "1.2.10 || 2.4.6", + "rector/type-perfect": "1.0.0 || 2.1.3", + "squizlabs/php_codesniffer": "4.0.1", + "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.5.x-dev" + } + }, + "autoload": { + "files": [ + "src/Rule/Rule.php", + "src/RuleSet/RuleContainer.php" + ], + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0" + }, + "time": "2026-06-18T15:10:53+00:00" + }, { "name": "scrivo/highlight.php", "version": "v9.18.1.10", @@ -7941,6 +8253,149 @@ ], "time": "2026-08-21T12:09:28+00:00" }, + { + "name": "thecodingmachine/safe", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10", + "squizlabs/php_codesniffer": "^3.2" + }, + "type": "library", + "autoload": { + "files": [ + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gettext.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rnp.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "generated/Exceptions/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" + }, + "funding": [ + { + "url": "https://github.com/OskarStark", + "type": "github" + }, + { + "url": "https://github.com/shish", + "type": "github" + }, + { + "url": "https://github.com/silasjoisten", + "type": "github" + }, + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2026-02-04T18:08:13+00:00" + }, { "name": "tijsverkoyen/css-to-inline-styles", "version": "v2.4.0", diff --git a/migrations/20260825100000_create_complete_order_rpc.sql b/migrations/20260825100000_create_complete_order_rpc.sql new file mode 100644 index 0000000..c06009d --- /dev/null +++ b/migrations/20260825100000_create_complete_order_rpc.sql @@ -0,0 +1,78 @@ +-- complete_order: transisi delivering -> completed secara atomik & idempotent. +-- Idempotensi: partial unique index stock_movements_order_out_unique_idx +-- ON (reference_type, reference_id) WHERE type='order_out' AND reference_type='order' +-- membuat insert mutasi ganda otomatis dilewati (ON CONFLICT DO NOTHING). +CREATE OR REPLACE FUNCTION public.complete_order(p_order_id uuid, p_actor uuid DEFAULT NULL) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $fn$ +DECLARE + v_order public.orders%ROWTYPE; + v_item record; + v_new_balance integer; +BEGIN + SELECT * INTO v_order FROM public.orders WHERE id = p_order_id FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'ORDER_NOT_FOUND'; + END IF; + + -- Sudah selesai: anggap sukses (idempotent), tanpa efek stok. + IF v_order.order_status = 'completed' THEN + RETURN; + END IF; + + IF v_order.deleted_at IS NOT NULL THEN + RAISE EXCEPTION 'ORDER_DELETED'; + END IF; + + IF v_order.order_status <> 'delivering' THEN + RAISE EXCEPTION 'INVALID_TRANSITION:%_TO_COMPLETED', v_order.order_status; + END IF; + + FOR v_item IN + SELECT oi.product_id, oi.quantity + FROM public.order_items oi + WHERE oi.order_id = p_order_id + ORDER BY oi.product_id + FOR UPDATE OF oi + LOOP + SELECT s.quantity + INTO v_new_balance + FROM public.stocks s + WHERE s.product_id = v_item.product_id + FOR UPDATE; + + v_new_balance := COALESCE(v_new_balance, 0) - v_item.quantity; + + IF v_new_balance < 0 THEN + RAISE EXCEPTION 'INSUFFICIENT_STOCK:%', v_item.product_id; + END IF; + + INSERT INTO public.stock_movements + (product_id, type, quantity, reference_type, reference_id, notes, created_by) + VALUES + (v_item.product_id, 'order_out', -v_item.quantity, 'order', p_order_id, NULL, p_actor) + ON CONFLICT DO NOTHING; + + UPDATE public.stocks s + SET quantity = v_new_balance, + updated_at = now() + WHERE s.product_id = v_item.product_id; + + END LOOP; + + UPDATE public.orders + SET order_status = 'completed', + completed_at = now(), + updated_at = now() + WHERE id = p_order_id; + + INSERT INTO public.order_status_histories + (order_id, old_status, new_status, changed_by) + VALUES + (p_order_id, v_order.order_status, 'completed', COALESCE(p_actor, v_order.created_by)); +END; +$fn$; diff --git a/resources/views/pdf/delivery-note.blade.php b/resources/views/pdf/delivery-note.blade.php new file mode 100644 index 0000000..2fac7de --- /dev/null +++ b/resources/views/pdf/delivery-note.blade.php @@ -0,0 +1,83 @@ + + + + + {{ $order['note_number'] }} + + + + + + + + +
+

SURAT JALAN

+
Sembara — Distribusi Beras & Sembako
+
+ No. {{ $order['note_number'] }}
+ Tgl Order: {{ \Carbon\Carbon::parse($order['order_date'])->translatedFormat('d M Y') }}
+ @if (!empty($order['delivery_date'])) + Tgl Kirim: {{ \Carbon\Carbon::parse($order['delivery_date'])->translatedFormat('d M Y') }} + @endif +
+ + + + + + + + +
Pelanggan{{ $order['customers']['name'] ?? '-' }}{{ isset($order['customers']['address']) ? ' — ' . $order['customers']['address'] : '' }}Pengemudi{{ $order['drivers']['name'] ?? '-' }}
+ + + + + + + + + + + + + @foreach ($order['order_items'] ?? [] as $i => $item) + + + + + + + + @endforeach + + + + + +
NoProdukQtyHargaSubtotal
{{ $i + 1 }}{{ data_get($item, 'products.name', '-') }}{{ $item['quantity'] }}Rp {{ number_format((int) $item['unit_price'], 0, ',', '.') }}Rp {{ number_format((int) $item['subtotal'], 0, ',', '.') }}
TotalRp {{ number_format($total, 0, ',', '.') }}
+ + @if (!empty($order['notes'])) +

Catatan: {{ $order['notes'] }}

+ @endif + + + + + + +
Penerima
Pengantar
+ + diff --git a/routes/web.php b/routes/web.php index 86a06c5..e4a79e8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,12 @@ middleware(['web', 'auth']) + ->name('orders.delivery-note'); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 8364a84..afef2a3 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -14,6 +14,6 @@ class ExampleTest extends TestCase { $response = $this->get('/'); - $response->assertStatus(200); + $response->assertRedirect('/admin'); } } diff --git a/tests/Feature/Orders/OrderTest.php b/tests/Feature/Orders/OrderTest.php new file mode 100644 index 0000000..d8b9bc4 --- /dev/null +++ b/tests/Feature/Orders/OrderTest.php @@ -0,0 +1,268 @@ + 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(); + } +} diff --git a/tests/Support/InsForgeFake.php b/tests/Support/InsForgeFake.php index 4accd0c..cc0f0f2 100644 --- a/tests/Support/InsForgeFake.php +++ b/tests/Support/InsForgeFake.php @@ -10,11 +10,15 @@ class InsForgeFake /** @var array>> table => id => row */ public static array $store = []; + /** @var list}> */ + public static array $rpcCalls = []; + public static string $accessToken = ''; public static function reset(): void { self::$store = []; + self::$rpcCalls = []; } public static function seed(string $table, array $row): void @@ -67,6 +71,18 @@ class InsForgeFake { Http::fake([ '*/api/auth/refresh' => Http::response(['accessToken' => self::$accessToken], 200), + '*/api/database/rpc/*' => function (Request $request) { + $function = str_replace('/api/database/rpc/', '', (string) parse_url($request->url(), PHP_URL_PATH)); + $args = $request->data(); + + self::$rpcCalls[] = ['function' => $function, 'args' => $args]; + + if ($function === 'complete_order') { + return self::simulateCompleteOrder($args); + } + + return Http::response(['ok' => true], 200); + }, '*/api/database/records/*' => fn (Request $request) => self::handleRecordsRequest($request), ]); } @@ -89,7 +105,12 @@ class InsForgeFake if (str_starts_with((string) $value, 'eq.')) { $column = $key; $expected = substr((string) $value, 3); - $rows = array_values(array_filter($rows, fn (array $row): bool => (string) ($row[$column] ?? '') === $expected)); + + if ($expected === 'null') { + $rows = array_values(array_filter($rows, fn (array $row): bool => ($row[$column] ?? null) === null)); + } else { + $rows = array_values(array_filter($rows, fn (array $row): bool => (string) ($row[$column] ?? '') === $expected)); + } } } @@ -150,4 +171,63 @@ class InsForgeFake return Http::response([], 200); } + + /** + * Tiruan semantik server: idempotent via gerbang unik (reference_type, reference_id), + * decrement stok sekali, set status completed + history. + */ + private static function simulateCompleteOrder(array $args) + { + $orderId = $args['p_order_id'] ?? null; + + if ($orderId === null || ! isset(self::$store['orders'][$orderId])) { + return Http::response(['error' => 'ORDER_NOT_FOUND'], 500); + } + + $order = self::$store['orders'][$orderId]; + + if (($order['order_status'] ?? '') === 'completed') { + return Http::response(['ok' => true], 200); + } + + if (($order['order_status'] ?? '') !== 'delivering') { + return Http::response(['error' => 'INVALID_TRANSITION'], 500); + } + + foreach (self::$store['order_items'] ?? [] as $item) { + if (($item['order_id'] ?? null) !== $orderId) { + continue; + } + + $alreadyOut = collect(self::$store['stock_movements'] ?? [])->contains( + fn (array $m): bool => $m['type'] === 'order_out' + && ($m['reference_id'] ?? null) === $orderId + && ($m['product_id'] ?? null) === $item['product_id'], + ); + + if ($alreadyOut) { + continue; + } + + self::seed('stock_movements', [ + 'id' => fake()->uuid(), + 'product_id' => $item['product_id'], + 'type' => 'order_out', + 'quantity' => -1 * (int) $item['quantity'], + 'reference_type' => 'order', + 'reference_id' => $orderId, + ]); + + foreach (self::$store['stocks'] as &$stock) { + if (($stock['product_id'] ?? null) === $item['product_id']) { + $stock['quantity'] -= (int) $item['quantity']; + } + } + unset($stock); + } + + self::$store['orders'][$orderId]['order_status'] = 'completed'; + + return Http::response(['ok' => true], 200); + } }