Bagian 7 dari seri

Membuat Transaksi Pembelian dan Stok Laravel

Diterbitkan 15 September 2026

Pada tahap ketujuh kita akan membuat transaksi pembelian dan penambahan stok pada sistem kasir Laravel.

Jika transaksi penjualan mengurangi stok, maka transaksi pembelian melakukan kebalikannya.

Ketika pembelian berhasil, aplikasi harus:

membuat header pembelian
membuat detail pembelian
menambahkan stok produk
memperbarui harga beli terakhir
mencatat stock movement
menghitung grand total
menyimpan supplier
menyimpan user yang melakukan transaksi

Seperti penjualan, proses pembelian juga menyentuh beberapa tabel sekaligus.

Karena itu penyimpanan harus menggunakan:

DB::transaction()

dan setiap produk dikunci menggunakan:

lockForUpdate()

Pada tahap ini kita juga akan membuat pembatalan pembelian.

Namun pembatalan pembelian mempunyai aturan tambahan.

Jika barang dari pembelian sudah sebagian terjual, stok saat ini mungkin lebih kecil daripada quantity pembelian.

Contoh:

Pembelian      : +10
Sudah terjual  : -8
Stok sekarang  : 2

Jika pembelian dibatalkan dan sistem langsung mengurangi 10, stok menjadi:

-8

Hal tersebut tidak boleh terjadi.

Karena itu pembatalan pembelian hanya diperbolehkan jika stok produk saat ini masih cukup untuk dikurangi sebanyak quantity pembelian yang dibatalkan.

Setelah tahap ini selesai, administrator sudah dapat melakukan pembelian barang dan stok produk akan bertambah secara otomatis.

Hasil yang Akan Dibuat

Pada tahap ini kita akan membuat:

PurchaseController

Route:

/purchases
/purchases/create
/purchases/{purchase}
/purchases/{purchase}/cancel

View:

resources/views/purchases/index.blade.php
resources/views/purchases/create.blade.php
resources/views/purchases/show.blade.php

Fitur:

  • daftar pembelian;
  • pencarian invoice dan supplier;
  • filter status;
  • transaksi pembelian multi-item;
  • pilih supplier;
  • input quantity;
  • input harga beli;
  • perhitungan subtotal;
  • perhitungan grand total;
  • validasi server;
  • penambahan stok;
  • update harga beli produk;
  • pencatatan stock movement;
  • detail pembelian;
  • pembatalan pembelian;
  • pengurangan stok saat pembatalan;
  • proteksi pembatalan ganda;
  • proteksi stok agar tidak negatif.

Persiapan

Pastikan Tahap 01 sampai Tahap 06 sudah selesai.

Aplikasi harus sudah memiliki:

products
suppliers
purchases
purchase_details
stock_movements
users

Pastikan supplier sudah tersedia.

Jika belum, buka:

/suppliers

dan tambahkan supplier.

Contoh:

PT Sumber Makmur
CV Bali Sejahtera
UD Maju Bersama

Login sebagai administrator:

admin@example.com
admin12345

Untuk tutorial ini transaksi pembelian hanya dapat dilakukan administrator.

1. Alur Transaksi Pembelian

Alur yang akan dibuat:

Admin membuka halaman pembelian
        ↓
Memilih supplier
        ↓
Memilih produk
        ↓
Mengisi quantity
        ↓
Mengisi harga beli
        ↓
Browser menghitung estimasi subtotal
        ↓
Form dikirim ke server
        ↓
Server memvalidasi request
        ↓
Server mengunci produk
        ↓
Server menghitung total kembali
        ↓
Server membuat purchase
        ↓
Server membuat purchase_details
        ↓
Server menambahkan stock
        ↓
Server memperbarui purchase_price
        ↓
Server membuat stock_movements
        ↓
Commit
        ↓
Tampilkan detail pembelian

2. Mengapa Harga Beli Boleh Diinput?

Berbeda dari transaksi penjualan, harga beli memang berasal dari transaksi supplier.

Contoh:

Harga beli lama : Rp2.500
Harga beli baru : Rp2.700

Admin memasukkan harga beli sesuai invoice supplier.

Server tetap melakukan validasi:

numeric
min:0

dan menghitung subtotal sendiri.

Jadi browser boleh mengirim:

price

tetapi browser tidak boleh menentukan:

subtotal
grand_total

3. Mengapa purchase_price Produk Diperbarui?

Setelah pembelian:

purchase_price

pada produk diperbarui menggunakan harga pembelian terbaru.

Tujuannya agar CRUD produk menampilkan harga beli terakhir.

Namun histori harga lama tetap tersedia pada:

purchase_details.price

Jadi transaksi lama tidak berubah.

4. Membuat PurchaseController

Jalankan:

php artisan make:controller PurchaseController

File:

app/Http/Controllers/PurchaseController.php

5. Menambahkan Route Pembelian

Buka:

routes/web.php

Tambahkan import:

use App\Http\Controllers\PurchaseController;

Tambahkan di group admin:

Route::middleware(['auth', 'role:admin'])->group(function () {
    Route::get(
        'purchases',
        [PurchaseController::class, 'index']
    )->name('purchases.index');

    Route::get(
        'purchases/create',
        [PurchaseController::class, 'create']
    )->name('purchases.create');

    Route::post(
        'purchases',
        [PurchaseController::class, 'store']
    )->name('purchases.store');

    Route::get(
        'purchases/{purchase}',
        [PurchaseController::class, 'show']
    )->name('purchases.show');

    Route::post(
        'purchases/{purchase}/cancel',
        [PurchaseController::class, 'cancel']
    )->name('purchases.cancel');
});

Semua route pembelian hanya dapat diakses admin.

6. Menambahkan Menu Pembelian

Pada layout:

resources/views/layouts/app.blade.php

tambahkan:

