Tutorial Pasukan Lucee MY

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.

Stok masuk dan keluar dengan transaksi selamat
0
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.

PendekatanBila bergunaKelemahan
Tukar lajur stok sahajaAplikasi mainanTiada sejarah, tiada audit
Rekod setiap gerakanKedai sebenarPerlu 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.

Halaman gerakan stok dengan baki semasa dan senarai gerakan terakhir

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

Log masuk untuk menyimpan kemajuan bacaan anda.

0 komen

0 komen

Log masuk untuk menjawab atau memberi komen.

Log masuk