Membuat Transaksi Penjualan dan POS Laravel
Diterbitkan 15 September 2026
Pada tahap keenam kita akan membuat fitur utama dari aplikasi, yaitu transaksi penjualan atau Point of Sale (POS).
Tahap ini berbeda dari CRUD biasa karena satu transaksi menyentuh beberapa tabel sekaligus.
Ketika transaksi berhasil, aplikasi harus:
membuat header penjualan
membuat detail penjualan
mengambil harga produk dari database
memeriksa stok terbaru
mengurangi stok produk
mencatat stock movement
menghitung subtotal
menghitung diskon
menghitung grand total
memeriksa jumlah pembayaran
menghitung kembalian
Seluruh proses tersebut harus berhasil sebagai satu kesatuan.
Jika salah satu proses gagal, transaksi tidak boleh tersimpan setengah.
Karena itu kita akan menggunakan:
DB::transaction()
dan:
lockForUpdate()
Harga, subtotal, grand total, dan kembalian juga tidak dipercayai dari browser.
Browser hanya mengirim data yang memang perlu dimasukkan pengguna seperti:
product_id
quantity
discount
paid_amount
customer_id
notes
Harga produk akan diambil ulang dari database pada server.
Setelah tahap ini selesai, aplikasi sudah dapat digunakan untuk melakukan transaksi penjualan dasar.
Hasil yang Akan Dibuat
Pada tahap ini kita akan membuat:
SaleController
Route:
/sales
/sales/create
/sales/{sale}
/sales/{sale}/cancel
View:
resources/views/sales/index.blade.php
resources/views/sales/create.blade.php
resources/views/sales/show.blade.php
Fitur yang tersedia:
- daftar transaksi;
- halaman POS;
- pencarian produk;
- keranjang belanja;
- tambah dan kurangi quantity;
- hapus item dari keranjang;
- pilih pelanggan;
- diskon transaksi;
- pembayaran;
- perhitungan kembalian;
- validasi server;
- pengecekan stok;
- pengurangan stok;
- pencatatan stock movement;
- detail transaksi;
- pembatalan transaksi;
- pengembalian stok saat transaksi dibatalkan;
- proteksi pembatalan ganda.
Persiapan
Pastikan Tahap 01 sampai Tahap 05 sudah selesai.
Aplikasi harus sudah mempunyai:
users
customers
products
sales
sale_details
stock_movements
Produk juga harus mempunyai stok.
Produk dari seeder Tahap 03 sudah memiliki stok awal.
Jalankan aplikasi:
php artisan serve
Login sebagai:
admin@example.com
admin12345
atau:
kasir@example.com
kasir12345
1. Alur Transaksi Penjualan
Alur yang akan kita buat:
Kasir membuka POS
↓
Memilih produk
↓
Menentukan quantity
↓
Keranjang menghitung estimasi subtotal
↓
Kasir memasukkan diskon
↓
Kasir memasukkan uang pembayaran
↓
Form dikirim ke server
↓
Server memvalidasi request
↓
Server memuat ulang produk dari database
↓
Server mengunci baris produk
↓
Server memeriksa stok
↓
Server menghitung harga dan total kembali
↓
Server membuat sale
↓
Server membuat sale_details
↓
Server mengurangi products.stock
↓
Server membuat stock_movements
↓
Server menghitung kembalian
↓
Commit
↓
Tampilkan detail transaksi
JavaScript pada halaman POS hanya digunakan untuk membantu antarmuka.
Keputusan akhir tetap dilakukan server.
2. Mengapa Harga Tidak Dikirim sebagai Sumber Kebenaran?
Pengguna dapat mengubah HTML atau request melalui browser developer tools.
Misalnya harga asli:
Rp10.000
Jika browser mengirim:
price = 100
dan server mempercayainya, transaksi dapat dimanipulasi.
Karena itu server hanya menerima:
product_id
quantity
Kemudian harga dibaca:
$product->selling_price
dari database.
3. Mengapa Perlu DB::transaction()?
Satu penjualan dapat menghasilkan:
1 record sales
3 record sale_details
3 perubahan products.stock
3 record stock_movements
Jika detail kedua gagal, kita tidak ingin:
header sudah tersimpan
stok sebagian sudah berkurang
detail belum lengkap
Dengan database transaction, seluruh perubahan dapat dibatalkan ketika terjadi exception.
4. Mengapa Perlu lockForUpdate()?
Bayangkan stok produk:
1
Dua kasir melakukan transaksi pada waktu hampir bersamaan.
Tanpa lock, keduanya dapat membaca:
stock = 1
dan keduanya menganggap stok cukup.
Dengan:
lockForUpdate()
baris produk dikunci selama transaksi database berjalan.
Transaksi lain harus menunggu sampai lock dilepas.
Setelah transaksi pertama mengurangi stok, transaksi kedua akan membaca stok terbaru.
5. Membuat SaleController
Jalankan:
php artisan make:controller SaleController
File dibuat pada:
app/Http/Controllers/SaleController.php
6. Menambahkan Route Penjualan
Buka:
routes/web.php
Tambahkan import:
use App\Http\Controllers\SaleController;
Tambahkan route:
Route::middleware(['auth'])->group(function () {
Route::get(
'sales',
[SaleController::class, 'index']
)->name('sales.index');
Route::get(
'sales/create',
[SaleController::class, 'create']
)->name('sales.create');
Route::post(
'sales',
[SaleController::class, 'store']
)->name('sales.store');
Route::get(
'sales/{sale}',
[SaleController::class, 'show']
)->name('sales.show');
});
Route::middleware(['auth', 'role:admin'])->group(function () {
Route::post(
'sales/{sale}/cancel',
[SaleController::class, 'cancel']
)->name('sales.cancel');
});
Dengan struktur ini:
admin
kasir
dapat membuat transaksi dan melihat transaksi.
Pembatalan transaksi dibatasi untuk:
admin
Agar lebih aman, kasir tidak dapat membatalkan transaksi sendiri.
7. Menambahkan Menu Penjualan
Pada layout:
resources/views/layouts/app.blade.php
tambahkan:
<li class="nav-item">
<a
class="nav-link"
href="{{ route('sales.index') }}">
Penjualan
</a>
</li>
Tambahkan juga tombol cepat:
<a
href="{{ route('sales.create') }}"
class="btn btn-primary">
Transaksi Baru
</a>
8. Import yang Dibutuhkan SaleController
SaleController akan menggunakan:
use App\Models\Customer;
use App\Models\Product;
use App\Models\Sale;
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;
9. SaleController Lengkap
Buka:
app/Http/Controllers/SaleController.php
Isi:
<?php
namespace App\Http\Controllers;
use App\Models\Customer;
use App\Models\Product;
use App\Models\Sale;
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 SaleController extends Controller
{
public function index(Request $request): View
{
$search = trim((string) $request->get('q'));
$status = $request->get('status');
$sales = Sale::query()
->with(['user', 'customer'])
->when($search !== '', function ($query) use ($search) {
$query->where(function ($query) use ($search) {
$query
->where(
'invoice_number',
'like',
'%' . $search . '%'
)
->orWhereHas(
'customer',
function ($query) use ($search) {
$query->where(
'name',
'like',
'%' . $search . '%'
);
}
);
});
})
->when(
in_array($status, ['paid', 'cancelled'], true),
fn ($query) => $query->where('status', $status)
)
->latest('sale_date')
->paginate(15)
->withQueryString();
return view('sales.index', compact(
'sales',
'search',
'status'
));
}
public function create(): View
{
$products = Product::query()
->with(['category', 'unit'])
->where('is_active', true)
->where('stock', '>', 0)
->orderBy('name')
->get([
'id',
'category_id',
'unit_id',
'sku',
'name',
'selling_price',
'stock',
'minimum_stock',
]);
$customers = Customer::query()
->orderBy('name')
->get([
'id',
'name',
'phone',
]);
return view('sales.create', compact(
'products',
'customers'
));
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'customer_id' => [
'nullable',
'exists:customers,id',
],
'items' => [
'required',
'array',
'min:1',
],
'items.*.product_id' => [
'required',
'integer',
'exists:products,id',
],
'items.*.quantity' => [
'required',
'integer',
'min:1',
],
'discount' => [
'nullable',
'numeric',
'min:0',
],
'paid_amount' => [
'required',
'numeric',
'min:0',
],
'notes' => [
'nullable',
'string',
'max:1000',
],
]);
$items = collect($validated['items'])
->groupBy('product_id')
->map(function ($rows, $productId) {
return [
'product_id' => (int) $productId,
'quantity' => (int) $rows->sum('quantity'),
];
})
->values();
$discount = (float) ($validated['discount'] ?? 0);
$paidAmount = (float) $validated['paid_amount'];
$sale = DB::transaction(function () use (
$request,
$validated,
$items,
$discount,
$paidAmount
) {
$lockedProducts = [];
$subtotal = 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.",
]);
}
if ($product->stock < $item['quantity']) {
throw ValidationException::withMessages([
'items' => "Stok {$product->name} tidak mencukupi. Stok tersedia: {$product->stock}.",
]);
}
$price = (float) $product->selling_price;
$lineSubtotal = $price * $item['quantity'];
$subtotal += $lineSubtotal;
$lockedProducts[] = [
'model' => $product,
'quantity' => $item['quantity'],
'price' => $price,
'subtotal' => $lineSubtotal,
];
}
if ($discount > $subtotal) {
throw ValidationException::withMessages([
'discount' => 'Diskon tidak boleh lebih besar dari subtotal.',
]);
}
$grandTotal = $subtotal - $discount;
if ($paidAmount < $grandTotal) {
throw ValidationException::withMessages([
'paid_amount' => 'Jumlah pembayaran masih kurang.',
]);
}
$changeAmount = $paidAmount - $grandTotal;
$sale = Sale::create([
'invoice_number' => 'TMP-' . Str::uuid(),
'user_id' => $request->user()->id,
'customer_id' => $validated['customer_id'] ?? null,
'sale_date' => now(),
'subtotal' => $subtotal,
'discount' => $discount,
'grand_total' => $grandTotal,
'paid_amount' => $paidAmount,
'change_amount' => $changeAmount,
'status' => 'paid',
'notes' => $validated['notes'] ?? null,
]);
$sale->update([
'invoice_number' => 'INV-'
. now()->format('Ymd')
. '-'
. str_pad(
(string) $sale->id,
6,
'0',
STR_PAD_LEFT
),
]);
foreach ($lockedProducts as $item) {
$product = $item['model'];
$sale->details()->create([
'product_id' => $product->id,
'quantity' => $item['quantity'],
'price' => $item['price'],
'discount' => 0,
'subtotal' => $item['subtotal'],
]);
$product->decrement(
'stock',
$item['quantity']
);
$product->stockMovements()->create([
'type' => 'sale',
'quantity_in' => 0,
'quantity_out' => $item['quantity'],
'reference_type' => 'sale',
'reference_id' => $sale->id,
'notes' => 'Penjualan '
. $sale->invoice_number,
]);
}
return $sale;
});
return redirect()
->route('sales.show', $sale)
->with('success', 'Transaksi penjualan berhasil disimpan.');
}
public function show(Sale $sale): View
{
$sale->load([
'user',
'customer',
'details.product.unit',
]);
return view('sales.show', compact('sale'));
}
public function cancel(
Request $request,
Sale $sale
): RedirectResponse {
$validated = $request->validate([
'cancel_notes' => [
'nullable',
'string',
'max:500',
],
]);
DB::transaction(function () use (
$sale,
$validated
) {
$lockedSale = Sale::query()
->whereKey($sale->id)
->lockForUpdate()
->firstOrFail();
if ($lockedSale->status === 'cancelled') {
throw ValidationException::withMessages([
'cancel' => 'Transaksi ini sudah dibatalkan sebelumnya.',
]);
}
if ($lockedSale->status !== 'paid') {
throw ValidationException::withMessages([
'cancel' => 'Hanya transaksi berstatus paid yang dapat dibatalkan.',
]);
}
$lockedSale->load('details');
foreach ($lockedSale->details as $detail) {
$product = Product::query()
->whereKey($detail->product_id)
->lockForUpdate()
->firstOrFail();
$product->increment(
'stock',
$detail->quantity
);
$product->stockMovements()->create([
'type' => 'sale_cancelled',
'quantity_in' => $detail->quantity,
'quantity_out' => 0,
'reference_type' => 'sale',
'reference_id' => $lockedSale->id,
'notes' => 'Pembatalan penjualan '
. $lockedSale->invoice_number,
]);
}
$notes = trim(
(string) ($validated['cancel_notes'] ?? '')
);
$currentNotes = trim(
(string) $lockedSale->notes
);
$cancelText = $notes !== ''
? 'Pembatalan: ' . $notes
: 'Transaksi dibatalkan oleh admin.';
$lockedSale->update([
'status' => 'cancelled',
'notes' => trim(
$currentNotes
. ($currentNotes !== '' ? PHP_EOL : '')
. $cancelText
),
]);
});
return redirect()
->route('sales.show', $sale)
->with('success', 'Transaksi berhasil dibatalkan dan stok telah dikembalikan.');
}
}
10. Mengapa Item Dikelompokkan Berdasarkan product_id?
Browser seharusnya hanya mempunyai satu baris per produk.
Namun request dapat dimanipulasi.
Contoh:
product_id = 1, quantity = 2
product_id = 1, quantity = 3
Kita menggabungkannya:
collect($validated['items'])
->groupBy('product_id')
Hasil akhir:
product_id = 1
quantity = 5
Dengan demikian stok diperiksa sekali berdasarkan total quantity produk.
11. Mengunci Produk Sebelum Menghitung
Setiap produk diambil menggunakan:
Product::query()
->whereKey($item['product_id'])
->lockForUpdate()
->firstOrFail();
Lock hanya efektif di dalam database transaction.
Karena seluruh logic berada pada:
DB::transaction(...)
produk tetap terkunci sampai transaksi selesai atau rollback.
12. Memeriksa Status Produk di Server
Walaupun halaman POS hanya menampilkan produk aktif, request tetap dapat dimanipulasi.
Karena itu server memeriksa:
if (! $product->is_active)
Jika produk tidak aktif, transaksi ditolak.
13. Memeriksa Stok di Server
Server memeriksa:
if ($product->stock < $item['quantity'])
Halaman POS mungkin menampilkan stok 10.
Namun sebelum tombol simpan ditekan, kasir lain dapat menjual sebagian stok.
Karena itu pengecekan stok terbaru harus dilakukan di dalam transaction setelah row lock diperoleh.
14. Mengambil Harga dari Database
Harga:
$price = (float) $product->selling_price;
bukan berasal dari input browser.
Subtotal item:
$lineSubtotal = $price * $item['quantity'];
Subtotal transaksi:
$subtotal += $lineSubtotal;
15. Memvalidasi Diskon
Kita menerima diskon transaksi:
discount
Kemudian memastikan:
if ($discount > $subtotal)
Diskon tidak boleh membuat total negatif.
Grand total:
$grandTotal = $subtotal - $discount;
16. Memvalidasi Pembayaran
Jumlah yang dibayar harus:
>= grand_total
Pemeriksaan:
if ($paidAmount < $grandTotal)
Jika kurang, transaksi ditolak.
17. Menghitung Kembalian
Kembalian:
$changeAmount = $paidAmount - $grandTotal;
Contoh:
subtotal = 50.000
discount = 5.000
grand total = 45.000
dibayar = 50.000
kembalian = 5.000
18. Membuat Nomor Invoice
Pertama kita membuat nilai sementara:
'TMP-' . Str::uuid()
Setelah record sale memiliki ID, invoice diperbarui:
INV-YYYYMMDD-000001
Contoh:
INV-20260916-000025
Bagian belakang menggunakan ID sale sehingga unik pada database.
19. Membuat Sale Detail
Detail dibuat melalui relasi:
$sale->details()->create([
// ...
]);
Relasi otomatis mengisi:
sale_id
Setiap detail menyimpan snapshot:
product_id
quantity
price
discount
subtotal
20. Mengurangi Stok
Setelah detail berhasil dibuat:
$product->decrement(
'stock',
$item['quantity']
);
Contoh:
stok awal = 20
terjual = 3
stok akhir = 17
21. Mencatat Stock Movement
Setiap stok keluar dicatat:
$product->stockMovements()->create([
'type' => 'sale',
'quantity_in' => 0,
'quantity_out' => $item['quantity'],
'reference_type' => 'sale',
'reference_id' => $sale->id,
]);
Dengan demikian kita dapat mengetahui stok berkurang karena transaksi penjualan tertentu.
22. Membuat Folder View Penjualan
Buat:
resources/views/sales
Kemudian:
index.blade.php
create.blade.php
show.blade.php
23. View Index Penjualan
Buat:
resources/views/sales/index.blade.php
Isi:
@extends('layouts.app')
@section('title', 'Penjualan')
@section('content')
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="h4 mb-1">Penjualan</h1>
<p class="text-muted mb-0">
Daftar transaksi penjualan.
</p>
</div>
<a
href="{{ route('sales.create') }}"
class="btn btn-primary">
Transaksi Baru
</a>
</div>
<div class="card mb-3">
<div class="card-body">
<form
method="GET"
action="{{ route('sales.index') }}"
class="row g-2">
<div class="col-md-6">
<input
type="text"
name="q"
value="{{ $search }}"
class="form-control"
placeholder="Cari invoice atau pelanggan...">
</div>
<div class="col-md-3">
<select
name="status"
class="form-select">
<option value="">
Semua Status
</option>
<option
value="paid"
@selected($status === 'paid')}>
Dibayar
</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('sales.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>Kasir</th>
<th>Pelanggan</th>
<th class="text-end">Total</th>
<th>Status</th>
<th>Aksi</th>
</tr>
</thead>
<tbody>
@forelse($sales as $sale)
<tr>
<td>
{{ $sales->firstItem() + $loop->index }}
</td>
<td>
<code>{{ $sale->invoice_number }}</code>
</td>
<td>
{{ $sale->sale_date->format('d/m/Y H:i') }}
</td>
<td>
{{ $sale->user?->name ?? '-' }}
</td>
<td>
{{ $sale->customer?->name ?? 'Umum' }}
</td>
<td class="text-end">
Rp {{ number_format(
(float) $sale->grand_total,
0,
',',
'.'
) }}
</td>
<td>
@if($sale->status === 'paid')
<span class="badge text-bg-success">
Dibayar
</span>
@else
<span class="badge text-bg-danger">
Dibatalkan
</span>
@endif
</td>
<td>
<a
href="{{ route('sales.show', $sale) }}"
class="btn btn-sm btn-outline-primary">
Detail
</a>
</td>
</tr>
@empty
<tr>
<td
colspan="8"
class="text-center text-muted">
Belum ada transaksi penjualan.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{ $sales->links() }}
</div>
</div>
@endsection
24. View Create POS
Buat:
resources/views/sales/create.blade.php
Isi:
@extends('layouts.app')
@section('title', 'Transaksi Penjualan')
@section('content')
<div class="mb-3">
<h1 class="h4 mb-1">Transaksi Penjualan</h1>
<p class="text-muted mb-0">
Pilih produk, tentukan jumlah, lalu selesaikan pembayaran.
</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('sales.store') }}"
id="sale-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">
Keranjang
</div>
<div class="card-body">
<div
id="cart-empty"
class="text-muted text-center py-4">
Keranjang masih kosong.
</div>
<div
id="cart-list"
class="d-none">
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="mb-3">
<label class="form-label">
Pelanggan
</label>
<select
name="customer_id"
class="form-select">
<option value="">
Pelanggan Umum
</option>
@foreach($customers as $customer)
<option
value="{{ $customer->id }}"
@selected(
(string) old('customer_id')
===
(string) $customer->id
)>
{{ $customer->name }}
@if($customer->phone)
- {{ $customer->phone }}
@endif
</option>
@endforeach
</select>
</div>
<div class="mb-3">
<label class="form-label">
Diskon Transaksi
</label>
<input
type="number"
name="discount"
id="discount"
value="{{ old('discount', 0) }}"
min="0"
step="0.01"
class="form-control">
</div>
<div class="border rounded p-3 mb-3">
<div class="d-flex justify-content-between mb-2">
<span>Subtotal</span>
<strong id="subtotal-text">
Rp 0
</strong>
</div>
<div class="d-flex justify-content-between">
<span>Grand Total</span>
<strong id="grand-total-text">
Rp 0
</strong>
</div>
</div>
<div class="mb-3">
<label class="form-label">
Jumlah Dibayar
</label>
<input
type="number"
name="paid_amount"
id="paid-amount"
value="{{ old('paid_amount', 0) }}"
min="0"
step="0.01"
class="form-control"
required>
</div>
<div class="mb-3">
<label class="form-label">
Estimasi Kembalian
</label>
<input
type="text"
id="change-text"
value="Rp 0"
class="form-control"
disabled>
</div>
<div class="mb-3">
<label class="form-label">
Catatan
</label>
<textarea
name="notes"
rows="3"
class="form-control"
maxlength="1000">{{ old('notes') }}</textarea>
</div>
<div id="hidden-items"></div>
<button
type="submit"
id="submit-sale"
class="btn btn-success w-100"
disabled>
Simpan Transaksi
</button>
</div>
</div>
</div>
</div>
</form>
<script>
const products = @json(
$products->map(function ($product) {
return [
'id' => $product->id,
'sku' => $product->sku,
'name' => $product->name,
'price' => (float) $product->selling_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 discountInput = document.getElementById('discount');
const paidAmountInput = document.getElementById('paid-amount');
const subtotalText = document.getElementById('subtotal-text');
const grandTotalText = document.getElementById('grand-total-text');
const changeText = document.getElementById('change-text');
const submitSaleButton = document.getElementById('submit-sale');
function rupiah(value) {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
maximumFractionDigits: 0,
}).format(value || 0);
}
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">
<div class="text-muted">
Produk tidak ditemukan.
</div>
</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"
data-product-id="${product.id}">
<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">
${rupiah(product.price)}
</div>
<div class="small">
Stok: ${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;
}
const existing = cart.get(productId);
if (existing) {
if (existing.quantity >= product.stock) {
alert('Quantity tidak dapat melebihi stok.');
return;
}
existing.quantity++;
} else {
cart.set(productId, {
product,
quantity: 1,
});
}
renderCart();
}
function updateQuantity(productId, quantity) {
const item = cart.get(productId);
if (! item) {
return;
}
let nextQuantity = Number(quantity);
if (! Number.isInteger(nextQuantity)) {
nextQuantity = 1;
}
if (nextQuantity < 1) {
nextQuantity = 1;
}
if (nextQuantity > item.product.stock) {
nextQuantity = item.product.stock;
alert('Quantity disesuaikan dengan stok tersedia.');
}
item.quantity = nextQuantity;
renderCart();
}
function removeFromCart(productId) {
cart.delete(productId);
renderCart();
}
function getSubtotal() {
let subtotal = 0;
cart.forEach(item => {
subtotal += item.product.price * item.quantity;
});
return subtotal;
}
function getDiscount() {
const value = Number(discountInput.value || 0);
if (value < 0) {
return 0;
}
return value;
}
function renderCart() {
cartList.innerHTML = '';
hiddenItems.innerHTML = '';
if (cart.size === 0) {
cartEmpty.classList.remove('d-none');
cartList.classList.add('d-none');
submitSaleButton.disabled = true;
} else {
cartEmpty.classList.add('d-none');
cartList.classList.remove('d-none');
submitSaleButton.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">
${rupiah(item.product.price)}
</div>
</div>
<button
type="button"
class="btn btn-sm btn-outline-danger remove-item">
Hapus
</button>
</div>
<div class="row g-2 mt-1 align-items-end">
<div class="col-5">
<label class="form-label small">
Qty
</label>
<input
type="number"
class="form-control form-control-sm quantity-input"
min="1"
max="${item.product.stock}"
value="${item.quantity}">
</div>
<div class="col-7 text-end">
<div class="small text-muted">
Subtotal
</div>
<strong>
${rupiah(
item.product.price * item.quantity
)}
</strong>
</div>
</div>
`;
row.querySelector('.remove-item')
.addEventListener('click', () => {
removeFromCart(productId);
});
row.querySelector('.quantity-input')
.addEventListener('change', event => {
updateQuantity(
productId,
Number(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;
hiddenItems.appendChild(productInput);
hiddenItems.appendChild(quantityInput);
index++;
});
updateSummary();
}
function updateSummary() {
const subtotal = getSubtotal();
const discount = Math.min(
getDiscount(),
subtotal
);
const grandTotal = Math.max(
subtotal - discount,
0
);
const paidAmount = Number(
paidAmountInput.value || 0
);
const change = Math.max(
paidAmount - grandTotal,
0
);
subtotalText.textContent = rupiah(subtotal);
grandTotalText.textContent = rupiah(grandTotal);
changeText.value = rupiah(change);
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
productSearch.addEventListener('input', event => {
renderProducts(event.target.value);
});
discountInput.addEventListener('input', updateSummary);
paidAmountInput.addEventListener('input', updateSummary);
document.getElementById('sale-form')
.addEventListener('submit', event => {
if (cart.size === 0) {
event.preventDefault();
alert('Keranjang masih kosong.');
return;
}
if (! confirm('Simpan transaksi penjualan ini?')) {
event.preventDefault();
}
});
renderProducts();
renderCart();
</script>
@endsection
25. Fungsi JavaScript pada POS
JavaScript melakukan:
render produk
pencarian produk
menambah item
mengubah quantity
menghapus item
menghitung estimasi subtotal
menghitung estimasi grand total
menghitung estimasi kembalian
membuat hidden input
Namun hasil JavaScript bukan sumber kebenaran final.
Server tetap menghitung ulang seluruh transaksi.
26. Data Produk yang Dikirim ke JavaScript
Kita hanya memasukkan data yang dibutuhkan antarmuka:
id
sku
name
price
stock
unit
category
Data tersebut digunakan untuk display.
Walaupun price tersedia di JavaScript, server tidak menerima harga tersebut sebagai kebenaran.
27. Hidden Input Keranjang
Ketika ada produk di keranjang, JavaScript membuat:
<input
type="hidden"
name="items[0][product_id]"
value="1">
<input
type="hidden"
name="items[0][quantity]"
value="2">
Untuk item kedua:
items[1][product_id]
items[1][quantity]
Laravel menerima:
[
'items' => [
[
'product_id' => 1,
'quantity' => 2,
],
[
'product_id' => 2,
'quantity' => 1,
],
],
]
28. Estimasi Bukan Total Final
Teks pada browser:
Subtotal
Grand Total
Kembalian
bersifat estimasi untuk kasir.
Nilai final dihitung ulang pada:
SaleController::store()
Jika harga berubah sebelum transaksi disimpan, server memakai harga database terbaru.
29. View Detail Penjualan
Buat:
resources/views/sales/show.blade.php
Isi:
@extends('layouts.app')
@section('title', 'Detail Penjualan')
@section('content')
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h1 class="h4 mb-1">
Detail Penjualan
</h1>
<div class="text-muted">
{{ $sale->invoice_number }}
</div>
</div>
<a
href="{{ route('sales.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 Transaksi
</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($sale->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>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card mb-3">
<div class="card-header">
Informasi Transaksi
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-5">Invoice</dt>
<dd class="col-7">
{{ $sale->invoice_number }}
</dd>
<dt class="col-5">Tanggal</dt>
<dd class="col-7">
{{ $sale->sale_date->format('d/m/Y H:i') }}
</dd>
<dt class="col-5">Kasir</dt>
<dd class="col-7">
{{ $sale->user?->name ?? '-' }}
</dd>
<dt class="col-5">Pelanggan</dt>
<dd class="col-7">
{{ $sale->customer?->name ?? 'Umum' }}
</dd>
<dt class="col-5">Status</dt>
<dd class="col-7">
@if($sale->status === 'paid')
<span class="badge text-bg-success">
Dibayar
</span>
@else
<span class="badge text-bg-danger">
Dibatalkan
</span>
@endif
</dd>
</dl>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
Ringkasan Pembayaran
</div>
<div class="card-body">
<div class="d-flex justify-content-between mb-2">
<span>Subtotal</span>
<span>
Rp {{ number_format(
(float) $sale->subtotal,
0,
',',
'.'
) }}
</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span>Diskon</span>
<span>
Rp {{ number_format(
(float) $sale->discount,
0,
',',
'.'
) }}
</span>
</div>
<hr>
<div class="d-flex justify-content-between mb-2">
<strong>Grand Total</strong>
<strong>
Rp {{ number_format(
(float) $sale->grand_total,
0,
',',
'.'
) }}
</strong>
</div>
<div class="d-flex justify-content-between mb-2">
<span>Dibayar</span>
<span>
Rp {{ number_format(
(float) $sale->paid_amount,
0,
',',
'.'
) }}
</span>
</div>
<div class="d-flex justify-content-between">
<span>Kembalian</span>
<span>
Rp {{ number_format(
(float) $sale->change_amount,
0,
',',
'.'
) }}
</span>
</div>
</div>
</div>
@if($sale->notes)
<div class="card mb-3">
<div class="card-header">
Catatan
</div>
<div class="card-body">
{!! nl2br(e($sale->notes)) !!}
</div>
</div>
@endif
@if(
auth()->user()->role === 'admin'
&& $sale->status === 'paid'
)
<div class="card border-danger">
<div class="card-header text-bg-danger">
Batalkan Transaksi
</div>
<div class="card-body">
<p class="small">
Pembatalan akan mengembalikan stok setiap produk.
</p>
<form
method="POST"
action="{{ route('sales.cancel', $sale) }}"
onsubmit="return confirm('Yakin membatalkan transaksi ini? Stok akan dikembalikan.')">
@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 Transaksi
</button>
</form>
</div>
</div>
@endif
</div>
</div>
@endsection
30. Pembatalan Tidak Menghapus Transaksi
Ketika transaksi dibatalkan, kita tidak melakukan:
$sale->delete();
Kita mengubah:
status = cancelled
Tujuannya agar histori tetap tersedia.
Laporan nantinya hanya menghitung transaksi:
status = paid
31. Pembatalan Mengembalikan Stok
Setiap detail transaksi diproses.
Contoh transaksi:
Air Mineral x 2
Kopi Sachet x 3
Saat dibatalkan:
Air Mineral +2
Kopi Sachet +3
Selain mengubah products.stock, sistem membuat stock movement:
type = sale_cancelled
quantity_in = quantity transaksi
32. Proteksi Pembatalan Ganda
Sebelum mengembalikan stok:
if ($lockedSale->status === 'cancelled')
Jika transaksi sudah dibatalkan, request berikutnya ditolak.
Ini penting.
Tanpa proteksi tersebut:
cancel pertama -> stok +2
cancel kedua -> stok +2 lagi
dan saldo menjadi salah.
33. Mengunci Sale Saat Pembatalan
Pembatalan menggunakan:
lockForUpdate()
pada sale.
Tujuannya agar dua request pembatalan yang datang bersamaan tidak memproses transaksi yang sama dua kali.
34. Mengunci Product Saat Pembatalan
Setiap product juga dikunci:
Product::query()
->whereKey(...)
->lockForUpdate()
Kemudian stok dikembalikan.
35. Memastikan Database Mendukung Transaction
Untuk MySQL modern, gunakan storage engine yang mendukung transaction seperti:
InnoDB
Laravel/MySQL pada konfigurasi umum menggunakan engine yang sesuai.
Jika database memakai engine tanpa dukungan transaction, perilaku rollback tidak akan sesuai kebutuhan.
36. Pengujian Transaksi Dasar
Sebelum transaksi, periksa stok.
Contoh:
Air Mineral = 20
Buat transaksi:
Air Mineral x 2
Setelah berhasil:
Air Mineral = 18
37. Memeriksa Sale dengan Tinker
Jalankan:
php artisan tinker
Kemudian:
$sale = App\Models\Sale::latest('id')->first();
Cek:
$sale->invoice_number;
Contoh:
INV-20260916-000001
Cek status:
$sale->status;
Hasil:
paid
38. Memeriksa Detail Penjualan
Di Tinker:
$sale->details;
Pastikan jumlah detail sesuai item di keranjang.
Contoh:
$sale->details()->count();
Jika transaksi mempunyai 2 produk:
2
39. Memeriksa Stok Setelah Penjualan
Ambil produk:
$product = App\Models\Product::where(
'sku',
'BRG-001'
)->first();
Cek:
$product->stock;
Nilai harus berkurang sesuai penjualan.
40. Memeriksa Stock Movement
Cek:
$product->stockMovements()
->where('reference_type', 'sale')
->where('reference_id', $sale->id)
->get();
Harus ada record:
type = sale
quantity_out > 0
41. Memeriksa Konsistensi Total
Cek:
$detailTotal = $sale->details()->sum('subtotal');
Kemudian:
$detailTotal;
harus sesuai:
$sale->subtotal;
Jika tidak ada diskon detail, subtotal header merupakan jumlah subtotal seluruh detail.
42. Pengujian Pembayaran Kurang
Misalnya:
grand total = 20.000
dibayar = 10.000
Server harus menolak transaksi.
Pesan:
Jumlah pembayaran masih kurang.
Tidak boleh ada:
sale baru
sale detail baru
pengurangan stok
stock movement baru
43. Pengujian Diskon Lebih Besar dari Subtotal
Contoh:
subtotal = 20.000
discount = 30.000
Transaksi harus ditolak.
Grand total tidak boleh negatif.
44. Pengujian Stok Tidak Cukup
Misalnya stok:
3
Kirim request dengan:
quantity = 10
Server harus menolak meskipun browser dimanipulasi.
Pesan menyebut stok tersedia.
45. Pengujian Produk Tidak Aktif
Nonaktifkan produk pada CRUD.
Jika seseorang memanipulasi request untuk mengirim ID produk tersebut, server harus menolak.
46. Pengujian Harga Browser Dimanipulasi
Form kita memang tidak mengirim field:
price
Tetapi pengguna dapat menambahkan field sendiri.
Misalnya:
price = 1
Controller tidak pernah membaca nilai tersebut.
Harga tetap diambil:
$product->selling_price
47. Pengujian Subtotal Browser Dimanipulasi
Jika pengguna mengirim:
subtotal = 1
controller mengabaikannya karena tidak ada pada validation array.
Subtotal dihitung server.
48. Pengujian Grand Total Browser Dimanipulasi
Sama seperti subtotal.
Field:
grand_total
tidak digunakan dari request.
49. Pengujian Duplikat Produk pada Request
Kirim dua item:
product_id = 1, quantity = 2
product_id = 1, quantity = 3
Server menggabungkan menjadi:
quantity = 5
Kemudian stok diperiksa terhadap 5.
50. Pengujian Pembatalan
Ambil satu transaksi paid.
Catat stok produk.
Klik:
Batalkan Transaksi
Setelah berhasil:
status = cancelled
Stok produk harus kembali.
51. Memeriksa Movement Pembatalan
Cek:
$product->stockMovements()
->where('type', 'sale_cancelled')
->where('reference_id', $sale->id)
->get();
Harus ada record stok masuk.
52. Pengujian Pembatalan Kedua Kali
Setelah transaksi sudah cancelled, coba kirim request cancel lagi.
Controller harus menolak.
Stok tidak boleh bertambah lagi.
53. Pengujian Hak Akses Pembatalan
Login sebagai:
kasir@example.com
Kasir dapat:
membuka POS
membuat transaksi
melihat detail
Kasir tidak mendapatkan tombol pembatalan.
Route cancel juga menggunakan:
role:admin
Jadi menyembunyikan tombol saja bukan satu-satunya proteksi.
54. Mengapa Proteksi Harus Ada pada Route?
HTML dapat dimanipulasi.
Walaupun tombol tidak terlihat, pengguna tetap dapat membuat request manual.
Karena itu pembatasan akses harus diterapkan di server:
role:admin
55. Daftar Penjualan dan Cancelled
Transaksi cancelled tetap tampil pada daftar.
Ini disengaja untuk audit sederhana.
Status ditampilkan:
Dibayar
Dibatalkan
56. Laporan Harus Mengabaikan Cancelled
Pada tahap laporan nanti query penjualan akan menggunakan:
->where('status', 'paid')
Transaksi cancelled tidak termasuk pendapatan.
57. Produk Habis Tidak Ditampilkan pada POS
Method create() menggunakan:
->where('stock', '>', 0)
Produk dengan stok 0 tidak masuk daftar.
Namun server tetap memeriksa stok saat penyimpanan karena stok dapat berubah setelah halaman dibuka.
58. Produk Tidak Aktif Tidak Ditampilkan pada POS
Query:
->where('is_active', true)
mencegah produk tidak aktif tampil.
59. Refresh Halaman POS Setelah Transaksi
Setelah transaksi berhasil, user diarahkan ke:
sales.show
Jika ingin membuat transaksi berikutnya, klik:
Transaksi Baru
Halaman baru akan mengambil stok terbaru.
60. Mengapa Tidak Menyimpan Keranjang di Database?
Untuk tutorial dasar ini keranjang hanya hidup pada browser sebelum transaksi disimpan.
Keuntungannya:
- implementasi lebih sederhana;
- tidak perlu tabel cart;
- tidak meninggalkan keranjang kosong;
- transaksi baru dibuat saat checkout.
Jika aplikasi membutuhkan draft transaksi, hold bill, atau multi-terminal advanced POS, tabel keranjang/draft dapat ditambahkan kemudian.
61. Mengapa Tidak Menggunakan Session Cart?
Session juga dapat digunakan.
Namun untuk tutorial ini keranjang dikelola pada JavaScript karena interaksi quantity lebih cepat dan mudah dipahami secara visual.
Server tetap tidak mempercayai nilai harga dari JavaScript.
62. Perhitungan Uang dan float pada Tutorial
Database menggunakan:
decimal(15,2)
Pada contoh controller, nilai dikonversi menjadi float untuk perhitungan sederhana.
Untuk aplikasi finansial dengan kebutuhan presisi lebih ketat, gunakan representasi integer unit terkecil atau library decimal/money yang sesuai.
Untuk sistem kasir sederhana dengan rupiah tanpa pecahan dalam contoh, pendekatan ini cukup mudah dipahami.
63. Menambahkan Validasi Diskon Integer jika Hanya Rupiah Bulat
Jika bisnis tidak menggunakan pecahan rupiah, aturan:
'numeric'
dapat diubah menjadi:
'integer'
Begitu juga pembayaran.
Namun struktur database tutorial tetap mendukung dua angka desimal.
64. Struktur File Setelah Tahap 06
Controller:
app/Http/Controllers/SaleController.php
Views:
resources/views/sales/
├── create.blade.php
├── index.blade.php
└── show.blade.php
Routes:
sales.index
sales.create
sales.store
sales.show
sales.cancel
65. Checklist Pengujian Tahap 06
Pastikan:
[ ] halaman penjualan dapat dibuka
[ ] POS menampilkan produk aktif
[ ] produk stok 0 tidak tampil
[ ] pencarian produk bekerja
[ ] item dapat masuk keranjang
[ ] quantity dapat diubah
[ ] quantity browser dibatasi stok
[ ] item dapat dihapus dari keranjang
[ ] pelanggan dapat dipilih
[ ] diskon dapat diisi
[ ] estimasi subtotal tampil
[ ] estimasi grand total tampil
[ ] estimasi kembalian tampil
Server:
[ ] items wajib minimal 1
[ ] product_id harus valid
[ ] quantity minimal 1
[ ] produk tidak aktif ditolak
[ ] stok kurang ditolak
[ ] harga diambil dari database
[ ] subtotal dihitung server
[ ] diskon > subtotal ditolak
[ ] pembayaran kurang ditolak
[ ] kembalian dihitung server
Database:
[ ] sale tersimpan
[ ] sale_details tersimpan
[ ] stok berkurang
[ ] stock_movements tercatat
[ ] invoice unik
[ ] sale menyimpan user_id kasir
Pembatalan:
[ ] hanya admin dapat cancel
[ ] status berubah cancelled
[ ] stok dikembalikan
[ ] movement sale_cancelled dibuat
[ ] cancel kedua ditolak
66. Kesalahan Umum: Keranjang Kosong tetapi Request Masuk
Server menggunakan:
'items' => [
'required',
'array',
'min:1',
],
Jadi walaupun JavaScript dilewati, request kosong ditolak.
67. Kesalahan Umum: Harga Dipercaya dari Hidden Input
Jangan membuat sumber kebenaran:
<input name="items[0][price]">
lalu langsung menggunakannya.
Harga selalu ambil dari:
$product->selling_price
68. Kesalahan Umum: Stok Dicek Sebelum Transaction Saja
Pengecekan stok harus berada di dalam:
DB::transaction()
dan setelah:
lockForUpdate()
Jika hanya dicek sebelum transaction, masih ada kemungkinan race condition.
69. Kesalahan Umum: Tidak Mengembalikan Stok Saat Cancel
Mengubah status menjadi cancelled saja tidak cukup.
Jika transaksi awal sudah mengurangi stok, cancel harus melakukan:
stock + quantity
dan membuat histori.
70. Kesalahan Umum: Cancel Menghapus Sale
Jangan:
$sale->delete();
untuk pembatalan biasa.
Gunakan status agar histori transaksi tetap tersedia.
71. Kesalahan Umum: Cancel Bisa Dua Kali
Pastikan status dicek sebelum stok dikembalikan.
Jika tidak, stok dapat bertambah berkali-kali.
72. Kesalahan Umum: Hanya Menyembunyikan Tombol Cancel
UI bukan proteksi keamanan.
Route juga harus dibatasi:
role:admin
73. Kesalahan Umum: Sale Detail Tidak Menyimpan Harga
Harga harus disimpan pada sale_details.
Jika harga produk berubah besok, transaksi lama tetap harus menunjukkan harga saat transaksi dibuat.
74. Kesalahan Umum: Laporan Menghitung Cancelled
Nanti gunakan:
where('status', 'paid')
untuk laporan pendapatan.
75. Checkpoint Hasil
Setelah tahap ini:
Login : selesai
Role admin/kasir : selesai
Database : selesai
Model dan relasi : selesai
Seeder : selesai
CRUD master : selesai
CRUD produk : selesai
POS penjualan : selesai
Keranjang : selesai
Pembayaran : selesai
Kembalian : selesai
Pengurangan stok : selesai
Stock movement penjualan : selesai
Detail transaksi : selesai
Pembatalan penjualan : selesai
Pengembalian stok : selesai
Pembelian : tahap berikutnya
Kartu stok : belum
Dashboard/laporan : belum
Nota : belum
Testing/deploy : belum
76. Ringkasan
Pada tahap keenam kita telah membuat transaksi penjualan lengkap.
Halaman POS dapat:
mencari produk
menambah ke keranjang
mengubah quantity
memilih pelanggan
memberikan diskon
menerima pembayaran
menampilkan estimasi kembalian
Server melakukan ulang seluruh pemeriksaan penting:
produk aktif
stok tersedia
harga database
subtotal
diskon
grand total
pembayaran
kembalian
Penyimpanan menggunakan:
DB::transaction()
dan produk dikunci menggunakan:
lockForUpdate()
Saat transaksi berhasil:
sales dibuat
sale_details dibuat
stok berkurang
stock_movements dibuat
Pembatalan tidak menghapus transaksi.
Status diubah menjadi:
cancelled
dan stok dikembalikan dengan histori:
sale_cancelled
Tahap berikutnya kita akan membuat transaksi pembelian dan penambahan stok.
Pembelian akan menggunakan prinsip yang sama:
database transaction
server-side calculation
stock update
stock movement
detail transaksi





Komentar
Tulis Komentar