@if(auth()->user()->role === 'admin')
    <li class="nav-item">
        <a
            class="nav-link"
            href="{{ route('purchases.index') }}">
            Pembelian
        </a>
    </li>
@endif

7. Import PurchaseController

Gunakan:

use App\Models\Product;
use App\Models\Purchase;
use App\Models\Supplier;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;

8. PurchaseController Lengkap

Buka:

app/Http/Controllers/PurchaseController.php

Isi:

<?php

namespace App\Http\Controllers;

use App\Models\Product;
use App\Models\Purchase;
use App\Models\Supplier;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;

class PurchaseController extends Controller
{
    public function index(Request $request): View
    {
        $search = trim((string) $request->get('q'));
        $status = $request->get('status');

        $purchases = Purchase::query()
            ->with(['supplier', 'user'])
            ->when($search !== '', function ($query) use ($search) {
                $query->where(function ($query) use ($search) {
                    $query
                        ->where(
                            'invoice_number',
                            'like',
                            '%' . $search . '%'
                        )
                        ->orWhereHas(
                            'supplier',
                            function ($query) use ($search) {
                                $query->where(
                                    'name',
                                    'like',
                                    '%' . $search . '%'
                                );
                            }
                        );
                });
            })
            ->when(
                in_array(
                    $status,
                    ['received', 'cancelled'],
                    true
                ),
                fn ($query) => $query->where('status', $status)
            )
            ->latest('purchase_date')
            ->paginate(15)
            ->withQueryString();

        return view('purchases.index', compact(
            'purchases',
            'search',
            'status'
        ));
    }

    public function create(): View
    {
        $suppliers = Supplier::query()
            ->orderBy('name')
            ->get([
                'id',
                'name',
                'phone',
            ]);

        $products = Product::query()
            ->with(['category', 'unit'])
            ->where('is_active', true)
            ->orderBy('name')
            ->get([
                'id',
                'category_id',
                'unit_id',
                'sku',
                'name',
                'purchase_price',
                'stock',
            ]);

        return view('purchases.create', compact(
            'suppliers',
            'products'
        ));
    }

    public function store(Request $request): RedirectResponse
    {
        $validated = $request->validate([
            'supplier_id' => [
                'nullable',
                'exists:suppliers,id',
            ],
            'purchase_date' => [
                'required',
                'date',
            ],
            'items' => [
                'required',
                'array',
                'min:1',
            ],
            'items.*.product_id' => [
                'required',
                'integer',
                'exists:products,id',
            ],
            'items.*.quantity' => [
                'required',
                'integer',
                'min:1',
            ],
            'items.*.price' => [
                'required',
                'numeric',
                'min:0',
            ],
        ]);

        $items = collect($validated['items'])
            ->groupBy('product_id')
            ->map(function ($rows, $productId) {
                $lastRow = $rows->last();

                return [
                    'product_id' => (int) $productId,
                    'quantity' => (int) $rows->sum('quantity'),
                    'price' => (float) $lastRow['price'],
                ];
            })
            ->values();

        $purchase = DB::transaction(function () use (
            $request,
            $validated,
            $items
        ) {
            $lockedProducts = [];
            $grandTotal = 0;

            foreach ($items as $item) {
                $product = Product::query()
                    ->whereKey($item['product_id'])
                    ->lockForUpdate()
                    ->firstOrFail();

                if (! $product->is_active) {
                    throw ValidationException::withMessages([
                        'items' => "Produk {$product->name} sedang tidak aktif.",
                    ]);
                }

                $quantity = (int) $item['quantity'];
                $price = (float) $item['price'];

                $subtotal = $quantity * $price;

                $grandTotal += $subtotal;

                $lockedProducts[] = [
                    'model' => $product,
                    'quantity' => $quantity,
                    'price' => $price,
                    'subtotal' => $subtotal,
                ];
            }

            $purchase = Purchase::create([
                'invoice_number' => 'TMP-' . Str::uuid(),
                'supplier_id' => $validated['supplier_id'] ?? null,
                'user_id' => $request->user()->id,
                'purchase_date' => $validated['purchase_date'],
                'grand_total' => $grandTotal,
                'status' => 'received',
            ]);

            $purchase->update([
                'invoice_number' => 'PUR-'
                    . now()->format('Ymd')
                    . '-'
                    . str_pad(
                        (string) $purchase->id,
                        6,
                        '0',
                        STR_PAD_LEFT
                    ),
            ]);

            foreach ($lockedProducts as $item) {
                $product = $item['model'];

                $purchase->details()->create([
                    'product_id' => $product->id,
                    'quantity' => $item['quantity'],
                    'price' => $item['price'],
                    'subtotal' => $item['subtotal'],
                ]);

                $product->increment(
                    'stock',
                    $item['quantity']
                );

                $product->update([
                    'purchase_price' => $item['price'],
                ]);

                $product->stockMovements()->create([
                    'type' => 'purchase',
                    'quantity_in' => $item['quantity'],
                    'quantity_out' => 0,
                    'reference_type' => 'purchase',
                    'reference_id' => $purchase->id,
                    'notes' => 'Pembelian '
                        . $purchase->invoice_number,
                ]);
            }

            return $purchase;
        });

        return redirect()
            ->route('purchases.show', $purchase)
            ->with('success', 'Transaksi pembelian berhasil disimpan.');
    }

    public function show(Purchase $purchase): View
    {
        $purchase->load([
            'supplier',
            'user',
            'details.product.unit',
        ]);

        return view('purchases.show', compact('purchase'));
    }

