397 lines
14 KiB
PHP
397 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\User;
|
|
use App\Services\InsForge\InsForgeAuthService;
|
|
use App\Services\InsForge\InsForgeClient;
|
|
use App\Services\InsForge\InsForgeException;
|
|
use App\Services\InsForge\InsForgeTableService;
|
|
use Illuminate\Auth\Access\AuthorizationException;
|
|
use Illuminate\Support\Facades\Session;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class OrderService extends InsForgeTableService
|
|
{
|
|
public const STATUS_PENDING = 'pending';
|
|
|
|
public const STATUS_PROCESSING = 'processing';
|
|
|
|
public const STATUS_DELIVERING = 'delivering';
|
|
|
|
public const STATUS_COMPLETED = 'completed';
|
|
|
|
public const STATUS_CANCELLED = 'cancelled';
|
|
|
|
/** @var array<string, list<string>> */
|
|
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<array{product_id: string, quantity: int|string, unit_price: int|string|float}> $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);
|
|
}
|
|
}
|