Lompat ke konten
dokumentasi.id
Commit 32b2830
v2
Star0

3

Stok per Batch dan Alokasi FEFO

Stok disimpan dalam botol pada level batch, bukan sebagai satu angka per produk. Setiap penerimaan barang membuat satu baris BatchProduk yang membawa nomor_batch, kedaluwarsa, jumlah_awal, dan sisa. Dua mekanisme menjaga angka sisa tetap benar: satu pintu penulisan bernama ubah_stok() dengan penguncian batch yang selalu urut id, dan pemilihan batch kedaluwarsa terdekat saat penjualan. Rincian opname dibahas di Mutasi Stok dan Stok Opname; alur nota, retur, dan piutang dibahas di Transaksi Penjualan, Retur, dan Piutang.

Satu pintu penulisan untuk sisa

ubah_stok() menambah jumlah ke batch.sisa, menolak hasil negatif dengan AturanBisnisError berkode stok_minus, menyimpan hanya kolom sisa lewat update_fields, lalu membuat satu baris MutasiStok yang mencatat jenis, jumlah, dan sisa_sesudah. backend/stok/layanan.py:21-44

Kontraknya dinyatakan di docstring: baris batch harus sudah dikunci oleh pemanggil. backend/stok/layanan.py:29-35 Jadi ubah_stok() tidak mengunci apa pun; ia hanya mengubah angka dan menulis jejaknya.

Enam fungsi memanggil ubah_stok(), dan buat_retur() memanggilnya dua kali dalam satu baris retur. backend/stok/layanan.py:79-87 backend/stok/layanan.py:91-102 backend/pembelian/layanan.py:147-156 backend/pembelian/layanan.py:259-261 backend/pembelian/layanan.py:325-329

graph TD
    BM["catat_barang_masuk"] --> UB["ubah_stok"]
    KR["kurangi_stok"] --> UB
    OP["setujui_opname"] --> UB
    TR["_buat_transaksi"] --> UB
    RT["buat_retur"] --> UB
    PB["setujui_pembatalan"] --> UB
    UB -->|"sisa tidak minus"| BP[("batch_produk.sisa")]
    UB -->|"setiap panggilan"| MS[("mutasi_stok")]
    UB -->|"sisa menjadi negatif"| ERR["AturanBisnisError: stok_minus"]
    style UB fill:#f59e0b,color:#000
    style BP fill:#336791,color:#fff
    style MS fill:#336791,color:#fff
    style ERR fill:#dc2626,color:#fff
%% fig: Pemanggil ubah_stok
%% desc: Enam fungsi domain menulis sisa lewat satu fungsi; setiap panggilan yang lolos menghasilkan satu baris mutasi_stok.
Fig. 1: Pemanggil ubah_stokEnam fungsi domain menulis sisa lewat satu fungsi; setiap panggilan yang lolos menghasilkan satu baris mutasi_stok.

JenisMutasi menyediakan tujuh nilai yang dipakai di tabel berikut. backend/stok/models.py:57-64

jenisPemanggilNilai jumlah
MASUKcatat_barang_masuk()positif, sebesar masuk.jumlah_botol
PENJUALAN_buat_transaksi()negatif, sebesar ambil per batch
RETURbuat_retur()positif, sebesar jumlah botol yang diretur
RUSAKbuat_retur(), kurangi_stok()negatif
KEDALUWARSAkurangi_stok()negatif
OPNAMEsetujui_opname()item.selisih, bisa positif atau negatif
BATALsetujui_pembatalan()positif, sebesar belum_diretur

kurangi_stok() hanya menerima RUSAK atau KEDALUWARSA karena serializer membatasi pilihan jenis. backend/stok/serializers.py:47-50

Kunci selalu urut id

kunci_batch() mengambil baris dengan select_for_update(), memfilter id__in, lalu mengurutkan order_by("id"). Docstring-nya menyebut alasannya: dua transaksi tidak saling menunggu (deadlock). backend/stok/layanan.py:47-50

Pemakai kunci_batch() adalah kurangi_stok(), buat_opname(), setujui_opname(), buat_retur(), dan setujui_pembatalan(). backend/stok/layanan.py:145-155 backend/pembelian/layanan.py:234

⚠️ ubah_stok() tidak mengunci baris. Memanggilnya tanpa kunci_batch() atau select_for_update() lebih dulu membuka celah dua penjualan membaca sisa yang sama.

Alokasi FEFO saat penjualan

_kunci_batch_fefo() mengunci semua batch yang lulus layak_jual() milik produk di nota dengan order_by("id"), lalu menyusun ulang di memori memakai kunci (b.kedaluwarsa, b.id) menjadi antrean per produk. backend/pembelian/layanan.py:68-78 layak_jual() sendiri menyaring sisa__gt=0 dan kedaluwarsa__gte=timezone.localdate(). backend/stok/models.py:29-31

Kebutuhan per produk dihitung lebih dulu, yaitu jumlah dikali isi_botol, sebelum batch dikunci dan ketersediaan dibandingkan. backend/pembelian/layanan.py:96-111 Total botol per baris nota diambil dari properti total_botol, yaitu jumlah * isi_botol. backend/pembelian/models.py:115-117