    public function cancel(
        Request $request,
        Purchase $purchase
    ): RedirectResponse {
        $validated = $request->validate([
            'cancel_notes' => [
                'nullable',
                'string',
                'max:500',
            ],
        ]);

        DB::transaction(function () use (
            $purchase,
            $validated
        ) {
            $lockedPurchase = Purchase::query()
                ->whereKey($purchase->id)
                ->lockForUpdate()
                ->firstOrFail();

            if ($lockedPurchase->status === 'cancelled') {
                throw ValidationException::withMessages([
                    'cancel' => 'Pembelian ini sudah dibatalkan sebelumnya.',
                ]);
            }

            if ($lockedPurchase->status !== 'received') {
                throw ValidationException::withMessages([
                    'cancel' => 'Hanya pembelian berstatus received yang dapat dibatalkan.',
                ]);
            }

            $lockedPurchase->load('details');

            $lockedProducts = [];

            foreach ($lockedPurchase->details as $detail) {
                $product = Product::query()
                    ->whereKey($detail->product_id)
                    ->lockForUpdate()
                    ->firstOrFail();

                if ($product->stock < $detail->quantity) {
                    throw ValidationException::withMessages([
                        'cancel' => "Pembelian tidak dapat dibatalkan karena stok {$product->name} saat ini hanya {$product->stock}, sedangkan pembelian yang akan dibatalkan sebanyak {$detail->quantity}.",
                    ]);
                }

                $lockedProducts[] = [
                    'model' => $product,
                    'quantity' => $detail->quantity,
                ];
            }

            foreach ($lockedProducts as $item) {
                $product = $item['model'];

                $product->decrement(
                    'stock',
                    $item['quantity']
                );

                $product->stockMovements()->create([
                    'type' => 'purchase_cancelled',
                    'quantity_in' => 0,
                    'quantity_out' => $item['quantity'],
                    'reference_type' => 'purchase',
                    'reference_id' => $lockedPurchase->id,
                    'notes' => 'Pembatalan pembelian '
                        . $lockedPurchase->invoice_number,
                ]);
            }

            $lockedPurchase->update([
                'status' => 'cancelled',
            ]);
        });

        return redirect()
            ->route('purchases.show', $purchase)
            ->with(
                'success',
                'Pembelian berhasil dibatalkan dan stok telah dikurangi kembali.'
            );
    }
}

9. Mengapa Produk Dikelompokkan Berdasarkan product_id?

Seperti pada transaksi penjualan, request dapat saja mengandung produk yang sama lebih dari sekali.

Contoh:

Produk A, qty 5
Produk A, qty 3

Kita gabungkan menjadi:

Produk A, qty 8

Untuk harga, contoh controller menggunakan harga pada baris terakhir.

Secara normal halaman pembelian kita hanya membuat satu baris per produk sehingga duplikasi tidak terjadi dari UI.

Pengelompokan ini merupakan proteksi tambahan pada server.

10. Harga Pembelian Tetap Dihitung Server

Browser mengirim:

quantity
price

Server menghitung:

$subtotal = $quantity * $price;

Browser tidak mengirim subtotal sebagai sumber kebenaran.

Grand total:

$grandTotal += $subtotal;

11. Mengapa Produk Dikunci?

Pembelian menambah stok.

Di saat yang sama kasir dapat melakukan penjualan.

Karena itu perubahan stok tetap menggunakan:

lockForUpdate()

agar nilai stok tidak diperbarui secara bersamaan tanpa koordinasi.

12. Membuat Nomor Invoice Pembelian

Format:

PUR-YYYYMMDD-000001

Contoh:

PUR-20260916-000003

Pertama dibuat nilai sementara:

'TMP-' . Str::uuid()

Kemudian setelah ID tersedia invoice diperbarui.

13. Menambahkan Stok Produk

Kode:

$product->increment(
    'stock',
    $item['quantity']
);

Contoh:

stok sebelum = 5
pembelian    = 20
stok sesudah = 25

14. Memperbarui Harga Beli Terakhir

Kode:

$product->update([
    'purchase_price' => $item['price'],
]);

Misalnya:

harga beli lama = 2.500
harga beli baru = 2.700

Setelah pembelian:

products.purchase_price = 2.700

Namun transaksi lama tetap menyimpan:

purchase_details.price = 2.500

jika memang harga saat transaksi lama 2.500.

15. Mencatat Stock Movement Pembelian

Kode:

$product->stockMovements()->create([
    'type' => 'purchase',
    'quantity_in' => $item['quantity'],
    'quantity_out' => 0,
    'reference_type' => 'purchase',
    'reference_id' => $purchase->id,
]);

Dengan demikian perubahan stok mempunyai sumber yang jelas.

16. Membuat Folder View Purchases

Buat:

resources/views/purchases

Kemudian:

index.blade.php
create.blade.php
show.blade.php

17. View Index Pembelian

Buat:

resources/views/purchases/index.blade.php

Isi:

@extends('layouts.app')

@section('title', 'Pembelian')

@section('content')
<div class="d-flex justify-content-between align-items-center mb-3">
    <div>
        <h1 class="h4 mb-1">Pembelian</h1>
        <p class="text-muted mb-0">
            Daftar transaksi pembelian barang.
        </p>
    </div>

    <a
        href="{{ route('purchases.create') }}"
        class="btn btn-primary">
        Pembelian Baru
    </a>
</div>

