feat: phase 4 — orders with status workflow, idempotent complete_order RPC, delivery note PDF
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ class User extends Authenticatable implements FilamentUser
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'id' => 'string',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+456
-1
@@ -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",
|
||||
|
||||
@@ -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$;
|
||||
@@ -0,0 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ $order['note_number'] }}</title>
|
||||
<style>
|
||||
body { font-family: DejaVu Sans, sans-serif; font-size: 11px; color: #111; }
|
||||
h1 { font-size: 16px; margin: 0; }
|
||||
.muted { color: #555; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
|
||||
th, td { border: 1px solid #999; padding: 4px 6px; text-align: left; }
|
||||
th { background: #eee; }
|
||||
.right { text-align: right; }
|
||||
.signatures { width: 100%; margin-top: 28px; }
|
||||
.signatures td { text-align: center; border: none; }
|
||||
.line { display: inline-block; width: 140px; border-bottom: 1px solid #000; height: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<table style="border:none;">
|
||||
<tr>
|
||||
<td style="border:none;">
|
||||
<h1>SURAT JALAN</h1>
|
||||
<div class="muted">Sembara — Distribusi Beras & Sembako</div>
|
||||
</td>
|
||||
<td class="right" style="border:none;">
|
||||
<strong>No. {{ $order['note_number'] }}</strong><br>
|
||||
Tgl Order: {{ \Carbon\Carbon::parse($order['order_date'])->translatedFormat('d M Y') }}<br>
|
||||
@if (!empty($order['delivery_date']))
|
||||
Tgl Kirim: {{ \Carbon\Carbon::parse($order['delivery_date'])->translatedFormat('d M Y') }}
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table style="margin-top:8px;">
|
||||
<tr>
|
||||
<th>Pelanggan</th>
|
||||
<td>{{ $order['customers']['name'] ?? '-' }}{{ isset($order['customers']['address']) ? ' — ' . $order['customers']['address'] : '' }}</td>
|
||||
<th>Pengemudi</th>
|
||||
<td>{{ $order['drivers']['name'] ?? '-' }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:30px;">No</th>
|
||||
<th>Produk</th>
|
||||
<th class="right" style="width:60px;">Qty</th>
|
||||
<th class="right" style="width:90px;">Harga</th>
|
||||
<th class="right" style="width:110px;">Subtotal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($order['order_items'] ?? [] as $i => $item)
|
||||
<tr>
|
||||
<td>{{ $i + 1 }}</td>
|
||||
<td>{{ data_get($item, 'products.name', '-') }}</td>
|
||||
<td class="right">{{ $item['quantity'] }}</td>
|
||||
<td class="right">Rp {{ number_format((int) $item['unit_price'], 0, ',', '.') }}</td>
|
||||
<td class="right">Rp {{ number_format((int) $item['subtotal'], 0, ',', '.') }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
<tr>
|
||||
<td colspan="4" class="right"><strong>Total</strong></td>
|
||||
<td class="right"><strong>Rp {{ number_format($total, 0, ',', '.') }}</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if (!empty($order['notes']))
|
||||
<p><em>Catatan: {{ $order['notes'] }}</em></p>
|
||||
@endif
|
||||
|
||||
<table class="signatures">
|
||||
<tr>
|
||||
<td>Penerima<br><span class="line"></span></td>
|
||||
<td>Pengantar<br><span class="line"></span></td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
+6
-1
@@ -1,7 +1,12 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\DeliveryNoteController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', function () {
|
||||
return view('welcome');
|
||||
return redirect('/admin');
|
||||
});
|
||||
|
||||
Route::get('/orders/{orderId}/delivery-note', DeliveryNoteController::class)
|
||||
->middleware(['web', 'auth'])
|
||||
->name('orders.delivery-note');
|
||||
|
||||
@@ -14,6 +14,6 @@ class ExampleTest extends TestCase
|
||||
{
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertRedirect('/admin');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Orders;
|
||||
|
||||
use App\Filament\Pages\Transactions\Orders as OrdersPage;
|
||||
use App\Models\User;
|
||||
use App\Services\OrderService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Support\InsForgeFake;
|
||||
use Tests\TestCase;
|
||||
|
||||
class OrderTest extends TestCase
|
||||
{
|
||||
private const PRODUCT_ID = '00000000-0000-0000-0000-000000000101';
|
||||
|
||||
private const CUSTOMER_ID = 'cccccccc-0000-0000-0000-000000000001';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
InsForgeFake::reset();
|
||||
InsForgeFake::fakeRest();
|
||||
|
||||
InsForgeFake::seed('products', [
|
||||
'id' => 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();
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,15 @@ class InsForgeFake
|
||||
/** @var array<string, array<string, array<string, mixed>>> table => id => row */
|
||||
public static array $store = [];
|
||||
|
||||
/** @var list<array{function: string, args: array<string, mixed>}> */
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user