Cara Membuat Open Bill, Tiket Dapur per Stasiun, dan HPP Resep Otomatis di Aplikasi Kasir Kafe JavaScript (Capacitor + IndexedDB)
Cara Membuat Open Bill, Tiket Dapur per Stasiun, dan HPP Resep Otomatis di Aplikasi Kasir Kafe JavaScript (Capacitor + IndexedDB)
Kasir kafe dan resto punya tantangan yang tidak ada di kasir toko biasa. Tamu duduk dulu, pesan, lalu tambah pesanan dua kali sebelum bayar. Kopi harus masuk ke printer bar, nasi goreng ke printer dapur. Satu meja bisa minta bayar terpisah. Pemilik ingin tahu modal per porsi, bukan sekadar omzet.
Artikel ini membahas teknik inti di balik Papikasir FnB, aplikasi kasir Android & iOS berbasis Capacitor dan JavaScript yang berjalan offline: open bill dengan penanda item yang sudah dikirim ke dapur, pembagian tiket per stasiun, validasi varian menu di sisi logika, HPP dari resep, urutan hitung service charge dan PB1, serta jebakan transaksi IndexedDB yang sering membuat data setengah tersimpan.
1. Open bill: tandai item yang sudah ke dapur
Pesanan makan di tempat disimpan dengan status terbuka. Setiap baris item punya penanda dikirim. Saat kasir menekan "Kirim ke dapur", hanya item yang belum dikirim yang dikembalikan untuk dicetak:
async kirimDapur(id) {
return this.db.tulis(['pesanan'], async (t) => {
const p = await t.get('pesanan', id);
const baru = p.items.filter((i) => !i.dikirim);
const tambahan = p.items.some((i) => i.dikirim);
for (const i of p.items) i.dikirim = true;
if (baru.length) { p.dapur = 'baru'; p.waktuSiap = null; }
await t.put('pesanan', p);
return { pesanan: p, baru, tambahan };
});
}
Nilai tambahan dipakai untuk menulis "DAPUR - TAMBAHAN" di tiket, sehingga koki tahu ini pesanan susulan meja yang sama, bukan pesanan baru.
Penanda ini juga menjaga kecurangan. Bila kasir mengurangi jumlah item yang sudah dimasak, sistem meminta PIN pemilik dan mencatatnya sebagai void:
for (const l of lama.items.filter((x) => x.dikirim)) {
const kini = baris.find((b) => b.id === l.id);
const kurang = l.qty - (kini?.qty || 0);
if (kurang > 0) {
if (u.peran !== 'pemilik' && !set.kasirVoid && !izin) gagal(`"${l.nama}" sudah dikirim ke dapur. Butuh PIN pemilik untuk mengurangi.`);
voidBaru.push({ nama: l.nama, qty: kurang, oleh: u.nama, izin: izin?.nama || null });
}
}
2. Tiket per stasiun: dapur dan bar
Setiap kategori menu menyimpan stasiun: dapur, bar, atau tidak (misal air mineral yang tinggal diambil). Saat mencetak, item dikelompokkan lalu dikirim ke printer yang sesuai. Bila printer bar tidak ada, tiket bar jatuh ke printer dapur; bila printer dapur pun tidak ada, ke printer kasir:
const grup = { dapur: [], bar: [] };
for (const it of items) if (it.stasiun !== 'tidak') (grup[it.stasiun] || grup.dapur).push(it);
for (const [st, arr] of Object.entries(grup)) {
if (!arr.length) continue;
const kunci = st === 'bar' ? 'printerBar' : 'printerDapur';
const tujuan = (await app.adaPrinter(kunci)) ? kunci
: st === 'bar' && (await app.adaPrinter('printerDapur')) ? 'printerDapur'
: set.dapur.tiketDiKasir && (await app.adaPrinter('printer')) ? 'printer' : null;
if (tujuan) await app.cetakBaris(tiketDapur(p, arr, { stasiun: st === 'bar' ? 'Bar' : 'Dapur' }), {}, tujuan);
}
Satu HP bisa tersambung ke beberapa printer Bluetooth LE sekaligus, jadi kasir di depan tetap memegang satu perangkat sementara tiket keluar di dapur dan bar.
3. Varian menu divalidasi ulang, bukan dipercaya dari layar
Varian disimpan sebagai grup: { nama: 'Ukuran', wajib: true, maks: 1, pilihan: [{ nama: 'Large', harga: 6000 }] }. Layar kasir boleh membantu memilih, tetapi harga dan aturan dicek ulang dari database saat pesanan disimpan:
for (const g of grup) {
const pilih = (it.opsi || []).filter((o) => o.grupId === g.id);
if (g.wajib && !pilih.length) gagal(`${m.nama}: pilih ${g.nama}`);
if (pilih.length > g.maks) gagal(`${m.nama}: ${g.nama} maksimal ${g.maks} pilihan`);
for (const o of pilih) {
const p = g.pilihan.find((x) => x.nama === o.nama);
if (!p) gagal(`${m.nama}: pilihan "${o.nama}" tidak tersedia`);
dipilih.push({ grupId: g.id, nama: p.nama, harga: p.harga });
}
}
Perhatikan bahwa harga diambil dari p.harga (database), bukan o.harga kiriman layar. Uji otomatis sengaja mengirim harga Rp1 untuk "Extra shot" dan memastikan yang tersimpan tetap Rp6.000.
4. HPP dari resep dan food cost
Setiap menu punya resep per porsi: daftar bahan dan jumlahnya dalam satuan bahan (gram, ml, pcs). Harga bahan disimpan per satuan dan dirata-rata tertimbang tiap kali belanja:
if (total > 0) b.hargaSatuan = (Math.max(0, b.stok) * b.hargaSatuan + total) / (Math.max(0, b.stok) + qty);
b.stok += qty;
HPP menu tinggal penjumlahan:
hpp(menu, bahanMap) {
if (!menu.resep?.length) return Math.round(menu.modalManual || 0);
return Math.round(menu.resep.reduce((a, r) => a + (bahanMap[r.bahanId]?.hargaSatuan || 0) * r.qty, 0));
}
Varian juga bisa memakai bahan, misalnya "Extra shot" memakai 18 gram biji kopi. Dengan begitu food cost (HPP dibagi harga jual) tetap akurat walau tamu memesan kopi Large dengan tambahan shot. Kafe dan resto umumnya menjaga food cost di kisaran 25-35%; menu di atas 45% diberi warna merah di daftar menu.
5. Urutan hitung: diskon, service, lalu PB1
Urutan yang umum di restoran Indonesia: diskon mengurangi subtotal, service charge dihitung dari subtotal setelah diskon, lalu PB1 dihitung dari subtotal plus service.
const setelahDiskon = subtotal - diskon;
const service = Math.round((setelahDiskon * servicePersen) / 100);
const dasarPajak = setelahDiskon + service;
if (pajakTermasuk) pajak = Math.round(dasarPajak - dasarPajak / (1 + persen / 100));
else { pajak = Math.round((dasarPajak * persen) / 100); kenaBayar = dasarPajak + pajak; }
Service hanya dikenakan untuk makan di sini (bisa diubah), dan pesanan online tidak dikenai PB1 karena umumnya sudah diurus aplikasi. Harga online memakai markup persen yang dibulatkan ke atas kelipatan Rp500, kecuali menu punya harga online khusus.
6. Jebakan IndexedDB: jangan menunggu hal lain di dalam transaksi
Transaksi IndexedDB otomatis selesai (commit) begitu tidak ada permintaan yang menunggu. Kalau di dalam transaksi kita await sesuatu yang bukan permintaan IndexedDB, misalnya membaca pengaturan dari transaksi lain atau crypto.subtle, transaksi keburu tertutup dan permintaan berikutnya gagal dengan TransactionInactiveError. Akibatnya pesanan bisa tersimpan tapi stok bahan tidak berkurang.
Solusinya: ambil semua data pendukung sebelum transaksi dibuka, lalu di dalam transaksi hanya menunggu operasi t.get, t.put, dan sejenisnya:
async checkout(data, bayarData) {
const set = await this.pengaturan();
const shift = await this.shiftAktif(u.id);
const dev = await this.perangkat(); // diambil di luar transaksi
return this.db.tulis(['pesanan', 'menu', 'opsi', 'bahan', 'kategori', 'meja', 'kv', 'mutasi', 'pelanggan'], async (t) => {
const p = await this._simpan(t, data, set, u, null, dev);
return this._bayar(t, p, bayarData, set, u, shift);
});
}
Dengan pola ini, simpan pesanan, nomor antrean, pembayaran, dan pengurangan bahan berhasil semua atau gagal semua.
Ringkasan
- Open bill memakai penanda
dikirimper item, sehingga tiket tambahan hanya berisi item baru dan pengurangan item yang sudah dimasak butuh izin. - Tiket dikelompokkan per stasiun kategori lalu dikirim ke printer dapur atau bar, dengan cadangan ke printer kasir.
- Varian dan harganya selalu divalidasi ulang dari database.
- HPP dihitung dari resep x harga bahan rata-rata, termasuk bahan dari varian, sehingga food cost per menu akurat.
- Urutan hitung: diskon, service, lalu PB1; pesanan online memakai markup dan tanpa PB1.
- Di IndexedDB, ambil data pendukung sebelum transaksi dibuka agar semua perubahan tersimpan atomik.
Download source code lengkapnya
Source code lengkap Papikasir FnB (meja & open bill, papan dapur, tiket dapur & bar, split bill, bayar campuran, QRIS bernominal, resep HPP, laporan, backup pindah HP, proyek Android & iOS siap build) tersedia di Papicode seharga Rp50.000:
Source code lengkapnya gratis
Lihat semua yang dibahas di artikel ini langsung berjalan.
Tutorial lainnya
Cara Membuat Sistem Tiket Travel dengan Pilih Kursi di Google Apps Script: Layout Armada, Anti Kursi Dobel, dan Kursi Kedaluwarsa
20 Oktober 2026
Cara Membuat Website Donasi Transparan dengan Google Apps Script: Kode Unik Transfer dan Laporan Penyaluran
20 Oktober 2026