<div class="card mb-3">
    <div class="card-body">
        <form
            method="GET"
            action="{{ route('purchases.index') }}"
            class="row g-2">

            <div class="col-md-6">
                <input
                    type="text"
                    name="q"
                    value="{{ $search }}"
                    class="form-control"
                    placeholder="Cari invoice atau supplier...">
            </div>

            <div class="col-md-3">
                <select
                    name="status"
                    class="form-select">

                    <option value="">
                        Semua Status
                    </option>

                    <option
                        value="received"
                        @selected($status === 'received')}>
                        Diterima
                    </option>

                    <option
                        value="cancelled"
                        @selected($status === 'cancelled')}>
                        Dibatalkan
                    </option>
                </select>
            </div>

            <div class="col-md-3">
                <div class="d-flex gap-2">
                    <button class="btn btn-outline-primary">
                        Filter
                    </button>

                    <a
                        href="{{ route('purchases.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>Invoice</th>
                        <th>Tanggal</th>
                        <th>Supplier</th>
                        <th>User</th>
                        <th class="text-end">Total</th>
                        <th>Status</th>
                        <th>Aksi</th>
                    </tr>
                </thead>

                <tbody>
                    @forelse($purchases as $purchase)
                        <tr>
                            <td>
                                {{ $purchases->firstItem() + $loop->index }}
                            </td>

                            <td>
                                <code>{{ $purchase->invoice_number }}</code>
                            </td>

                            <td>
                                {{ $purchase->purchase_date->format('d/m/Y H:i') }}
                            </td>

                            <td>
                                {{ $purchase->supplier?->name ?? '-' }}
                            </td>

                            <td>
                                {{ $purchase->user?->name ?? '-' }}
                            </td>

                            <td class="text-end">
                                Rp {{ number_format(
                                    (float) $purchase->grand_total,
                                    0,
                                    ',',
                                    '.'
                                ) }}
                            </td>

                            <td>
                                @if($purchase->status === 'received')
                                    <span class="badge text-bg-success">
                                        Diterima
                                    </span>
                                @else
                                    <span class="badge text-bg-danger">
                                        Dibatalkan
                                    </span>
                                @endif
                            </td>

                            <td>
                                <a
                                    href="{{ route('purchases.show', $purchase) }}"
                                    class="btn btn-sm btn-outline-primary">
                                    Detail
                                </a>
                            </td>
                        </tr>
                    @empty
                        <tr>
                            <td
                                colspan="8"
                                class="text-center text-muted">
                                Belum ada transaksi pembelian.
                            </td>
                        </tr>
                    @endforelse
                </tbody>
            </table>
        </div>

        {{ $purchases->links() }}
    </div>
</div>
@endsection

18. View Create Pembelian

Buat:

resources/views/purchases/create.blade.php

Isi:

@extends('layouts.app')

@section('title', 'Pembelian Baru')

@section('content')
<div class="mb-3">
    <h1 class="h4 mb-1">Pembelian Baru</h1>

    <p class="text-muted mb-0">
        Tambahkan produk yang diterima dari supplier.
    </p>
</div>

@if($errors->any())
    <div class="alert alert-danger">
        <ul class="mb-0">
            @foreach($errors->all() as $error)
                <li>{{ $error }}</li>
            @endforeach
        </ul>
    </div>
@endif

<form
    method="POST"
    action="{{ route('purchases.store') }}"
    id="purchase-form">

    @csrf

    <div class="row g-3">
        <div class="col-xl-7">
            <div class="card mb-3">
                <div class="card-header">
                    Pilih Produk
                </div>

                <div class="card-body">
                    <input
                        type="text"
                        id="product-search"
                        class="form-control mb-3"
                        placeholder="Cari SKU atau nama produk...">

                    <div
                        id="product-list"
                        class="row g-2">
                    </div>
                </div>
            </div>
        </div>

        <div class="col-xl-5">
            <div class="card mb-3">
                <div class="card-header">
                    Detail Pembelian
                </div>

                <div class="card-body">
                    <div class="mb-3">
                        <label class="form-label">
                            Supplier
                        </label>

                        <select
                            name="supplier_id"
                            class="form-select">

                            <option value="">
                                Tanpa Supplier
                            </option>

                            @foreach($suppliers as $supplier)
                                <option
                                    value="{{ $supplier->id }}"
                                    @selected(
                                        (string) old('supplier_id')
                                        ===
                                        (string) $supplier->id
                                    )>
                                    {{ $supplier->name }}
                                </option>
                            @endforeach
                        </select>
                    </div>

                    <div class="mb-3">
                        <label class="form-label">
                            Tanggal Pembelian
                        </label>

                        <input
                            type="datetime-local"
                            name="purchase_date"
                            value="{{ old(
                                'purchase_date',
                                now()->format('Y-m-d\TH:i')
                            ) }}"
                            class="form-control"
                            required>
                    </div>
                </div>
            </div>

            <div class="card mb-3">
                <div class="card-header">
                    Item Pembelian
                </div>

                <div class="card-body">
                    <div
                        id="cart-empty"
                        class="text-center text-muted py-4">
                        Belum ada produk.
                    </div>

                    <div
                        id="cart-list"
                        class="d-none">
                    </div>

                    <div id="hidden-items"></div>
                </div>
            </div>

            <div class="card">
                <div class="card-body">
                    <div class="d-flex justify-content-between mb-3">
                        <strong>Grand Total</strong>

                        <strong id="grand-total-text">
                            Rp 0
                        </strong>
                    </div>

                    <button
                        type="submit"
                        id="submit-purchase"
                        class="btn btn-success w-100"
                        disabled>
                        Simpan Pembelian
                    </button>
                </div>
            </div>
        </div>
    </div>
</form>

<script>
const products = @json(
    $products->map(function ($product) {
        return [
            'id' => $product->id,
            'sku' => $product->sku,
            'name' => $product->name,
            'purchase_price' => (float) $product->purchase_price,
            'stock' => $product->stock,
            'unit' => $product->unit?->symbol,
            'category' => $product->category?->name,
        ];
    })->values()
);

const cart = new Map();

const productSearch = document.getElementById('product-search');
const productList = document.getElementById('product-list');
const cartList = document.getElementById('cart-list');
const cartEmpty = document.getElementById('cart-empty');
const hiddenItems = document.getElementById('hidden-items');
const grandTotalText = document.getElementById('grand-total-text');
const submitButton = document.getElementById('submit-purchase');

function rupiah(value) {
    return new Intl.NumberFormat('id-ID', {
        style: 'currency',
        currency: 'IDR',
        maximumFractionDigits: 0,
    }).format(value || 0);
}

