Bagian 8 dari seri

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.

Lanjut: Dashboard dan Laporan