Cara Mengurangi Stok Otomatis Saat Transaksi di PHP & MySQL Tanpa Stok Minus (Studi Kasus Bengkel)
Hampir semua aplikasi kasir punya fitur "stok berkurang otomatis saat barang terjual". Kelihatannya cukup dengan satu query UPDATE stok = stok - qty, tapi tanpa perlindungan yang benar, stok bisa minus, selisih, atau laporan laba jadi salah karena harga beli sudah berubah.
Di tutorial ini kita bahas cara yang aman dengan PHP & MySQL, memakai contoh work order bengkel: sparepart ditambahkan ke servis, stok berkurang, dan stok kembali kalau item dihapus atau servis dibatalkan. Contohnya diambil dari aplikasi Kasir Bengkel & Servis Motor yang bisa kamu download gratis di Papicode (link di akhir artikel).
1. Struktur tabel
CREATE TABLE sparepart (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(100) NOT NULL,
stok INT NOT NULL DEFAULT 0,
harga_beli INT NOT NULL,
harga_jual INT NOT NULL
) ENGINE=InnoDB;
CREATE TABLE wo_item (
id INT AUTO_INCREMENT PRIMARY KEY,
wo_id INT NOT NULL,
jenis ENUM('jasa','part') NOT NULL,
ref_id INT NOT NULL, -- id sparepart / jasa
nama VARCHAR(100) NOT NULL, -- snapshot nama
qty INT NOT NULL,
harga INT NOT NULL, -- snapshot harga jual saat transaksi
harga_beli INT NOT NULL DEFAULT 0 -- snapshot modal saat transaksi
) ENGINE=InnoDB;
Perhatikan kolom snapshot di wo_item. Jangan hanya menyimpan ref_id lalu mengambil harga dari tabel sparepart saat membuat laporan. Kalau besok harga oli naik, laporan bulan lalu ikut berubah, padahal transaksinya terjadi dengan harga lama.
2. Masalahnya: race condition
Bayangkan stok kampas rem tinggal 1, dan dua kasir menambahkannya ke dua work order berbeda pada detik yang sama:
- Kasir A:
SELECT stok→ 1, cukup - Kasir B:
SELECT stok→ 1, cukup - Kasir A:
UPDATE stok = stok - 1→ 0 - Kasir B:
UPDATE stok = stok - 1→ -1
Kedua pengecekan lolos karena dibaca sebelum ada yang menulis.
3. Solusi: transaksi + SELECT ... FOR UPDATE
Kunci baris sparepart di dalam transaksi. Kasir kedua otomatis menunggu sampai transaksi kasir pertama selesai, lalu membaca stok terbaru.
<?php
function tambah_part(PDO $pdo, int $woId, int $sparepartId, int $qty): void
{
$pdo->beginTransaction();
try {
// 1. Kunci baris sparepart sampai commit/rollback
$st = $pdo->prepare('SELECT * FROM sparepart WHERE id = ? FOR UPDATE');
$st->execute([$sparepartId]);
$p = $st->fetch(PDO::FETCH_ASSOC);
if (!$p) throw new RuntimeException('Sparepart tidak ditemukan.');
if ($p['stok'] < $qty) throw new RuntimeException("Stok {$p['nama']} tinggal {$p['stok']}.");
// 2. Simpan item lengkap dengan snapshot harga jual & harga beli
$pdo->prepare("INSERT INTO wo_item (wo_id, jenis, ref_id, nama, qty, harga, harga_beli)
VALUES (?, 'part', ?, ?, ?, ?, ?)")
->execute([, [], [], , [], []]);
->()->([, []]);
->();
} ( ) {
->();
;
}
}
Wajib engine InnoDB. MyISAM tidak mendukung transaksi maupun row lock.
Alternatif tanpa FOR UPDATE: gabungkan cek dan update dalam satu query atomik, lalu periksa jumlah baris yang berubah:
$st = $pdo->prepare('UPDATE sparepart SET stok = stok - ? WHERE id = ? AND stok >= ?');
$st->execute([$qty, $id, $qty]);
if ($st->rowCount() === 0) {
throw new RuntimeException('Stok tidak cukup.');
}
4. Kembalikan stok saat item dihapus atau servis dibatalkan
Stok yang sudah dikurangi harus dikembalikan dengan jumlah sesuai item, bukan dihitung ulang dari nol:
<?php
// Hapus satu item
$it = ambil_item($itemId);
if ($it['jenis'] === 'part') {
$pdo->prepare('UPDATE sparepart SET stok = stok + ? WHERE id = ?')->execute([$it['qty'], $it['ref_id']]);
}
$pdo->prepare('DELETE FROM wo_item WHERE id = ?')->execute([$itemId]);
// Batalkan seluruh work order: kembalikan semua part-nya
$st = $pdo->prepare("SELECT ref_id, qty FROM wo_item WHERE wo_id = ? AND jenis = 'part'");
$st->execute([$woId]);
foreach ($st as $it) {
$pdo->prepare('UPDATE sparepart SET stok = stok + ? WHERE id = ?')->execute([$it['qty'], $it['ref_id']]);
}
$pdo->prepare("UPDATE work_order SET status = 'batal' WHERE id = ?")->execute([$woId]);
Bungkus juga dua operasi ini di dalam transaksi supaya tidak berhenti di tengah jalan.
5. Hitung laba dengan snapshot harga beli
Karena harga beli tersimpan di setiap item, laba sparepart per periode cukup dihitung dengan satu query:
SELECT SUM(i.qty * i.harga) AS penjualan,
SUM(i.qty * i.harga_beli) AS modal,
SUM(i.qty * (i.harga - i.harga_beli)) AS laba
FROM wo_item i
JOIN work_order w ON w.id = i.wo_id
WHERE i.jenis = 'part'
AND w.status = 'lunas'
AND w.lunas_at BETWEEN '2026-10-01' AND '2026-10-31 23:59:59';
Hanya work order lunas yang dihitung. Work order yang masih dikerjakan atau dibatalkan tidak ikut masuk laporan.
Ringkasan
- Simpan snapshot nama, harga jual, dan harga beli di setiap item transaksi.
- Cegah stok minus dengan transaksi + SELECT ... FOR UPDATE, atau
UPDATE ... WHERE stok >= qtylalu cekrowCount(). - Kembalikan stok sesuai qty item saat item dihapus atau transaksi dibatalkan.
- Hitung laba dari snapshot dan hanya untuk transaksi lunas.
Download source code lengkapnya
Semua logika di atas sudah dipakai di Aplikasi Kasir Bengkel & Servis Motor PHP Native & MySQL: motor masuk lewat plat nomor, antrean kanban, work order jasa dan sparepart dengan stok otomatis, nota thermal, laporan laba dan kinerja mekanik, serta pengingat servis berkala via WhatsApp. Gratis dan langsung jalan di XAMPP.
Source code lengkapnya gratis
Lihat semua yang dibahas di artikel ini langsung berjalan.