function escapeHtml(value) {
    return String(value ?? '')
        .replaceAll('&', '&amp;')
        .replaceAll('<', '&lt;')
        .replaceAll('>', '&gt;')
        .replaceAll('"', '&quot;')
        .replaceAll("'", '&#039;');
}

function renderProducts(keyword = '') {
    const search = keyword.trim().toLowerCase();

    const filtered = products.filter(product => {
        return product.name.toLowerCase().includes(search)
            || product.sku.toLowerCase().includes(search);
    });

    productList.innerHTML = '';

    if (filtered.length === 0) {
        productList.innerHTML = `
            <div class="col-12 text-muted">
                Produk tidak ditemukan.
            </div>
        `;

        return;
    }

    filtered.forEach(product => {
        const col = document.createElement('div');
        col.className = 'col-md-6';

        col.innerHTML = `
            <button
                type="button"
                class="btn btn-outline-secondary text-start w-100 h-100">
                <div class="fw-semibold">
                    ${escapeHtml(product.name)}
                </div>

                <div class="small text-muted">
                    ${escapeHtml(product.sku)}
                    ${product.category ? ' • ' + escapeHtml(product.category) : ''}
                </div>

                <div class="mt-2">
                    Harga beli terakhir:
                    ${rupiah(product.purchase_price)}
                </div>

                <div class="small">
                    Stok saat ini:
                    ${product.stock}
                    ${product.unit ? escapeHtml(product.unit) : ''}
                </div>
            </button>
        `;

        col.querySelector('button')
            .addEventListener('click', () => {
                addToCart(product.id);
            });

        productList.appendChild(col);
    });
}

function addToCart(productId) {
    const product = products.find(
        item => item.id === productId
    );

    if (! product) {
        return;
    }

    if (cart.has(productId)) {
        return;
    }

    cart.set(productId, {
        product,
        quantity: 1,
        price: product.purchase_price,
    });

    renderCart();
}

function removeFromCart(productId) {
    cart.delete(productId);
    renderCart();
}

function updateQuantity(productId, value) {
    const item = cart.get(productId);

    if (! item) {
        return;
    }

    let quantity = Number(value);

    if (! Number.isInteger(quantity) || quantity < 1) {
        quantity = 1;
    }

    item.quantity = quantity;

    renderCart();
}

function updatePrice(productId, value) {
    const item = cart.get(productId);

    if (! item) {
        return;
    }

    let price = Number(value);

    if (! Number.isFinite(price) || price < 0) {
        price = 0;
    }

    item.price = price;

    renderCart();
}

function getGrandTotal() {
    let total = 0;

    cart.forEach(item => {
        total += item.quantity * item.price;
    });

    return total;
}

function renderCart() {
    cartList.innerHTML = '';
    hiddenItems.innerHTML = '';

    if (cart.size === 0) {
        cartEmpty.classList.remove('d-none');
        cartList.classList.add('d-none');
        submitButton.disabled = true;
    } else {
        cartEmpty.classList.add('d-none');
        cartList.classList.remove('d-none');
        submitButton.disabled = false;
    }

    let index = 0;

    cart.forEach((item, productId) => {
        const row = document.createElement('div');

        row.className = 'border rounded p-2 mb-2';

        row.innerHTML = `
            <div class="d-flex justify-content-between gap-2">
                <div>
                    <div class="fw-semibold">
                        ${escapeHtml(item.product.name)}
                    </div>

                    <div class="small text-muted">
                        ${escapeHtml(item.product.sku)}
                    </div>
                </div>

                <button
                    type="button"
                    class="btn btn-sm btn-outline-danger remove-item">
                    Hapus
                </button>
            </div>

            <div class="row g-2 mt-1">
                <div class="col-5">
                    <label class="form-label small">
                        Qty
                    </label>

                    <input
                        type="number"
                        min="1"
                        step="1"
                        value="${item.quantity}"
                        class="form-control form-control-sm quantity-input">
                </div>

                <div class="col-7">
                    <label class="form-label small">
                        Harga Beli
                    </label>

                    <input
                        type="number"
                        min="0"
                        step="0.01"
                        value="${item.price}"
                        class="form-control form-control-sm price-input">
                </div>
            </div>

            <div class="text-end mt-2">
                <span class="small text-muted">
                    Subtotal:
                </span>

                <strong>
                    ${rupiah(item.quantity * item.price)}
                </strong>
            </div>
        `;

        row.querySelector('.remove-item')
            .addEventListener('click', () => {
                removeFromCart(productId);
            });

        row.querySelector('.quantity-input')
            .addEventListener('change', event => {
                updateQuantity(
                    productId,
                    event.target.value
                );
            });

        row.querySelector('.price-input')
            .addEventListener('change', event => {
                updatePrice(
                    productId,
                    event.target.value
                );
            });

        cartList.appendChild(row);

        const productInput = document.createElement('input');
        productInput.type = 'hidden';
        productInput.name = `items[${index}][product_id]`;
        productInput.value = item.product.id;

        const quantityInput = document.createElement('input');
        quantityInput.type = 'hidden';
        quantityInput.name = `items[${index}][quantity]`;
        quantityInput.value = item.quantity;

        const priceInput = document.createElement('input');
        priceInput.type = 'hidden';
        priceInput.name = `items[${index}][price]`;
        priceInput.value = item.price;

        hiddenItems.appendChild(productInput);
        hiddenItems.appendChild(quantityInput);
        hiddenItems.appendChild(priceInput);

        index++;
    });

    grandTotalText.textContent = rupiah(getGrandTotal());
}

productSearch.addEventListener('input', event => {
    renderProducts(event.target.value);
});

document.getElementById('purchase-form')
    .addEventListener('submit', event => {
        if (cart.size === 0) {
            event.preventDefault();
            alert('Item pembelian masih kosong.');
            return;
        }

        if (! confirm('Simpan transaksi pembelian ini?')) {
            event.preventDefault();
        }
    });

