Membuat Kartu Stok dan Penyesuaian Stok Laravel
Diterbitkan 15 September 2026
Pada tahap kedelapan kita akan membuat kartu stok dan penyesuaian stok pada sistem kasir Laravel.
Tahap ini memanfaatkan tabel:
stock_movements
yang sudah digunakan sejak tahap sebelumnya.
Sampai tahap ini movement stok dapat berasal dari:
initial
sale
sale_cancelled
purchase
purchase_cancelled
Sekarang kita akan menambahkan:
adjustment
untuk mencatat koreksi stok ketika jumlah fisik berbeda dengan jumlah yang tersimpan pada sistem.
Contoh:
Stok sistem : 20
Stok fisik : 18
Artinya perlu koreksi:
-2
Contoh lain:
Stok sistem : 10
Stok fisik : 13
Artinya perlu koreksi:
+3
Penyesuaian stok tidak boleh dilakukan dengan mengedit langsung:
products.stock
melalui form produk.
Perubahan harus:
mengunci produk
menghitung selisih
memastikan stok tidak negatif
mengubah stock
membuat stock movement
dan seluruh proses dilakukan dalam:
DB::transaction()
Setelah tahap ini selesai, administrator dapat melihat histori stok tiap produk dan melakukan koreksi stok dengan jejak yang jelas.
Hasil yang Akan Dibuat
Kita akan membuat:
StockController
Route:
/stocks
/stocks/{product}
/stocks/{product}/adjust
View:
resources/views/stocks/index.blade.php
resources/views/stocks/show.blade.php
resources/views/stocks/adjust.blade.php
Fitur:
- daftar stok produk;
- pencarian SKU dan nama;
- filter kategori;
- indikator stok menipis;
- halaman kartu stok per produk;
- filter tipe movement;
- filter tanggal;
- saldo berjalan;
- penyesuaian stok;
- alasan adjustment;
- proteksi stok negatif;
- database transaction;
- row lock;
- histori adjustment.
Persiapan
Pastikan Tahap 01 sampai Tahap 07 sudah selesai.
Aplikasi sudah memiliki:
products
stock_movements
sales
purchases
dan movement:
initial
sale
sale_cancelled
purchase
purchase_cancelled
Login sebagai administrator:
admin@example.com
admin12345
1. Konsep Kartu Stok
Kartu stok adalah histori perubahan stok suatu produk.
Contoh:
Stok awal +20
Pembelian +10
Penjualan -3
Penjualan -2
Pembatalan jual +2
Adjustment -1
Saldo akhir:
26
Tabel products.stock menyimpan saldo saat ini.
Tabel stock_movements menyimpan alasan perubahan saldo.
Keduanya harus tetap konsisten.
2. Struktur Movement yang Sudah Digunakan
Sampai tahap ini kita memakai nilai type:
initial
sale
sale_cancelled
purchase
purchase_cancelled
Pada tahap ini kita menambahkan:
adjustment
Tidak perlu migration baru karena kolom type sudah bertipe string.
3. Prinsip Penyesuaian Stok
Penyesuaian stok dilakukan berdasarkan:
stok fisik
bukan berdasarkan input langsung tambah atau kurang.
Contoh:
stok sistem = 20
stok fisik = 17
Selisih:
17 - 20 = -3
Movement:
quantity_in = 0
quantity_out = 3
Contoh lain:
stok sistem = 8
stok fisik = 11
Selisih:
11 - 8 = +3
Movement:
quantity_in = 3
quantity_out = 0
Pendekatan ini lebih mudah untuk audit karena admin cukup menghitung stok fisik sebenarnya.
4. Membuat StockController
Jalankan:
php artisan make:controller StockController
File:
app/Http/Controllers/StockController.php
5. Menambahkan Route Stok
Buka:
routes/web.php
Tambahkan import:
use App\Http\Controllers\StockController;
Tambahkan ke group admin:
Route::middleware(['auth', 'role:admin'])->group(function () {
Route::get(
'stocks',
[StockController::class, 'index']
)->name('stocks.index');
Route::get(
'stocks/{product}',
[StockController::class, 'show']
)->name('stocks.show');
Route::get(
'stocks/{product}/adjust',
[StockController::class, 'adjust']
)->name('stocks.adjust');
Route::post(
'stocks/{product}/adjust',
[StockController::class, 'storeAdjustment']
)->name('stocks.adjust.store');
});
6. Menambahkan Menu Stok
Pada layout:
resources/views/layouts/app.blade.php
tambahkan:
@if(auth()->user()->role === 'admin')
<li class="nav-item">
<a
class="nav-link"
href="{{ route('stocks.index') }}">
Stok
</a>
</li>
@endif
7. Import StockController
Gunakan:
use App\Models\Category;
use App\Models\Product;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
8. StockController Lengkap
Buka:
app/Http/Controllers/StockController.php
Isi:
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Product;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class StockController extends Controller
{
public function index(Request $request): View
{
$search = trim((string) $request->get('q'));
$categoryId = $request->get('category_id');
$stockStatus = $request->get('stock_status');
$products = Product::query()
->with(['category', 'unit'])
->when($search !== '', function ($query) use ($search) {
$query->where(function ($query) use ($search) {
$query
->where('sku', 'like', '%' . $search . '%')
->orWhere('name', 'like', '%' . $search . '%');
});
})
->when(
$categoryId !== null && $categoryId !== '',
fn ($query) => $query->where('category_id', $categoryId)
)
->when(
$stockStatus === 'low',
fn ($query) => $query->whereColumn(
'stock',
'<=',
'minimum_stock'
)
)
->when(
$stockStatus === 'available',
fn ($query) => $query->where('stock', '>', 0)
)
->when(
$stockStatus === 'empty',
fn ($query) => $query->where('stock', 0)
)
->orderBy('name')
->paginate(15)
->withQueryString();
$categories = Category::query()
->orderBy('name')
->get();
return view('stocks.index', compact(
'products',
'categories',
'search',
'categoryId',
'stockStatus'
));
}
public function show(
Request $request,
Product $product
): View {
$type = $request->get('type');
$startDate = $request->get('start_date');
$endDate = $request->get('end_date');
$movements = $product->stockMovements()
->when(
$type !== null && $type !== '',
fn ($query) => $query->where('type', $type)
)
->when(
$startDate,
fn ($query) => $query->whereDate(
'created_at',
'>=',
$startDate
)
)
->when(
$endDate,
fn ($query) => $query->whereDate(
'created_at',
'<=',
$endDate
)
)
->orderBy('created_at')
->orderBy('id')
->get();
$allMovements = $product->stockMovements()
->orderBy('created_at')
->orderBy('id')
->get();
$runningBalance = 0;
$balances = [];
foreach ($allMovements as $movement) {
$runningBalance +=
$movement->quantity_in
- $movement->quantity_out;
$balances[$movement->id] = $runningBalance;
}
return view('stocks.show', compact(
'product',
'movements',
'balances',
'type',
'startDate',
'endDate'
));
}
public function adjust(Product $product): View
{
$product->load(['category', 'unit']);
return view('stocks.adjust', compact('product'));
}
public function storeAdjustment(
Request $request,
Product $product
): RedirectResponse {
$validated = $request->validate([
'physical_stock' => [
'required',
'integer',
'min:0',
],
'notes' => [
'required',
'string',
'max:500',
],
]);
DB::transaction(function () use (
$product,
$validated
) {
$lockedProduct = Product::query()
->whereKey($product->id)
->lockForUpdate()
->firstOrFail();
$currentStock = (int) $lockedProduct->stock;
$physicalStock = (int) $validated['physical_stock'];
$difference = $physicalStock - $currentStock;
if ($difference === 0) {
throw ValidationException::withMessages([
'physical_stock' => 'Stok fisik sama dengan stok sistem. Tidak ada penyesuaian yang perlu disimpan.',
]);
}
if ($physicalStock < 0) {
throw ValidationException::withMessages([
'physical_stock' => 'Stok tidak boleh negatif.',
]);
}
$lockedProduct->update([
'stock' => $physicalStock,
]);
$lockedProduct->stockMovements()->create([
'type' => 'adjustment',
'quantity_in' => $difference > 0
? $difference
: 0,
'quantity_out' => $difference < 0
? abs($difference)
: 0,
'reference_type' => 'adjustment',
'reference_id' => null,
'notes' => trim($validated['notes']),
]);
});
return redirect()
->route('stocks.show', $product)
->with('success', 'Penyesuaian stok berhasil disimpan.');
}
}
9. Mengapa Kartu Stok Menggunakan Movement?
Saldo produk saat ini tersedia pada:
products.stock
Namun kartu stok harus menjelaskan bagaimana saldo tersebut terbentuk.
Karena itu halaman detail mengambil:
$product->stockMovements()
10. Menghitung Saldo Berjalan
Saldo berjalan dihitung:
saldo sebelumnya
+ quantity_in
- quantity_out
Contoh:
initial +20 -> saldo 20
sale -2 -> saldo 18
purchase +5 -> saldo 23
Controller membuat array:
$balances[$movement->id]
yang akan digunakan pada tabel.
11. Mengapa Saldo Dihitung dari Semua Movement?
Ketika kartu stok difilter tanggal atau type, saldo berjalan tetap sebaiknya merefleksikan histori lengkap.
Karena itu:
$movements
digunakan untuk data yang ditampilkan.
Sedangkan:
$allMovements
digunakan untuk menghitung saldo historis setiap movement.
12. Potensi Perbedaan Saldo
Pada kondisi ideal:
saldo movement akhir
=
products.stock
Jika berbeda, berarti pernah ada perubahan stok yang tidak dicatat melalui movement.
Itulah alasan sejak Tahap 05 kita tidak mengizinkan stok diedit langsung dari CRUD produk.
13. Membuat Folder View Stocks
Buat:
resources/views/stocks
Kemudian:
index.blade.php
show.blade.php
adjust.blade.php
14. View Index Stok
Buat:
resources/views/stocks/index.blade.php
Isi:
@extends('layouts.app')
@section('title', 'Stok Produk')
@section('content')
<div class="mb-3">
<h1 class="h4 mb-1">Stok Produk</h1>
<p class="text-muted mb-0">
Lihat saldo stok dan kartu stok setiap produk.
</p>
</div>
<div class="card mb-3">
<div class="card-body">
<form
method="GET"
action="{{ route('stocks.index') }}"
class="row g-2">
<div class="col-lg-4">
<input
type="text"
name="q"
value="{{ $search }}"
class="form-control"
placeholder="Cari SKU atau nama produk...">
</div>
<div class="col-lg-3">
<select
name="category_id"
class="form-select">
<option value="">
Semua Kategori
</option>
@foreach($categories as $category)
<option
value="{{ $category->id }}"
@selected(
(string) $categoryId
===
(string) $category->id
)>
{{ $category->name }}
</option>
@endforeach
</select>
</div>
<div class="col-lg-2">
<select
name="stock_status"
class="form-select">
<option value="">
Semua Stok
</option>
<option
value="low"
@selected($stockStatus === 'low')}>
Menipis
</option>
<option
value="available"
@selected($stockStatus === 'available')}>
Tersedia
</option>
<option
value="empty"
@selected($stockStatus === 'empty')}>
Habis
</option>
</select>
</div>
<div class="col-lg-3">
<div class="d-flex gap-2">
<button
class="btn btn-outline-primary">
Filter
</button>
<a
href="{{ route('stocks.index') }}"
class="btn btn-outline-secondary">
Reset
</a>
</div>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered align-middle">
<thead>
<tr>
<th>No</th>
<th>SKU</th>
<th>Produk</th>
<th>Kategori</th>
<th class="text-center">
Stok
</th>
<th class="text-center">
Minimum
</th>
<th>Status</th>
<th>Aksi</th>
</tr>
</thead>
<tbody>
@forelse($products as $product)
<tr>
<td>
{{ $products->firstItem() + $loop->index }}
</td>
<td>
<code>{{ $product->sku }}</code>
</td>
<td>
{{ $product->name }}
</td>
<td>
{{ $product->category?->name ?? '-' }}
</td>
<td class="text-center">
{{ $product->stock }}
{{ $product->unit?->symbol }}
</td>
<td class="text-center">
{{ $product->minimum_stock }}
</td>
<td>
@if($product->stock === 0)
<span class="badge text-bg-danger">
Habis
</span>
@elseif(
$product->stock
<=
$product->minimum_stock
)
<span class="badge text-bg-warning">
Menipis
</span>
@else
<span class="badge text-bg-success">
Aman
</span>
@endif
</td>
<td>
<div class="d-flex flex-wrap gap-2">
<a
href="{{ route('stocks.show', $product) }}"
class="btn btn-sm btn-outline-primary">
Kartu Stok
</a>
<a
href="{{ route('stocks.adjust', $product) }}"
class="btn btn-sm btn-outline-warning">
Penyesuaian
</a>
</div>
</td>
</tr>
@empty
<tr>
<td
colspan="8"
class="text-center text-muted">
Data produk belum tersedia.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{ $products->links() }}
</div>
</div>
@endsection
15. Filter Stok Menipis
Query:
whereColumn(
'stock',
'<=',
'minimum_stock'
)
berarti produk dengan:
stock <= minimum_stock
ditampilkan.
16. Filter Stok Habis
Query:
where('stock', 0)
digunakan untuk produk habis.
17. View Kartu Stok
Buat:
resources/views/stocks/show.blade.php
Isi:
@extends('layouts.app')
@section('title', 'Kartu Stok')
@section('content')
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h1 class="h4 mb-1">
Kartu Stok
</h1>
<div class="text-muted">
{{ $product->sku }} - {{ $product->name }}
</div>
</div>
<div class="d-flex gap-2">
<a
href="{{ route('stocks.adjust', $product) }}"
class="btn btn-warning">
Penyesuaian Stok
</a>
<a
href="{{ route('stocks.index') }}"
class="btn btn-secondary">
Kembali
</a>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-4">
<div class="card">
<div class="card-body">
<div class="text-muted small">
Stok Saat Ini
</div>
<div class="fs-3 fw-semibold">
{{ $product->stock }}
{{ $product->unit?->symbol }}
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<div class="text-muted small">
Stok Minimum
</div>
<div class="fs-3 fw-semibold">
{{ $product->minimum_stock }}
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<div class="text-muted small">
Status
</div>
<div class="fs-5 fw-semibold">
@if($product->stock === 0)
Habis
@elseif(
$product->stock
<=
$product->minimum_stock
)
Menipis
@else
Aman
@endif
</div>
</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<form
method="GET"
action="{{ route('stocks.show', $product) }}"
class="row g-2">
<div class="col-lg-3">
<select
name="type"
class="form-select">
<option value="">
Semua Tipe
</option>
@foreach([
'initial' => 'Stok Awal',
'purchase' => 'Pembelian',
'purchase_cancelled' => 'Pembatalan Pembelian',
'sale' => 'Penjualan',
'sale_cancelled' => 'Pembatalan Penjualan',
'adjustment' => 'Penyesuaian',
] as $value => $label)
<option
value="{{ $value }}"
@selected($type === $value)>
{{ $label }}
</option>
@endforeach
</select>
</div>
<div class="col-lg-3">
<input
type="date"
name="start_date"
value="{{ $startDate }}"
class="form-control">
</div>
<div class="col-lg-3">
<input
type="date"
name="end_date"
value="{{ $endDate }}"
class="form-control">
</div>
<div class="col-lg-3">
<div class="d-flex gap-2">
<button
class="btn btn-outline-primary">
Filter
</button>
<a
href="{{ route('stocks.show', $product) }}"
class="btn btn-outline-secondary">
Reset
</a>
</div>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered align-middle">
<thead>
<tr>
<th>Tanggal</th>
<th>Tipe</th>
<th>Referensi</th>
<th class="text-end">Masuk</th>
<th class="text-end">Keluar</th>
<th class="text-end">Saldo</th>
<th>Catatan</th>
</tr>
</thead>
<tbody>
@forelse($movements as $movement)
<tr>
<td>
{{ $movement->created_at->format('d/m/Y H:i') }}
</td>
<td>
@switch($movement->type)
@case('initial')
Stok Awal
@break
@case('purchase')
Pembelian
@break
@case('purchase_cancelled')
Pembatalan Pembelian
@break
@case('sale')
Penjualan
@break
@case('sale_cancelled')
Pembatalan Penjualan
@break
@case('adjustment')
Penyesuaian
@break
@default
{{ $movement->type }}
@endswitch
</td>
<td>
@if($movement->reference_type)
{{ $movement->reference_type }}
@if($movement->reference_id)
#{{ $movement->reference_id }}
@endif
@else
-
@endif
</td>
<td class="text-end text-success">
@if($movement->quantity_in > 0)
+{{ $movement->quantity_in }}
@else
-
@endif
</td>
<td class="text-end text-danger">
@if($movement->quantity_out > 0)
-{{ $movement->quantity_out }}
@else
-
@endif
</td>
<td class="text-end fw-semibold">
{{ $balances[$movement->id] ?? '-' }}
</td>
<td>
{{ $movement->notes ?: '-' }}
</td>
</tr>
@empty
<tr>
<td
colspan="7"
class="text-center text-muted">
Tidak ada histori stok sesuai filter.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
@endsection
18. Mengapa Movement Diurutkan Lama ke Baru?
Kartu stok menggunakan:
orderBy('created_at')
orderBy('id')
agar saldo berjalan mudah dibaca dari atas ke bawah.
Jika daftar terbaru ingin berada di atas, saldo harus dihitung lebih hati-hati.
Untuk tutorial ini urutan kronologis lebih mudah dipahami.
19. View Penyesuaian Stok
Buat:
resources/views/stocks/adjust.blade.php
Isi:
@extends('layouts.app')
@section('title', 'Penyesuaian Stok')
@section('content')
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
Penyesuaian Stok
</div>
<div class="card-body">
<div class="mb-3">
<div class="fw-semibold">
{{ $product->name }}
</div>
<div class="text-muted">
{{ $product->sku }}
</div>
</div>
<div class="alert alert-info">
Stok sistem saat ini:
<strong>
{{ $product->stock }}
{{ $product->unit?->symbol }}
</strong>
</div>
<form
method="POST"
action="{{ route('stocks.adjust.store', $product) }}">
@csrf
<div class="mb-3">
<label class="form-label">
Stok Fisik
</label>
<input
type="number"
name="physical_stock"
value="{{ old(
'physical_stock',
$product->stock
) }}"
min="0"
step="1"
class="form-control @error('physical_stock') is-invalid @enderror"
required>
@error('physical_stock')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
<div class="form-text">
Masukkan hasil hitung stok fisik sebenarnya.
</div>
</div>
<div class="mb-3">
<label class="form-label">
Alasan Penyesuaian
</label>
<textarea
name="notes"
rows="4"
maxlength="500"
class="form-control @error('notes') is-invalid @enderror"
required>{{ old('notes') }}</textarea>
@error('notes')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
<div class="form-text">
Contoh: hasil stock opname, barang rusak,
selisih pencatatan, atau koreksi stok awal.
</div>
</div>
<div class="d-flex gap-2">
<button
type="submit"
class="btn btn-warning"
onclick="return confirm('Simpan penyesuaian stok ini?')">
Simpan Penyesuaian
</button>
<a
href="{{ route('stocks.show', $product) }}"
class="btn btn-secondary">
Kembali
</a>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection
20. Mengapa Input Menggunakan Stok Fisik?
Daripada meminta admin memilih:
stok masuk
stok keluar
kita meminta nilai akhir hasil hitung fisik.
Contoh:
stok sistem = 20
stok fisik = 18
Controller menghitung:
difference = -2
Movement menjadi stok keluar 2.
Ini mengurangi kesalahan input tanda tambah atau kurang.
21. Mengunci Produk Saat Adjustment
Controller menggunakan:
lockForUpdate()
karena saat admin sedang melakukan penyesuaian, kasir dapat saja melakukan transaksi.
Stok yang digunakan untuk menghitung selisih harus stok terbaru di dalam database transaction.
22. Adjustment Tidak Boleh Nol
Jika:
stok sistem = 20
stok fisik = 20
tidak perlu membuat movement.
Controller menolak:
Tidak ada penyesuaian yang perlu disimpan.
Ini mencegah histori penuh dengan record tanpa perubahan.
23. Adjustment Positif
Contoh:
stok sistem = 10
stok fisik = 13
difference = +3
Movement:
type = adjustment
quantity_in = 3
quantity_out = 0
Produk menjadi:
stock = 13
24. Adjustment Negatif
Contoh:
stok sistem = 10
stok fisik = 7
difference = -3
Movement:
quantity_in = 0
quantity_out = 3
Produk menjadi:
stock = 7
25. Stok Tidak Boleh Negatif
Form:
physical_stock
menggunakan:
min:0
Jadi nilai negatif ditolak.
Controller juga menjaga logic agar produk tidak memiliki saldo negatif.
26. Catatan Adjustment Wajib
Pada adjustment kita mewajibkan:
notes
Contoh alasan:
Hasil stock opname bulanan
2 barang rusak
Selisih pencatatan
Koreksi stok awal
Audit stok tanpa alasan akan sulit dipahami kemudian.
27. Mengapa reference_id Adjustment NULL?
Adjustment bukan transaksi dengan header tersendiri.
Karena itu:
reference_type = adjustment
reference_id = NULL
Jika di masa depan kita membuat tabel:
stock_adjustments
reference dapat diarahkan ke record adjustment tersebut.
Untuk sistem sederhana belum diperlukan.
28. Memeriksa Kartu Stok dengan Tinker
Jalankan:
php artisan tinker
Ambil produk:
$product = App\Models\Product::where(
'sku',
'BRG-001'
)->first();
Lihat movement:
$product->stockMovements()
->orderBy('created_at')
->orderBy('id')
->get([
'type',
'quantity_in',
'quantity_out',
'reference_type',
'reference_id',
'notes',
]);
29. Menghitung Saldo dari Movement
Di Tinker:
$movementBalance =
$product->stockMovements()->sum('quantity_in')
-
$product->stockMovements()->sum('quantity_out');
Cek:
$movementBalance;
Lalu:
$product->stock;
Pada kondisi normal keduanya sama.
30. Jika Saldo Tidak Sama
Jika:
movement balance != products.stock
kemungkinan ada perubahan stok yang dilakukan di luar alur movement.
Contoh buruk:
$product->update([
'stock' => 999,
]);
tanpa movement.
Untuk tutorial ini hindari perubahan seperti itu.
31. Pengujian Adjustment Naik
Misalnya:
stok sistem = 20
stok fisik = 23
Simpan adjustment.
Hasil:
stock = 23
movement adjustment quantity_in = 3
32. Pengujian Adjustment Turun
Misalnya:
stok sistem = 23
stok fisik = 19
Hasil:
stock = 19
movement adjustment quantity_out = 4
33. Pengujian Adjustment Sama
Masukkan stok fisik sama dengan saldo.
Sistem harus menolak.
Tidak boleh membuat movement baru.
34. Pengujian Adjustment Negatif
Manipulasi request:
physical_stock = -1
Validasi harus menolak.
35. Pengujian Tanpa Alasan
Kosongkan notes.
Validasi harus menolak.
36. Pengujian Filter Type
Buka kartu stok.
Pilih:
Penjualan
Hanya movement:
sale
yang tampil.
37. Pengujian Filter Tanggal
Pilih:
start_date
end_date
Movement di luar periode tidak tampil.
38. Pengujian Saldo Setelah Filter
Walaupun tampilan difilter, kolom saldo tetap mengambil saldo historis movement tersebut.
Ini membantu mengetahui posisi stok pada saat movement terjadi.
39. Menampilkan Link ke Transaksi Opsional
Saat ini reference ditampilkan:
sale #10
purchase #5
Pada pengembangan selanjutnya dapat dibuat link.
Contoh jika:
reference_type = sale
buat link ke:
sales.show
Jika:
purchase
buat link ke:
purchases.show
Namun tutorial dasar cukup menampilkan referensi.
40. Mengapa Kartu Stok Hanya Admin?
Histori stok dan adjustment merupakan bagian sensitif operasional.
Karena itu route dibatasi:
role:admin
Kasir tidak perlu melakukan adjustment.
41. Pengujian Akses Kasir
Login:
kasir@example.com
kasir12345
Buka:
/stocks
Akses harus ditolak.
42. Pengujian Tanpa Login
Logout.
Buka:
/stocks
Pengguna harus diarahkan ke login.
43. Struktur File Setelah Tahap 08
Controller:
app/Http/Controllers/StockController.php
Views:
resources/views/stocks/
├── adjust.blade.php
├── index.blade.php
└── show.blade.php
Routes:
stocks.index
stocks.show
stocks.adjust
stocks.adjust.store
44. Checklist Pengujian Tahap 08
Daftar stok:
[ ] halaman stok dapat dibuka admin
[ ] pencarian SKU bekerja
[ ] pencarian nama bekerja
[ ] filter kategori bekerja
[ ] filter stok menipis bekerja
[ ] filter stok tersedia bekerja
[ ] filter stok habis bekerja
Kartu stok:
[ ] movement tampil kronologis
[ ] stok masuk tampil
[ ] stok keluar tampil
[ ] saldo berjalan tampil
[ ] filter type bekerja
[ ] filter tanggal bekerja
[ ] notes tampil
[ ] referensi tampil
Adjustment:
[ ] stok fisik dapat dimasukkan
[ ] notes wajib
[ ] stok naik tercatat sebagai quantity_in
[ ] stok turun tercatat sebagai quantity_out
[ ] saldo produk berubah
[ ] stock movement dibuat
[ ] adjustment 0 ditolak
[ ] stok negatif ditolak
Keamanan:
[ ] hanya admin dapat mengakses
[ ] adjustment menggunakan DB transaction
[ ] produk dikunci lockForUpdate
45. Kesalahan Umum: Edit Stock dari ProductController
Jangan menambahkan:
stock
ke form update produk.
Perubahan stok harus melalui proses yang mempunyai movement.
46. Kesalahan Umum: Adjustment Hanya Mengubah Product
Jangan hanya:
$product->update([
'stock' => $physicalStock,
]);
tanpa membuat:
stock_movements
47. Kesalahan Umum: Movement Dibuat tetapi Stock Tidak Diubah
Kebalikannya juga salah.
Movement dan saldo produk harus berubah dalam transaction yang sama.
48. Kesalahan Umum: Tidak Mengunci Produk
Jika adjustment dilakukan saat transaksi penjualan berlangsung, tanpa lock saldo dapat bertabrakan.
Gunakan:
DB::transaction()
dan:
lockForUpdate()
49. Kesalahan Umum: Adjustment Tanpa Alasan
Wajibkan notes agar histori dapat diaudit.
50. Kesalahan Umum: Menggunakan Quantity Negatif
Tabel kita memisahkan:
quantity_in
quantity_out
Keduanya sebaiknya selalu bernilai 0 atau positif.
Jangan menyimpan:
quantity_out = -3
Gunakan:
quantity_out = 3
51. Kesalahan Umum: Movement Ganda
Pastikan satu proses hanya membuat movement satu kali.
Contoh pembelian dibatalkan dua kali harus sudah ditolak di Tahap 07.
52. Pengembangan Lanjutan
Kartu stok dapat dikembangkan menjadi:
stock opname berkala
tabel stock_adjustments
nomor dokumen adjustment
approval adjustment
multi gudang
transfer antar gudang
batch
expired date
serial number
average cost
FIFO
Fitur tersebut belum diperlukan untuk sistem kasir sederhana.
53. Hubungan dengan Dashboard
Data stok sekarang sudah lengkap.
Pada tahap dashboard nanti kita dapat membuat:
produk stok menipis
produk habis
jumlah produk aktif
stok total
54. Hubungan dengan Laporan
Movement dapat digunakan untuk laporan:
stok masuk
stok keluar
penyesuaian
histori per produk
Namun laporan utama pada seri ini akan fokus pada penjualan dan ringkasan bisnis.
55. Checkpoint Hasil
Setelah Tahap 08:
Login : selesai
Role admin/kasir : selesai
Database : selesai
Model dan relasi : selesai
Seeder : selesai
CRUD master : selesai
CRUD produk : selesai
Penjualan : selesai
Pembatalan penjualan : selesai
Pembelian : selesai
Pembatalan pembelian : selesai
Stock movement : selesai
Kartu stok : selesai
Penyesuaian stok : selesai
Proteksi stok negatif : selesai
Dashboard/laporan : tahap berikutnya
Nota : belum
Testing/deploy : belum
56. Ringkasan
Pada tahap kedelapan kita telah membuat kartu stok dan penyesuaian stok.
Kartu stok menampilkan movement:
initial
purchase
purchase_cancelled
sale
sale_cancelled
adjustment
Setiap movement mempunyai:
stok masuk
stok keluar
saldo berjalan
referensi
catatan
Penyesuaian stok dilakukan berdasarkan:
stok fisik
Server menghitung selisih terhadap stok sistem.
Jika stok fisik lebih besar:
quantity_in
Jika lebih kecil:
quantity_out
Proses adjustment menggunakan:
DB::transaction()
dan:
lockForUpdate()
agar saldo tetap aman ketika ada transaksi lain.
Tahap berikutnya kita akan membuat dashboard, laporan penjualan, dan ringkasan sistem.
Slug artikel lama dashboard-laporan-dan-nota tetap dipertahankan. Pada struktur baru, bagian tersebut akan difokuskan pada dashboard dan laporan, sedangkan nota akan dibuat lebih detail pada artikel khusus setelahnya.




