> */ 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); } }