renderProducts();
renderCart();
</script>
@endsection

19. Keranjang Pembelian Berbeda dari POS

Pada POS, harga jual hanya ditampilkan.

Pada pembelian, harga beli dapat diubah karena supplier dapat memberikan harga yang berbeda pada setiap transaksi.

Setiap item pembelian memiliki:

product_id
quantity
price

Subtotal dihitung:

quantity × price

20. View Detail Pembelian

Buat:

resources/views/purchases/show.blade.php

Isi:

@extends('layouts.app')

@section('title', 'Detail Pembelian')

@section('content')
<div class="d-flex justify-content-between align-items-start mb-3">
    <div>
        <h1 class="h4 mb-1">
            Detail Pembelian
        </h1>

        <div class="text-muted">
            {{ $purchase->invoice_number }}
        </div>
    </div>

    <a
        href="{{ route('purchases.index') }}"
        class="btn btn-secondary">
        Kembali
    </a>
</div>

@if($errors->has('cancel'))
    <div class="alert alert-danger">
        {{ $errors->first('cancel') }}
    </div>
@endif

<div class="row g-3">
    <div class="col-lg-8">
        <div class="card">
            <div class="card-header">
                Item Pembelian
            </div>

            <div class="card-body">
                <div class="table-responsive">
                    <table class="table table-bordered align-middle mb-0">
                        <thead>
                            <tr>
                                <th>Produk</th>
                                <th class="text-end">Harga</th>
                                <th class="text-center">Qty</th>
                                <th class="text-end">Subtotal</th>
                            </tr>
                        </thead>

                        <tbody>
                            @foreach($purchase->details as $detail)
                                <tr>
                                    <td>
                                        {{ $detail->product?->name ?? 'Produk' }}

                                        @if($detail->product?->unit)
                                            <div class="small text-muted">
                                                {{ $detail->product->unit->symbol }}
                                            </div>
                                        @endif
                                    </td>

                                    <td class="text-end">
                                        Rp {{ number_format(
                                            (float) $detail->price,
                                            0,
                                            ',',
                                            '.'
                                        ) }}
                                    </td>

                                    <td class="text-center">
                                        {{ $detail->quantity }}
                                    </td>

                                    <td class="text-end">
                                        Rp {{ number_format(
                                            (float) $detail->subtotal,
                                            0,
                                            ',',
                                            '.'
                                        ) }}
                                    </td>
                                </tr>
                            @endforeach
                        </tbody>

                        <tfoot>
                            <tr>
                                <th
                                    colspan="3"
                                    class="text-end">
                                    Grand Total
                                </th>

                                <th class="text-end">
                                    Rp {{ number_format(
                                        (float) $purchase->grand_total,
                                        0,
                                        ',',
                                        '.'
                                    ) }}
                                </th>
                            </tr>
                        </tfoot>
                    </table>
                </div>
            </div>
        </div>
    </div>

    <div class="col-lg-4">
        <div class="card mb-3">
            <div class="card-header">
                Informasi Pembelian
            </div>

            <div class="card-body">
                <dl class="row mb-0">
                    <dt class="col-5">Invoice</dt>
                    <dd class="col-7">
                        {{ $purchase->invoice_number }}
                    </dd>

                    <dt class="col-5">Tanggal</dt>
                    <dd class="col-7">
                        {{ $purchase->purchase_date->format('d/m/Y H:i') }}
                    </dd>

                    <dt class="col-5">Supplier</dt>
                    <dd class="col-7">
                        {{ $purchase->supplier?->name ?? '-' }}
                    </dd>

                    <dt class="col-5">User</dt>
                    <dd class="col-7">
                        {{ $purchase->user?->name ?? '-' }}
                    </dd>

                    <dt class="col-5">Status</dt>
                    <dd class="col-7">
                        @if($purchase->status === 'received')
                            <span class="badge text-bg-success">
                                Diterima
                            </span>
                        @else
                            <span class="badge text-bg-danger">
                                Dibatalkan
                            </span>
                        @endif
                    </dd>
                </dl>
            </div>
        </div>

        @if($purchase->status === 'received')
            <div class="card border-danger">
                <div class="card-header text-bg-danger">
                    Batalkan Pembelian
                </div>

                <div class="card-body">
                    <p class="small">
                        Pembatalan akan mengurangi kembali stok
                        sesuai quantity pada transaksi ini.
                    </p>

                    <p class="small text-danger">
                        Pembatalan akan ditolak jika stok saat ini
                        sudah lebih kecil dari quantity pembelian
                        yang akan dibatalkan.
                    </p>

                    <form
                        method="POST"
                        action="{{ route('purchases.cancel', $purchase) }}"
                        onsubmit="return confirm('Yakin membatalkan pembelian ini?')">

                        @csrf

                        <div class="mb-3">
                            <label class="form-label">
                                Alasan
                            </label>

                            <textarea
                                name="cancel_notes"
                                rows="3"
                                class="form-control"
                                maxlength="500"></textarea>
                        </div>

                        <button
                            type="submit"
                            class="btn btn-danger">
                            Batalkan Pembelian
                        </button>
                    </form>
                </div>
            </div>
        @endif
    </div>
</div>
@endsection

21. Catatan cancel_notes pada Versi Ini

Form menyediakan:

cancel_notes

untuk alasan pembatalan.

Namun migration purchases pada Tahap 02 belum mempunyai kolom:

notes

Karena itu alasan pembatalan belum disimpan ke database pada versi struktur saat ini.

Field tersebut tetap divalidasi untuk mempersiapkan pengembangan berikutnya.

Jika ingin menyimpan alasan pembatalan, tambahkan kolom baru melalui migration baru.

Contoh:

php artisan make:migration add_notes_to_purchases_table --table=purchases

Jangan mengubah migration lama jika aplikasi sudah berjalan di production.

