Stok masuk dan keluar dengan transaksi selamat
Rekod setiap gerakan stok dalam jadual berasingan dan kemas kini baki dalam satu transaksi, supaya baki tidak pernah terpesong walaupun berlaku ralat.

Kandungan
Stok masuk dan keluar dengan transaksi selamat
Bab ini menjawab satu soalan yang kelihatan mudah: bagaimana anda merekod barang masuk dan keluar tanpa merosakkan baki?
Jawapan naif ialah "tukar lajur stok". Jawapan yang betul ialah "rekod setiap gerakan, dan kira baki daripada gerakan itu". Perbezaannya baru kelihatan apabila sesuatu berlaku: barang hilang, kiraan salah, atau dua orang mengemas kini pada masa yang sama. Halaman ini menyambung daripada senarai yang anda bina dalam bab carian dan penapis.
Kenapa kita menyimpan setiap gerakan
Kalau anda hanya menyimpan satu nombor stok, nombor itu tidak mempunyai sejarah. Apabila ia menjadi 12 sedangkan sepatutnya 10, tiada cara untuk tahu apa yang berlaku.
Dengan jadual gerakan, setiap perubahan menjadi satu baris: bila, produk apa, masuk atau keluar, berapa banyak, dan catatan.
| Pendekatan | Bila berguna | Kelemahan |
|---|---|---|
Tukar lajur stok sahaja | Aplikasi mainan | Tiada sejarah, tiada audit |
| Rekod setiap gerakan | Kedai sebenar | Perlu dua tulisan, bukan satu |
Kita menggunakan pendekatan kedua, dan menyimpan lajur stok sebagai baki semasa supaya senarai kekal pantas. Lajur itu sudah wujud sejak bab pangkalan data, dan borang tambah produk menetapkan nilai awalnya.
Jadual gerakan
CREATE TABLE gerakan_stok (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
produk_id INT UNSIGNED NOT NULL,
jenis ENUM('masuk','keluar') NOT NULL,
kuantiti INT NOT NULL,
nota VARCHAR(200) NULL,
dicipta_pada DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_gerakan_produk FOREIGN KEY (produk_id) REFERENCES produk(id),
INDEX idx_produk (produk_id, dicipta_pada)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
ENUM menghalang nilai lain daripada masuk. Kalau kod anda tersilap menulis keluar dengan ruang di hujung, pangkalan data akan menolaknya, dan itu lebih baik daripada menyimpan data yang tidak boleh dikira kemudian.
Kekangan FOREIGN KEY memastikan setiap gerakan merujuk produk yang benar-benar wujud.
Dua tulisan, satu transaksi
Merekod gerakan memerlukan dua tulisan: satu INSERT ke gerakan_stok, dan satu UPDATE pada produk.stok.
Kalau yang pertama berjaya dan yang kedua gagal, pangkalan data anda bercanggah: ada gerakan tanpa perubahan baki. Itulah masalah yang diselesaikan oleh cftransaction.
<cftransaction>
<cfquery name="semasa" datasource="inventori">
SELECT id, nama, stok
FROM produk
WHERE id = <cfqueryparam value="#idProduk#" cfsqltype="cf_sql_integer">
FOR UPDATE
</cfquery>
<cfquery datasource="inventori">
INSERT INTO gerakan_stok (produk_id, jenis, kuantiti, nota)
VALUES (
<cfqueryparam value="#idProduk#" cfsqltype="cf_sql_integer">,
<cfqueryparam value="#jenis#" cfsqltype="cf_sql_varchar">,
<cfqueryparam value="#kuantiti#" cfsqltype="cf_sql_integer">,
<cfqueryparam value="#nota#" cfsqltype="cf_sql_varchar" null="#NOT len(nota)#">
)
</cfquery>
<cfquery datasource="inventori">
UPDATE produk
SET stok = stok <cfif jenis EQ "masuk">+<cfelse>-</cfif> <cfqueryparam value="#kuantiti#" cfsqltype="cf_sql_integer">
WHERE id = <cfqueryparam value="#idProduk#" cfsqltype="cf_sql_integer">
</cfquery>
</cftransaction>
Semua query di dalam <cftransaction> berkongsi satu transaksi. Kalau satu gagal, kesemuanya dibatalkan.
Perhatikan null="#NOT len(nota)#". Tanpanya, nota kosong dihantar sebagai teks kosong, bukan NULL. Kedua-duanya boleh diterima, tetapi NULL lebih jujur: ia bermaksud "tiada nota", bukan "nota yang kosong".
Mengapa FOR UPDATE
FOR UPDATE mengunci baris itu sehingga transaksi tamat. Ini menghalang dua orang daripada membaca baki yang sama pada masa yang sama dan mengiranya dua kali.
Tanpa kunci, senario ini mungkin: dua staf mengeluarkan 8 unit daripada stok 10. Kedua-duanya membaca 10, kedua-duanya fikir ia cukup, dan stok berakhir di -6.
Dengan FOR UPDATE, yang kedua menunggu yang pertama selesai, kemudian membaca baki yang baharu.
Semak baki di dalam transaksi
Kita menyemak sama ada stok mencukupi selepas membaca baris, bukan sebelum:
<cfif jenis EQ "keluar" AND semasa.stok LT kuantiti>
<cfset ralat = "Stok tidak mencukupi. Baki sekarang #semasa.stok# unit.">
<cfelse>
...
</cfif>
Kalau baki tidak mencukupi, kita tidak menjalankan INSERT dan tidak menjalankan UPDATE. Transaksi tamat tanpa menulis apa-apa, jadi tiada gerakan separuh siap.
Bukti rollback
Saya menguji dua perkara pada rig, dan kedua-duanya penting.
Pertama, cubaan mengeluarkan 999 unit daripada baki 12 unit memulangkan mesej "Stok tidak mencukupi. Baki sekarang 12 unit.", dan tiada baris baharu muncul dalam gerakan_stok.
Kedua, saya menulis satu halaman ujian yang memasukkan gerakan, mengemas kini stok, kemudian sengaja membaling ralat di dalam transaksi yang sama. Hasilnya: ralat ditangkap, sifar baris UJIAN-ROLLBACK kekal dalam jadual, dan baki stok tidak berubah.
Itulah maksud transaksi: sama ada semuanya berlaku, atau tiada satu pun.
Perangkap tanda pagar
Kod saya mula-mula gagal dengan ralat kompil di baris 68. Puncanya ialah warna CSS di dalam <cfoutput>:
<div style="background:#ecfdf5;color:#065f46">#berjaya#</div>
CFML membaca #ecfdf5;color:# sebagai ungkapan, bukan sebagai warna. Penyelesaiannya ada dua: tulis ##ecfdf5 untuk melarikan tanda pagar, atau lebih kemas, pindahkan warna itu ke kelas CSS di luar <cfoutput>.
Saya memilih cara kedua, kerana ia mengelakkan masalah yang sama berulang di tempat lain.
Halaman stok penuh
Fail stok.cfm menggabungkan semuanya: pengawal log masuk daripada bab log masuk, pengesahan, transaksi, dan senarai 10 gerakan terakhir.
<cfinclude template="includes/semakan-login.cfm">
<cfinclude template="includes/atas.cfm">
<cfparam name="form.produk_id" default="0">
<cfset ralat = "">
<cfset berjaya = "">
<cfquery name="semuaProduk" datasource="inventori">
SELECT id, sku, nama, stok
FROM produk
WHERE is_active = 1
ORDER BY nama
</cfquery>
<cfif structKeyExists(form, "hantar")>
<cfset idProduk = val(form.produk_id)>
<cfset jenis = form.jenis ?: "masuk">
<cfset kuantiti = val(form.kuantiti)>
<cfset nota = trim(form.nota ?: "")>
<cfif idProduk EQ 0>
<cfset ralat = "Pilih produk terlebih dahulu.">
<cfelseif kuantiti LTE 0>
<cfset ralat = "Kuantiti mesti lebih daripada sifar.">
<cfelseif NOT listFind("masuk,keluar", jenis)>
<cfset ralat = "Jenis gerakan tidak sah.">
</cfif>
<cfif NOT len(ralat)>
<cftransaction>
<cfquery name="semasa" datasource="inventori">
SELECT id, nama, stok
FROM produk
WHERE id = <cfqueryparam value="#idProduk#" cfsqltype="cf_sql_integer">
FOR UPDATE
</cfquery>
<cfif jenis EQ "keluar" AND semasa.stok LT kuantiti>
<cfset ralat = "Stok tidak mencukupi. Baki sekarang #semasa.stok# unit.">
<cfelse>
<cfquery datasource="inventori">
INSERT INTO gerakan_stok (produk_id, jenis, kuantiti, nota)
VALUES (
<cfqueryparam value="#idProduk#" cfsqltype="cf_sql_integer">,
<cfqueryparam value="#jenis#" cfsqltype="cf_sql_varchar">,
<cfqueryparam value="#kuantiti#" cfsqltype="cf_sql_integer">,
<cfqueryparam value="#nota#" cfsqltype="cf_sql_varchar" null="#NOT len(nota)#">
)
</cfquery>
<cfquery datasource="inventori">
UPDATE produk
SET stok = stok <cfif jenis EQ "masuk">+<cfelse>-</cfif> <cfqueryparam value="#kuantiti#" cfsqltype="cf_sql_integer">
WHERE id = <cfqueryparam value="#idProduk#" cfsqltype="cf_sql_integer">
</cfquery>
</cfif>
</cftransaction>
<cfif NOT len(ralat)>
<cfset berjaya = "Gerakan #jenis# #kuantiti# unit direkodkan.">
</cfif>
</cfif>
</cfif>
<h2>Gerakan stok</h2>
<cfif len(ralat)><cfoutput><div class="ralat-kotak">#ralat#</div></cfoutput></cfif>
<cfif len(berjaya)><cfoutput><div class="ralat-kotak kotak-berjaya">#berjaya#</div></cfoutput></cfif>
<cfoutput>
<form method="post" action="stok.cfm" class="borang">
<label>Produk
<select name="produk_id">
<option value="0">-- pilih --</option>
<cfloop query="semuaProduk">
<option value="#id#" #(val(form.produk_id) EQ id ? "selected" : "")#>#sku# - #nama# (#stok# unit)</option>
</cfloop>
</select>
</label>
<label>Jenis
<select name="jenis">
<option value="masuk">Barang masuk</option>
<option value="keluar">Barang keluar</option>
</select>
</label>
<label>Kuantiti
<input type="text" name="kuantiti" inputmode="numeric" value="#encodeForHTMLAttribute(form.kuantiti ?: "")#">
</label>
<label>Nota (pilihan)
<input type="text" name="nota" maxlength="200" value="#encodeForHTMLAttribute(form.nota ?: "")#">
</label>
<button type="submit" name="hantar" value="1">Rekod gerakan</button>
</form>
</cfoutput>
<h3>10 gerakan terakhir</h3>
<cfquery name="terakhir" datasource="inventori">
SELECT g.id, g.jenis, g.kuantiti, g.nota, g.dicipta_pada, p.sku, p.nama
FROM gerakan_stok g
INNER JOIN produk p ON p.id = g.produk_id
ORDER BY g.id DESC
LIMIT 10
</cfquery>
<table>
<thead><tr><th scope="col">Masa</th><th scope="col">Produk</th><th scope="col">Jenis</th><th scope="col">Kuantiti</th><th scope="col">Nota</th></tr></thead>
<tbody>
<cfoutput><cfloop query="terakhir">
<tr>
<td>#dateFormat(dicipta_pada, "dd/mm/yyyy")# #timeFormat(dicipta_pada, "HH:mm")#</td>
<td>#sku# - #nama#</td>
<td>#jenis#</td>
<td class="nombor">#kuantiti#</td>
<td>#encodeForHTML(nota ?: "")#</td>
</tr>
</cfloop></cfoutput>
</tbody>
</table>
<cfinclude template="includes/bawah.cfm">
Di bahagian bawah halaman, senarai gerakan terakhir menggunakan INNER JOIN supaya setiap baris membawa SKU dan nama produk. Perhatikan LIMIT 10: kita tidak memaparkan seluruh sejarah dalam satu halaman.

Hasil sebenar pada rig ujian kami: selepas masuk 5 dan keluar 3, baki menjadi 12 unit.
Ujian yang saya jalankan
- Stok awal 10 unit. Masuk 5 unit, kemudian keluar 3 unit menghasilkan baki 12.
- Cubaan keluar 999 unit ditolak dengan mesej baki semasa, dan tiada baris gerakan ditambah.
- Ralat sengaja selepas dua tulisan membatalkan kedua-duanya: sifar baris kekal, baki tidak berubah.
Ralat lazim
Baki berubah tetapi tiada gerakan direkod. Anda menjalankan UPDATE di luar cftransaction, atau selepas transaksi tamat.
Stok menjadi negatif. Semakan baki berlaku sebelum FOR UPDATE, atau FOR UPDATE tiada langsung.
Ralat kompil pada baris dengan warna CSS. Tanda # di dalam <cfoutput> dibaca sebagai ungkapan. Gunakan ## atau kelas CSS.
Nota kosong disimpan sebagai teks kosong. Tambah null="#NOT len(nota)#" pada cfqueryparam.
Apa yang anda sudah ada
Aplikasi anda kini mempunyai sejarah penuh bagi setiap perubahan stok. Itu asas kepada laporan, dan asas kepada kepercayaan: bila angka tidak sepadan, anda boleh menyemak bila ia mula terpesong.
Bab seterusnya menggunakan data ini untuk laporan ringkas dan eksport CSV. Produk yang diarkibkan dalam bab ubah dan arkib dikecualikan daripada senarai ini, tetapi gerakannya kekal dalam sejarah.
Soalan Lazim
Boleh saya membetulkan gerakan yang tersilap? Jangan edit gerakan lama. Rekod gerakan pembetulan dengan nota, supaya sejarah kekal jujur.
Adakah FOR UPDATE menyekat semua pembacaan?
Ia menyekat tulisan dan kunci eksklusif pada baris itu. Pembacaan biasa tanpa FOR UPDATE masih boleh berlaku.
Kenapa tidak kira stok daripada jumlah gerakan setiap kali? Boleh, dan ia lebih tepat. Tetapi untuk senarai 300 produk, ia lebih perlahan. Kita menyimpan baki dan boleh menyemaknya semula bila perlu.
Berapa lama transaksi patut dibuka?
Sesingkat mungkin. Jangan letak kerja yang lambat, seperti menghantar e-mel, di dalam cftransaction.
Apa jadi kalau pelayan mati semasa transaksi? MySQL membatalkan transaksi yang tidak selesai. Itu sebabnya kita menggunakan InnoDB, bukan enjin yang tidak menyokong transaksi.
Sumber
- Rujukan
cftransaction: docs.lucee.org/reference/tags/transaction.html SELECT ... FOR UPDATE: dev.mysql.com/doc/refman/8.4/en/innodb-locking-reads.htmlENUMdalam MySQL: dev.mysql.com/doc/refman/8.4/en/enum.html- Kekangan asing: dev.mysql.com/doc/refman/8.4/en/create-table-foreign-keys.html