feat: phase 4 — orders with status workflow, idempotent complete_order RPC, delivery note PDF

This commit is contained in:
sean
2026-08-25 19:10:11 +07:00
parent 002fdce854
commit 029a4a21f8
15 changed files with 1749 additions and 4 deletions
+298
View File
@@ -0,0 +1,298 @@
<?php
namespace App\Filament\Pages\Transactions;
use App\Services\CustomerService;
use App\Services\DriverService;
use App\Services\OrderService;
use App\Services\ProductService;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\CreateAction;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\EmbeddedTable;
use Filament\Schemas\Schema;
use Filament\Support\Enums\FontWeight;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Table;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Validation\ValidationException;
use UnitEnum;
class Orders extends Page implements HasTable
{
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-list';
protected static string|UnitEnum|null $navigationGroup = 'Transaksi';
protected static ?int $navigationSort = 0;
protected static ?string $navigationLabel = 'Pesanan';
protected static ?string $slug = 'transactions/orders';
protected string $view = 'filament.pages.blank';
public function getTitle(): string|Htmlable
{
return 'Pesanan';
}
public function table(Table $table): Table
{
/** @var OrderService $service */
$service = app(OrderService::class);
return $table
->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<Component>
*/
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<string, mixed> */
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(),
]);
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Services\OrderService;
use Barryvdh\DomPDF\Facade\Pdf;
use Symfony\Component\HttpFoundation\Response;
class DeliveryNoteController extends Controller
{
public function __invoke(string $orderId, OrderService $service): Response
{
$order = $service->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');
}
}
+1
View File
@@ -28,6 +28,7 @@ class User extends Authenticatable implements FilamentUser
protected function casts(): array
{
return [
'id' => 'string',
'is_active' => 'boolean',
];
}
+12
View File
@@ -33,4 +33,16 @@ class CustomerService extends InsForgeTableService
return sprintf('CUST-%04d', ((int) ($matches[1] ?? 0)) + 1);
}
/** @return array<string, string> 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();
}
}
+13
View File
@@ -33,4 +33,17 @@ class DriverService extends InsForgeTableService
return sprintf('DRV-%04d', ((int) ($matches[1] ?? 0)) + 1);
}
/** @return array<string, string> 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();
}
}
+396
View File
@@ -0,0 +1,396 @@
<?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);
}
}
+31
View File
@@ -33,4 +33,35 @@ class ProductService extends InsForgeTableService
return sprintf('BERAS-%03d', ((int) ($matches[1] ?? 0)) + 1);
}
/** @return array<string, string> 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<string, int> */
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<string, int> */
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();
}
}