22. Proteksi Pembatalan Pembelian

Sebelum stok dikurangi, sistem memeriksa:

if ($product->stock < $detail->quantity)

Contoh:

quantity pembelian = 10
stok sekarang      = 4

Pembatalan ditolak.

Alasannya, sebagian barang sudah keluar dari stok.

23. Mengapa Pembatalan Pembelian Bisa Sulit?

Pembelian:

+10

Kemudian penjualan:

-8

Saldo:

2

Jika kita membatalkan pembelian:

-10

saldo menjadi negatif.

Sistem sederhana ini memilih aturan aman:

pembelian hanya boleh dibatalkan jika saldo stok sekarang masih cukup untuk mengurangi seluruh quantity pembelian.

Untuk sistem enterprise, pembatalan dapat menggunakan mekanisme retur supplier yang lebih kompleks.

24. Stock Movement Pembatalan

Saat purchase dibatalkan:

type = purchase_cancelled
quantity_in = 0
quantity_out = quantity
reference_type = purchase
reference_id = purchase id

Ini membuat histori stok tetap dapat ditelusuri.

25. Harga Beli Setelah Pembatalan

Pada versi tutorial ini, ketika pembelian dibatalkan, kita tidak otomatis mengembalikan products.purchase_price ke harga sebelumnya.

Mengapa?

Karena tabel produk hanya menyimpan harga beli terakhir, sedangkan untuk mencari harga valid sebelumnya diperlukan aturan tambahan.

Histori harga tetap tersedia pada:

purchase_details

Jika aplikasi memerlukan harga beli aktif yang benar-benar mengikuti transaksi terakhir non-cancelled, logic tambahan dapat dibuat kemudian.

Untuk sistem kasir sederhana, fokus utama kita adalah konsistensi stok.

26. Pengujian Pembelian Dasar

Sebelum pembelian:

Air Mineral stock = 20

Buat pembelian:

Air Mineral
qty = 10
harga = 2600

Setelah berhasil:

stock = 30
purchase_price = 2600

27. Memeriksa Purchase dengan Tinker

Jalankan:

php artisan tinker

Kemudian:

$purchase = App\Models\Purchase::latest('id')->first();

Cek:

$purchase->invoice_number;

Cek:

$purchase->status;

Hasil:

received

28. Memeriksa Purchase Details

$purchase->details;

atau:

$purchase->details()->count();

Pastikan item sesuai keranjang.

29. Memeriksa Stok

$product = App\Models\Product::where(
    'sku',
    'BRG-001'
)->first();

Kemudian:

$product->stock;

Nilai harus bertambah.

30. Memeriksa Harga Beli Terakhir

Cek:

$product->purchase_price;

Nilai harus sama dengan harga pembelian terbaru.

31. Memeriksa Stock Movement

$product->stockMovements()
    ->where('type', 'purchase')
    ->where('reference_id', $purchase->id)
    ->get();

Harus terdapat:

quantity_in > 0
quantity_out = 0

32. Pengujian Grand Total

Misalnya:

Produk A:
qty = 10
price = 2.000
subtotal = 20.000

Produk B:
qty = 5
price = 3.000
subtotal = 15.000

Grand total:

35.000

Server harus menghitung kembali nilai tersebut.

33. Pengujian Quantity 0

Manipulasi request:

quantity = 0

Server harus menolak karena:

'min:1'

34. Pengujian Harga Negatif

Kirim:

price = -1000

Server harus menolak karena:

'min:0'

35. Pengujian Produk Tidak Aktif

Jika produk dinonaktifkan setelah halaman pembelian dibuka tetapi sebelum transaksi disimpan, server tetap memeriksa:

if (! $product->is_active)

Transaksi ditolak.

36. Pengujian Pembatalan

Catat stok.

Batalkan pembelian yang masih memungkinkan.

Setelah berhasil:

status = cancelled

stok berkurang sesuai quantity pembelian.

37. Pengujian Pembatalan Ganda

Kirim pembatalan kedua.

Server harus menolak.

Stok tidak boleh berkurang dua kali.

38. Pengujian Pembatalan Ketika Stok Tidak Cukup

Contoh:

Pembelian = 10
Penjualan = 7
Stok tersisa dari keseluruhan = 3

Jika stok produk lebih kecil dari quantity detail pembelian:

3 < 10

cancel harus ditolak.

39. Mengapa Tidak Langsung Menghapus Purchase?

Jangan:

$purchase->delete();

Karena transaksi tersebut pernah mengubah stok.

Gunakan:

status = cancelled

dan buat movement kebalikan.

40. Mengapa Supplier Nullable?

Pada migration Tahap 02:

supplier_id

dibuat nullable.

Artinya pembelian dapat dibuat tanpa supplier.

Namun untuk aplikasi produksi, penggunaan supplier sebaiknya diwajibkan jika bisnis memang membutuhkan histori pemasok.

41. Jika Supplier Wajib

Ubah validation:

'supplier_id' => [
    'required',
    'exists:suppliers,id',
],

dan idealnya sesuaikan database melalui migration baru jika diperlukan.

42. Mengapa Tanggal Pembelian Bisa Diinput?

Transaksi dapat dicatat sesuai tanggal invoice supplier.

Misalnya barang diterima:

15 September

tetapi dimasukkan ke sistem:

16 September

Admin tetap dapat memasukkan tanggal transaksi sebenarnya.

43. Pengujian Akses Kasir

Login sebagai:

kasir@example.com

Coba buka:

/purchases

Akses harus ditolak karena route menggunakan:

role:admin

44. Pengujian Tanpa Login

Logout.

Buka:

/purchases

Pengguna harus diarahkan ke login.

45. Struktur File Setelah Tahap 07

Controller:

app/Http/Controllers/PurchaseController.php

Views:

resources/views/purchases/
├── create.blade.php
├── index.blade.php
└── show.blade.php

Routes:

