From 002fdce85435144a4d622c59261eb1e13aa7ec58 Mon Sep 17 00:00:00 2001 From: sean Date: Tue, 25 Aug 2026 18:46:03 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20phase=203=20=E2=80=94=20stock=20balance?= =?UTF-8?q?s,=20restock/adjust=20mutations=20with=20activity=20logging,=20?= =?UTF-8?q?movements=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Pages/Inventory/StockMovements.php | 104 +++++++++++ app/Filament/Pages/Inventory/Stocks.php | 116 ++++++++++++ app/Services/ActivityLogger.php | 24 +++ .../InsForge/InsForgeTableService.php | 9 +- app/Services/StockMovementService.php | 45 +++++ app/Services/StockService.php | 132 +++++++++++++ tests/Feature/Inventory/StockTest.php | 173 ++++++++++++++++++ 7 files changed, 601 insertions(+), 2 deletions(-) create mode 100644 app/Filament/Pages/Inventory/StockMovements.php create mode 100644 app/Filament/Pages/Inventory/Stocks.php create mode 100644 app/Services/ActivityLogger.php create mode 100644 app/Services/StockMovementService.php create mode 100644 app/Services/StockService.php create mode 100644 tests/Feature/Inventory/StockTest.php diff --git a/app/Filament/Pages/Inventory/StockMovements.php b/app/Filament/Pages/Inventory/StockMovements.php new file mode 100644 index 0000000..ceea4fc --- /dev/null +++ b/app/Filament/Pages/Inventory/StockMovements.php @@ -0,0 +1,104 @@ +records(function (?string $search, ?string $sortColumn, ?string $sortDirection, int|string $page, int|string $recordsPerPage, ?array $filters) use ($service) { + $type = Arr::get($filters, 'type.value') ?? Arr::get($filters, 'type'); + + return $service->paginate( + search: $search, + sortColumn: $sortColumn, + sortDirection: $sortDirection, + page: (int) $page, + perPage: (int) $recordsPerPage, + filters: array_filter(['type' => is_array($type) ? ($type['value'] ?? null) : $type]), + ); + }) + ->columns([ + TextColumn::make('created_at')->label('Waktu')->dateTime('d M Y H:i', 'Asia/Jakarta')->sortable(), + TextColumn::make('products.code')->label('Kode')->badge(), + TextColumn::make('products.name')->label('Produk'), + TextColumn::make('type') + ->label('Tipe') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'restock' => 'success', + 'adjustment' => 'warning', + 'order_out' => 'info', + default => 'gray', + }) + ->formatStateUsing(fn (string $state): string => match ($state) { + 'restock' => 'Restok', + 'adjustment' => 'Koreksi', + 'order_out' => 'Order Keluar', + default => $state, + }), + TextColumn::make('quantity') + ->label('Qty') + ->numeric() + ->color(fn (string $state): string => str_starts_with((string) $state, '-') ? 'danger' : 'success'), + TextColumn::make('notes')->label('Catatan')->limit(40), + ]) + ->filters([ + Filter::make('type') + ->schema([ + Select::make('value')->label('Tipe Mutasi')->options([ + 'restock' => 'Restok', + 'adjustment' => 'Koreksi', + 'order_out' => 'Order Keluar', + ]), + ]), + ]) + ->paginated([10, 25, 50]); + } + + public function content(Schema $schema): Schema + { + return $schema + ->components([ + EmbeddedTable::make(), + ]); + } +} diff --git a/app/Filament/Pages/Inventory/Stocks.php b/app/Filament/Pages/Inventory/Stocks.php new file mode 100644 index 0000000..b797e9b --- /dev/null +++ b/app/Filament/Pages/Inventory/Stocks.php @@ -0,0 +1,116 @@ +records(fn (?string $sortColumn, ?string $sortDirection, int|string $page, int|string $recordsPerPage) => $service->paginate( + sortColumn: $sortColumn, + sortDirection: $sortDirection, + page: (int) $page, + perPage: (int) $recordsPerPage, + )) + ->columns([ + TextColumn::make('products.code')->label('Kode')->badge()->sortable(false), + TextColumn::make('products.name')->label('Produk')->weight(FontWeight::SemiBold), + TextColumn::make('quantity')->label('Saldo')->numeric()->sortable(), + TextColumn::make('products.stock_minimum')->label('Min.'), + TextColumn::make('status') + ->label('Status') + ->state(fn (array $record): string => (int) $record['quantity'] <= (int) ($record['products']['stock_minimum'] ?? 0) ? 'kritis' : 'aman') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'kritis' => 'danger', + default => 'success', + }), + TextColumn::make('updated_at')->label('Diperbarui')->dateTime('d M Y H:i', 'Asia/Jakarta')->sortable(), + ]) + ->recordActions([ + Action::make('restok') + ->label('Restok') + ->icon('heroicon-o-plus-circle') + ->visible(fn (): bool => Gate::check('manage-stock')) + ->schema(fn (Schema $schema): Schema => $schema->components([ + TextInput::make('quantity')->label('Jumlah Masuk')->numeric()->minValue(1)->required(), + Textarea::make('notes')->label('Catatan'), + ])) + ->action(function (array $data, array $record): void { + app(StockService::class)->restock( + $record['product_id'], + (int) $data['quantity'], + $data['notes'] ?? null, + auth()->user(), + ); + }), + Action::make('koreksi') + ->label('Koreksi') + ->icon('heroicon-o-adjustments-horizontal') + ->color('warning') + ->visible(fn (): bool => Gate::check('manage-stock')) + ->schema(fn (Schema $schema): Schema => $schema->components([ + TextInput::make('new_quantity')->label('Saldo Baru')->numeric()->minValue(0)->required() + ->default(fn (array $record): int => (int) $record['quantity']), + Textarea::make('notes')->label('Alasan Koreksi'), + ])) + ->action(function (array $data, array $record): void { + app(StockService::class)->adjust( + $record['product_id'], + (int) $data['new_quantity'], + $data['notes'] ?? null, + auth()->user(), + ); + }), + ]) + ->paginated([10, 25, 50]); + } + + public function content(Schema $schema): Schema + { + return $schema + ->components([ + EmbeddedTable::make(), + ]); + } +} diff --git a/app/Services/ActivityLogger.php b/app/Services/ActivityLogger.php new file mode 100644 index 0000000..0eb180e --- /dev/null +++ b/app/Services/ActivityLogger.php @@ -0,0 +1,24 @@ + $actor?->id, + 'action' => $action, + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'description' => $description, + ], fn ($value) => $value !== null && $value !== ''); + + $this->client->insertRecord('activity_logs', [$entry], asUser: false); + } +} diff --git a/app/Services/InsForge/InsForgeTableService.php b/app/Services/InsForge/InsForgeTableService.php index 2e6836f..0c93cda 100644 --- a/app/Services/InsForge/InsForgeTableService.php +++ b/app/Services/InsForge/InsForgeTableService.php @@ -25,6 +25,11 @@ abstract class InsForgeTableService return 'desc'; } + protected function select(): string + { + return '*'; + } + /** @param array $filters column => value (eq) */ public function paginate( ?string $search = null, @@ -35,7 +40,7 @@ abstract class InsForgeTableService array $filters = [], ): LengthAwarePaginator { $query = [ - 'select' => '*', + 'select' => $this->select(), 'limit' => max(1, min($perPage, 100)), 'offset' => ($page - 1) * $perPage, 'order' => sprintf( @@ -122,7 +127,7 @@ abstract class InsForgeTableService * * @return array{0: InsForgeClient, 1: bool} */ - private function scoped(): array + protected function scoped(): array { $token = session(InsForgeAuthService::SESSION_ACCESS_TOKEN); diff --git a/app/Services/StockMovementService.php b/app/Services/StockMovementService.php new file mode 100644 index 0000000..398848e --- /dev/null +++ b/app/Services/StockMovementService.php @@ -0,0 +1,45 @@ + $filters */ + public function paginate( + ?string $search = null, + ?string $sortColumn = null, + ?string $sortDirection = null, + int $page = 1, + int $perPage = 25, + array $filters = [], + ): LengthAwarePaginator { + $extra = array_intersect_key($filters, array_flip(['type', 'product_id'])); + + return parent::paginate($search, $sortColumn, $sortDirection, $page, $perPage, $extra); + } +} diff --git a/app/Services/StockService.php b/app/Services/StockService.php new file mode 100644 index 0000000..c2c26e4 --- /dev/null +++ b/app/Services/StockService.php @@ -0,0 +1,132 @@ + 0 || throw new \InvalidArgumentException('Jumlah restok harus lebih dari nol.'); + + $newBalance = $this->applyMutation($productId, fn (int $current): int => $current + $quantity, $quantity, 'restock', $notes, $actor); + + $this->activity->log( + 'stock_restocked', + 'stocks', + $productId, + sprintf('Restok %d untuk produk %s (saldo menjadi %d).', $quantity, $this->productLabel($productId), $newBalance), + $actor, + ); + } + + /** + * Koreksi saldo ke nilai absolut baru. + */ + public function adjust(string $productId, int $newQuantity, ?string $notes = null, ?User $actor = null): void + { + $delta = $newQuantity - $this->currentQuantity($productId); + + if ($delta === 0) { + return; + } + + $this->applyMutation($productId, fn (): int => $newQuantity, $delta, 'adjustment', $notes, $actor); + + $this->activity->log( + 'stock_adjusted', + 'stocks', + $productId, + sprintf('Koreksi stok produk %s menjadi %d (selisih %+d).', $this->productLabel($productId), $newQuantity, $delta), + $actor, + ); + } + + private function applyMutation(string $productId, callable $resolveNewBalance, int $movementQuantity, string $type, ?string $notes, ?User $actor): int + { + [$client, $asUser] = $this->scoped(); + + $stock = $client->listRecords('stocks', ['product_id' => 'eq.'.$productId, 'limit' => 1], asUser: $asUser)[0] ?? null; + + $current = (int) ($stock['quantity'] ?? 0); + $newBalance = $resolveNewBalance($current); + + if ($stock === null) { + $client->insertRecord('stocks', [[ + 'product_id' => $productId, + 'quantity' => max($newBalance, 0), + ]], asUser: false); + } else { + $client->updateRecords('stocks', ['id' => $stock['id']], [ + 'quantity' => $newBalance, + ], asUser: false); + } + + $mutation = array_filter([ + 'product_id' => $productId, + 'type' => $type, + 'quantity' => $movementQuantity, + 'notes' => $notes, + 'created_by' => $actor?->id, + ], fn ($value) => $value !== null && $value !== ''); + + $client->insertRecord('stock_movements', [$mutation], asUser: false); + + return $newBalance; + } + + private function currentQuantity(string $productId): int + { + [$client, $asUser] = $this->scoped(); + + $stock = $client->listRecords('stocks', ['product_id' => 'eq.'.$productId, 'limit' => 1], asUser: $asUser)[0] ?? null; + + return (int) ($stock['quantity'] ?? 0); + } + + private function productLabel(string $productId): string + { + $product = $this->products->find($productId); + + return trim(($product['code'] ?? '').' — '.($product['name'] ?? '?'), ' —') ?: '?'; + } +} diff --git a/tests/Feature/Inventory/StockTest.php b/tests/Feature/Inventory/StockTest.php new file mode 100644 index 0000000..953939c --- /dev/null +++ b/tests/Feature/Inventory/StockTest.php @@ -0,0 +1,173 @@ + '11111111-1111-1111-1111-111111111111', + 'name' => 'Admin BerasPro', + 'email' => 'admin@beraspro.test', + 'role' => 'admin', + ]; + + protected function setUp(): void + { + parent::setUp(); + + InsForgeFake::reset(); + InsForgeFake::fakeRest(); + } + + private function actingAsRole(string $role): void + { + $user = new User; + + $user->forceFill([ + 'id' => "33333333-3333-3333-3333-{$role}0000000", + 'name' => ucfirst($role), + 'email' => "{$role}@beraspro.test", + 'role' => $role, + 'is_active' => true, + ]); + + $this->actingAs($user); + } + + public function test_restock_creates_movement_and_updates_balance(): void + { + $this->actingAsRole('admin'); + + InsForgeFake::seed('stocks', [ + 'id' => 'cccc3333-0000-0000-0000-000000000001', + 'product_id' => 'p-1', + 'quantity' => 100, + ]); + + app(StockService::class)->restock('p-1', 250, 'gudang A', auth()->user()); + + $this->assertSame(350, InsForgeFake::$store['stocks']['cccc3333-0000-0000-0000-000000000001']['quantity']); + + $movement = end(InsForgeFake::$store['stock_movements']); + $this->assertSame('restock', $movement['type']); + $this->assertSame(250, $movement['quantity']); + $this->assertSame('p-1', $movement['product_id']); + } + + public function test_adjust_sets_absolute_balance_with_signed_delta(): void + { + $this->actingAsRole('admin'); + + InsForgeFake::seed('stocks', [ + 'id' => 'cccc3333-0000-0000-0000-000000000002', + 'product_id' => 'p-2', + 'quantity' => 500, + ]); + + app(StockService::class)->adjust('p-2', 420, 'selisih fisik'); + + $this->assertSame(420, InsForgeFake::$store['stocks']['cccc3333-0000-0000-0000-000000000002']['quantity']); + + $movement = end(InsForgeFake::$store['stock_movements']); + $this->assertSame('adjustment', $movement['type']); + $this->assertSame(-80, $movement['quantity']); + } + + public function test_adjust_creates_stock_row_when_missing(): void + { + $this->actingAsRole('admin'); + + app(StockService::class)->adjust('p-new', 25, 'stok awal'); + + $row = array_values(array_filter( + InsForgeFake::$store['stocks'] ?? [], + fn (array $row): bool => $row['product_id'] === 'p-new', + )); + + $this->assertCount(1, $row); + $this->assertSame(25, reset($row)['quantity']); + } + + public function test_activity_log_written_for_mutation(): void + { + $this->actingAsRole('admin'); + + InsForgeFake::seed('products', [ + 'id' => 'p-log', + 'code' => 'BERAS-009', + 'name' => 'Beras Log', + ]); + + InsForgeFake::seed('stocks', [ + 'id' => 'cccc3333-0000-0000-0000-000000000003', + 'product_id' => 'p-log', + 'quantity' => 10, + ]); + + app(StockService::class)->restock('p-log', 5, null, auth()->user()); + + $logs = array_values(InsForgeFake::$store['activity_logs'] ?? []); + $log = end($logs); + $this->assertNotFalse($log); + $this->assertSame('stock_restocked', $log['action']); + $this->assertSame('p-log', $log['entity_id']); + } + + public function test_stocks_page_lists_balances_for_operator(): void + { + $this->actingAsRole('operator'); + + InsForgeFake::seed('stocks', [ + 'id' => 'cccc3333-0000-0000-0000-000000000004', + 'product_id' => 'p-view', + 'quantity' => 9, + 'updated_at' => '2026-08-25T02:00:00+00:00', + 'products' => ['code' => 'BERAS-010', 'name' => 'Beras Kritis', 'stock_minimum' => 50, 'is_active' => true], + ]); + + Livewire::test(Stocks::class) + ->assertSuccessful() + ->assertSee('Beras Kritis') + ->assertSee('kritis'); + } + + public function test_movements_page_filters_by_type(): void + { + $this->actingAsRole('operator'); + + Http::fake([ + '*/api/auth/refresh' => Http::response(['accessToken' => 'x.y.z'], 200), + '*/api/database/records/stock_movements*' => fn (Request $request) => InsForgeFake::handleRecordsRequest($request), + ]); + + InsForgeFake::seed('stock_movements', [ + 'id' => 'dddd4444-0000-0000-0000-000000000001', + 'product_id' => 'p-a', + 'type' => 'restock', + 'quantity' => 100, + 'notes' => 'awal', + ]); + InsForgeFake::seed('stock_movements', [ + 'id' => 'dddd4444-0000-0000-0000-000000000002', + 'product_id' => 'p-b', + 'type' => 'adjustment', + 'quantity' => -3, + 'notes' => 'rusak', + ]); + + Livewire::test(StockMovements::class) + ->filterTable('type', ['value' => 'adjustment']) + ->assertSuccessful() + ->assertSee('rusak'); + } +}