sequenceDiagram
    actor K as Kasir
    participant L as buat_transaksi
    participant F as _kunci_batch_fefo
    participant DB as batch_produk
    participant U as ubah_stok
    participant M as mutasi_stok

    K->>L: kirim items dan kunci_idempoten
    L->>L: hitung kebutuhan per produk
    L->>F: panggil _kunci_batch_fefo
    F->>DB: select_for_update, layak_jual, order_by id
    DB-->>F: baris batch terkunci
    F-->>L: batch per produk, urut kedaluwarsa lalu id
    alt tersedia kurang dari diminta
        L-->>K: AturanBisnisError stok_tidak_cukup dengan rincian
    else stok cukup
        L->>DB: insert transaksi dan item_transaksi
        loop setiap batch sampai perlu nol
            L->>U: ubah_stok negatif jenis PENJUALAN
            U->>DB: update sisa
            U->>M: insert sisa_sesudah
        end
        L-->>K: nota tersimpan
    end
%% fig: Urutan lock dan pemotongan stok FEFO
%% desc: Penjualan mengunci batch layak jual urut id, memeriksa ketersediaan, lalu memotong sisa per batch dan menulis mutasi_stok.
Fig. 2: Urutan lock dan pemotongan stok FEFOPenjualan mengunci batch layak jual urut id, memeriksa ketersediaan, lalu memotong sisa per batch dan menulis mutasi_stok.

Pemotongan berjalan per baris item. Variabel perlu berkurang sebesar ambil = min(perlu, batch.sisa); setiap potongan membuat satu baris ItemTransaksiBatch berisi jumlah_botol, lalu memanggil ubah_stok(batch, -ambil, JenisMutasi.PENJUALAN, pengguna, nomor). backend/pembelian/layanan.py:147-156 Alokasi itulah yang menyimpan batch mana saja yang dipakai satu item, dan menjadi dasar retur serta pembatalan. backend/pembelian/models.py:120-135

Uji test_fefo_memakai_batch_kedaluwarsa_terdekat_dulu membuktikan urutannya: batch DEKAT dengan kedaluwarsa 30 hari habis 5 botol lebih dulu, lalu JAUH dengan 200 hari menyumbang 2 botol. backend/tests/test_pembelian.py:31-44

⚠️ layak_jual() memakai kedaluwarsa__gte=timezone.localdate(), jadi batch yang kedaluwarsa hari ini masih bisa terjual. Batch yang sudah lewat satu hari tidak ikut dikunci, sehingga penjualan gagal dengan stok_tidak_cukup meski sisa-nya masih 100. backend/tests/test_pembelian.py:47-55

Jaminan di lapis database

ObjekDefinisiAkibat
sisa_tidak_minusCheckConstraint(condition=Q(sisa__gte=0))UPDATE yang membuat sisa negatif ditolak database
batch_unik_per_produkUniqueConstraint(fields=["produk", "nomor_batch"])satu nomor_batch hanya boleh sekali per produk
batch_produk_exp_idxIndex(fields=["produk", "kedaluwarsa"])mempercepat pembacaan urut kedaluwarsa per produk
alokasi_unikUniqueConstraint(fields=["item", "batch"])satu item tidak punya dua alokasi ke batch yang sama
retur_tidak_melebihi_alokasiCheckConstraint(jumlah_diretur__lte=F("jumlah_botol"))jumlah_diretur tidak bisa melampaui alokasi

Tiga baris pertama berasal dari Meta milik BatchProduk. backend/stok/models.py:34-51 Dua baris terakhir berasal dari Meta milik ItemTransaksiBatch. backend/pembelian/models.py:120-135

MutasiStok menyimpan jumlah bertanda, positif untuk masuk dan negatif untuk keluar, beserta sisa_sesudah. Dengan begitu riwayat batch bisa dibaca tanpa menghitung ulang seluruh transaksi. backend/stok/models.py:67-79

Bukti uji konkurensi

_jalankan_bersamaan() menjalankan sejumlah thread, menyamakan titik mulai dengan threading.Barrier, menangkap AturanBisnisError sebagai exc.kode, lalu menunggu tiap thread sampai join(30) detik. backend/tests/test_konkurensi.py:28-46

testBebanHasil yang diharapkan
test_stok_terakhir_tidak_terjual_dua_kali8 thread, satu batch berisi 1 botol7 hasil stok_tidak_cukup, satu Transaksi, sisa batch 0
test_kunci_idempoten_sama_bersamaan_hanya_satu_nota6 thread, kunci_idempoten sama, sisa 50satu nomor nota, Transaksi berjumlah 1, sisa 49
test_nomor_nota_unik_saat_bersamaan6 thread, kunci berbeda, sisa 506 nomor nota berbeda

Tiga baris di atas bersandar pada tiga test berurutan yang masing-masing memuat assert tersebut. backend/tests/test_konkurensi.py:49-68 backend/tests/test_konkurensi.py:71-92 backend/tests/test_konkurensi.py:95-113

Angka sisa 49 pada uji kedua menunjukkan hanya satu potongan yang benar-benar terjadi. Uji pertama menunjukkan stok_minus tidak pernah sampai ke database: penjualan yang kalah sudah ditolak lebih dulu oleh pemeriksaan ketersediaan di _buat_transaksi().