purchases.index
purchases.create
purchases.store
purchases.show
purchases.cancel

46. Checklist Pengujian Tahap 07

Daftar pembelian:

[ ] index dapat dibuka admin
[ ] pencarian invoice bekerja
[ ] pencarian supplier bekerja
[ ] filter status bekerja
[ ] pagination bekerja

Create:

[ ] produk aktif tampil
[ ] produk dapat ditambahkan
[ ] quantity dapat diubah
[ ] harga beli dapat diubah
[ ] subtotal tampil
[ ] grand total tampil
[ ] supplier dapat dipilih
[ ] tanggal dapat dipilih

Server:

[ ] items minimal 1
[ ] product_id valid
[ ] quantity minimal 1
[ ] harga tidak negatif
[ ] produk tidak aktif ditolak
[ ] subtotal dihitung server
[ ] grand total dihitung server

Database:

[ ] purchase tersimpan
[ ] purchase_details tersimpan
[ ] stok bertambah
[ ] purchase_price diperbarui
[ ] stock_movements tercatat
[ ] invoice unik
[ ] user_id tersimpan

Cancel:

[ ] status menjadi cancelled
[ ] stok dikurangi kembali
[ ] stock movement purchase_cancelled dibuat
[ ] cancel ganda ditolak
[ ] cancel ditolak jika stok tidak cukup

47. Kesalahan Umum: Menambah Stock Tanpa Transaction

Jangan menjalankan:

create purchase
increment stock
create movement

secara terpisah tanpa database transaction.

Jika salah satu gagal, database dapat menjadi tidak konsisten.

48. Kesalahan Umum: Tidak Mengunci Produk

Pembelian dan penjualan dapat terjadi bersamaan.

Gunakan:

lockForUpdate()

di dalam database transaction.

49. Kesalahan Umum: Mempercayai Subtotal Browser

Jangan menyimpan:

subtotal
grand_total

yang dikirim browser.

Hitung ulang berdasarkan:

quantity × price

di server.

50. Kesalahan Umum: Harga Beli Lama Hilang

Harga pada products.purchase_price memang berubah.

Tetapi histori harga harus tetap tersimpan di:

purchase_details.price

Jangan mengandalkan produk untuk melihat harga transaksi lama.

51. Kesalahan Umum: Delete Purchase Setelah Stock Bertambah

Jangan delete fisik transaksi yang sudah memengaruhi stok.

Gunakan status dan reversal movement.

52. Kesalahan Umum: Cancel Membuat Stok Negatif

Selalu periksa:

$product->stock < $detail->quantity

sebelum mengurangi stok.

53. Kesalahan Umum: Hanya Mengecek Satu Produk Saat Cancel

Pembelian dapat memiliki banyak item.

Semua produk harus diperiksa lebih dulu sebelum melakukan pengurangan stok.

Pada controller kita:

loop pertama -> lock dan validasi semua produk
loop kedua   -> kurangi stok

Tujuannya agar tidak terjadi kasus produk pertama sudah dikurangi tetapi produk kedua ternyata tidak cukup.

54. Mengapa Validasi Semua Produk Dilakukan Dulu?

Contoh:

Produk A cukup stok
Produk B tidak cukup stok

Jika langsung mengurangi Produk A sebelum mengecek Produk B, kita mengandalkan rollback untuk memperbaikinya.

Rollback memang akan bekerja, tetapi memisahkan fase validasi dan perubahan membuat logic lebih mudah dipahami dan lebih aman.

55. Pengembangan Lanjutan

Pembelian dapat dikembangkan dengan:

nomor invoice supplier
jatuh tempo
status pembayaran
hutang supplier
retur pembelian
diskon per item
PPN
ongkir
batch
expired date
multi gudang

Fitur tersebut belum diperlukan untuk sistem kasir sederhana.

56. Hubungan dengan Kartu Stok

Setelah penjualan dan pembelian tersedia, kita sudah mempunyai movement:

initial
sale
sale_cancelled
purchase
purchase_cancelled

Tahap berikutnya akan menggunakan data tersebut untuk membuat:

kartu stok
filter histori stok
penyesuaian stok
stok masuk
stok keluar

57. Checkpoint Hasil

Setelah Tahap 07 selesai:

Login                         : selesai
Role admin/kasir              : selesai
Database                      : selesai
Model dan relasi              : selesai
Seeder                        : selesai
CRUD master                   : selesai
CRUD produk                   : selesai
POS penjualan                 : selesai
Pembatalan penjualan          : selesai
Pembelian                     : selesai
Penambahan stok               : selesai
Harga beli terakhir           : selesai
Stock movement pembelian      : selesai
Pembatalan pembelian          : selesai
Kartu stok                    : tahap berikutnya
Penyesuaian stok              : tahap berikutnya
Dashboard/laporan             : belum
Nota                          : belum
Testing/deploy                : belum

58. Ringkasan

Pada tahap ketujuh kita telah membuat transaksi pembelian dan penambahan stok.

Saat pembelian berhasil:

purchases dibuat
purchase_details dibuat
products.stock bertambah
products.purchase_price diperbarui
stock_movements dibuat

Proses penyimpanan menggunakan:

DB::transaction()

dan produk dikunci menggunakan:

lockForUpdate()

Pembatalan pembelian tidak menghapus transaksi.

Status diubah menjadi:

cancelled

dan stok dikurangi kembali.

Pembatalan ditolak jika stok saat ini lebih kecil dari quantity pembelian yang akan dibatalkan.

Tahap berikutnya kita akan membuat kartu stok dan penyesuaian stok agar administrator dapat melihat seluruh histori perubahan stok serta mencatat koreksi stok fisik secara terkontrol.

Lanjut: Kartu Stok dan Penyesuaian Stok

Komentar

Belum ada komentar.

Tulis Komentar

Komentar pengunjung diperiksa sebelum ditampilkan.