Cara Membuat Nomor Antrean Harian yang Aman dari Duplikat di Laravel (Studi Kasus Aplikasi Kasir)
Di kedai kopi, pelanggan dipanggil dengan nomor antrean seperti A-012. Nomor ini harus berurutan, mulai lagi dari 1 setiap hari, dan yang paling penting tidak boleh dobel. Kalau dua kasir menekan tombol bayar pada detik yang sama lalu keduanya mendapat A-012, pesanan bisa tertukar.
Di tutorial ini kita membuat nomor antrean harian yang aman di Laravel, lengkap dengan cara menghitung total pesanan di server. Contohnya diambil dari aplikasi Kopi POS (Kasir Kedai Kopi + Layar Barista) yang bisa kamu download gratis di Papicode (link di akhir artikel).
1. Masalahnya: "ambil angka terbesar lalu tambah satu"
Cara yang paling sering ditemui:
$nomor = Order::whereDate('created_at', today())->max('nomor') + 1; // JANGAN begini
Order::create(['nomor' => $nomor, ...]);
Bayangkan kasir A dan kasir B menjalankan baris pertama secara bersamaan. Keduanya membaca angka terbesar 11, keduanya menghitung 12, lalu keduanya menyimpan nomor 12. Kode seperti ini aman ketika dicoba sendirian, tetapi gagal di jam ramai.
2. Simpan tanggal sebagai kolom + UNIQUE
Langkah pertama adalah membuat database menolak nomor ganda. Tambahkan kolom tanggal dan indeks unik gabungan:
Schema::create('orders', function (Blueprint $t) {
$t->id();
$t->date('tanggal');
$t->unsignedSmallInteger('nomor'); // nomor antrean harian
$t->enum('tipe', ['dine_in', 'takeaway']);
$t->unsignedInteger('total');
// ...
$t->timestamps();
$t->unique(['tanggal', 'nomor']); // satu nomor hanya sekali per hari
});
Kenapa tidak memakai DATE(created_at)? Indeks unik tidak bisa dibuat dari ekspresi seperti itu di semua versi MySQL. Kolom tanggal tersendiri juga membuat query laporan harian jauh lebih cepat.
Dengan indeks ini, data ganda tidak mungkin tersimpan. Request yang kalah balapan akan mendapat error duplicate entry (kode 1062). Langkah berikutnya adalah membuat error itu sangat jarang terjadi dan menanganinya dengan rapi.
3. Kunci baris dengan lockForUpdate
Di dalam transaksi, lockForUpdate() membuat request kedua menunggu sampai request pertama selesai (commit):
use Illuminate\Support\Facades\DB;
$order = DB::transaction(function () use ($data, $items) {
$nomor = (int) Order::where('tanggal', today())->lockForUpdate()->max('nomor') + 1;
$order = Order::create($data + ['tanggal' => today(), 'nomor' => $nomor]);
$order->items()->createMany($items);
return $order;
});
Query di atas menghasilkan SELECT MAX(nomor) ... FOR UPDATE. Pada InnoDB, perintah ini mengunci baris-baris hari itu (termasuk celah setelahnya). Kasir B baru bisa membaca angka terbesar setelah kasir A selesai menyimpan nomor 12, sehingga kasir B mendapat 13.
4. Tetap siapkan rencana cadangan
Ada satu kasus yang tidak bisa ditangani kunci baris: pesanan pertama di hari itu, ketika belum ada satu baris pun untuk dikunci. Untuk berjaga-jaga, ulangi transaksi saat terjadi error 1062:
use Illuminate\Database\QueryException;
private function simpanDenganNomor(callable $buat, array $items): Order
{
for ($coba = 0; $coba < 3; $coba++) {
try {
return DB::transaction(function () use ($buat, $items) {
$nomor = (int) Order::where('tanggal', today())->lockForUpdate()->max('nomor') + 1;
$order = $buat($nomor);
$order->items()->createMany($items);
return $order;
});
} catch (QueryException $e) {
// 1062 = duplicate entry: ada yang lebih dulu mengambil nomor ini, coba lagi
if ($coba === 2 || ($e->errorInfo[1] ?? ) !== ) ;
}
}
}
Hasilnya kita punya tiga lapis pengaman:
lockForUpdate()membuat request mengantre dengan tertib.- Indeks
UNIQUEmenolak data ganda apa pun yang terjadi. - Perulangan mencoba lagi jika masih terjadi tabrakan.
Di aplikasi aslinya, 8 pesanan yang dikirim bersamaan mendapat nomor B-018 sampai B-025 tanpa satu pun yang dobel.
5. Format nomor yang enak dipanggil
Simpan nomor sebagai angka, lalu bentuk labelnya di model. Dengan begitu pengurutan tetap benar (10 setelah 9, bukan "10" sebelum "9"):
class Order extends Model
{
public function label(): string
{
// A untuk dine in, B untuk takeaway, contoh A-012
return ($this->tipe === 'takeaway' ? 'B' : 'A') . '-' . str_pad((string) $this->nomor, 3, '0', STR_PAD_LEFT);
}
}
6. Jangan percaya total dari browser
Nomor antrean sudah aman, sekarang totalnya. Dari halaman kasir cukup kirim ID menu, jumlah, dan ID opsi yang dipilih. Harga dihitung ulang di server:
$menu = Menu::with('opsiGrup.opsi')->whereIn('id', collect($d['items'])->pluck('menu_id'))->get()->keyBy('id');
$subtotal = 0;
foreach ($d['items'] as $it) {
$m = $menu[$it['menu_id']] ?? null;
if (!$m || !$m->tersedia) {
throw ValidationException::withMessages(['items' => 'Menu sedang tidak tersedia.']);
}
$dipilih = collect($it['opsi'] ?? []);
$tambahan = 0;
foreach ($m->opsiGrup as $g) {
$ada = $g->opsi->whereIn('id', $dipilih);
if ($g->wajib && $ada->()) {
::([ => ]);
}
+= ->();
}
(->(->opsiGrup->flatMap->opsi->())->()) {
::([ => ]);
}
+= (->harga + ) * [];
}
Pengecekan terakhir itu penting. Tanpa pengecekan tersebut, seseorang bisa mengirim ID opsi "Large" milik menu lain yang tambahannya Rp0, atau opsi dari grup yang tidak terpasang di menu tersebut.
Diskon dan kembalian juga dihitung di server:
$diskon = $d['diskon_tipe'] === 'persen'
? (int) round($subtotal * min(100, $d['diskon']) / 100)
: min($subtotal, (int) $d['diskon']); // diskon nominal tidak boleh melebihi subtotal
$total = $subtotal - $diskon;
if ($d['metode'] === 'tunai' && $d['dibayar'] < $total) {
throw ValidationException::withMessages(['dibayar' => 'Uang yang diterima kurang dari total.']);
}
$kembalian = $d['metode'] === 'tunai' ? $d['dibayar'] - $total : 0;
Simpan juga nama dan harga menu ke tabel order_items saat transaksi. Kalau harga menu naik minggu depan, struk dan laporan lama tetap menampilkan harga yang dibayar pelanggan saat itu.
Ringkasan
- "Ambil angka terbesar + 1" tanpa pengaman pasti dobel saat ramai.
- Tambahkan kolom
tanggaldanUNIQUE(tanggal, nomor)agar database menolak nomor ganda. - Hitung nomor di dalam
DB::transaction+lockForUpdate()agar request mengantre. - Ulangi transaksi bila mendapat error 1062.
- Simpan nomor sebagai angka, format labelnya di model.
- Harga, opsi, diskon, dan kembalian selalu dihitung ulang di server.
Download source code lengkapnya
Semua teknik di atas dipakai di Kopi POS: Kasir Kedai Kopi + Layar Barista (Laravel 12 & MySQL): menu dengan varian, dine in/takeaway, tunai/QRIS/debit dengan kembalian, bayar nanti, struk thermal, layar barista dengan bunyi notifikasi, layar TV pesanan siap, shift kasir dan selisih kas, serta laporan omzet, menu terlaris, dan jam ramai. Gratis, tinggal jalankan skrip setup.
Source code lengkapnya gratis
Lihat semua yang dibahas di artikel ini langsung berjalan.