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-441
Kontraknya dinyatakan di docstring: baris batch harus sudah dikunci oleh pemanggil. backend/stok/layanan.py:29-352 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-873 backend/stok/layanan.py:91-1024 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.JenisMutasi menyediakan tujuh nilai yang dipakai di tabel berikut. backend/stok/models.py:57-648
jenis | Pemanggil | Nilai jumlah |
|---|---|---|
MASUK | catat_barang_masuk() | positif, sebesar masuk.jumlah_botol |
PENJUALAN | _buat_transaksi() | negatif, sebesar ambil per batch |
RETUR | buat_retur() | positif, sebesar jumlah botol yang diretur |
RUSAK | buat_retur(), kurangi_stok() | negatif |
KEDALUWARSA | kurangi_stok() | negatif |
OPNAME | setujui_opname() | item.selisih, bisa positif atau negatif |
BATAL | setujui_pembatalan() | positif, sebesar belum_diretur |
kurangi_stok() hanya menerima RUSAK atau KEDALUWARSA karena serializer membatasi pilihan jenis. backend/stok/serializers.py:47-509
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-5010
Pemakai kunci_batch() adalah kurangi_stok(), buat_opname(), setujui_opname(), buat_retur(), dan setujui_pembatalan(). backend/stok/layanan.py:145-15511 backend/pembelian/layanan.py:23412
⚠️
ubah_stok()tidak mengunci baris. Memanggilnya tanpakunci_batch()atauselect_for_update()lebih dulu membuka celah dua penjualan membacasisayang 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-7813 layak_jual() sendiri menyaring sisa__gt=0 dan kedaluwarsa__gte=timezone.localdate(). backend/stok/models.py:29-3114
Kebutuhan per produk dihitung lebih dulu, yaitu jumlah dikali isi_botol, sebelum batch dikunci dan ketersediaan dibandingkan. backend/pembelian/layanan.py:96-11115 Total botol per baris nota diambil dari properti total_botol, yaitu jumlah * isi_botol. backend/pembelian/models.py:115-11716
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.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-1565 Alokasi itulah yang menyimpan batch mana saja yang dipakai satu item, dan menjadi dasar retur serta pembatalan. backend/pembelian/models.py:120-13517
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-4418
⚠️
layak_jual()memakaikedaluwarsa__gte=timezone.localdate(), jadi batch yang kedaluwarsa hari ini masih bisa terjual. Batch yang sudah lewat satu hari tidak ikut dikunci, sehingga penjualan gagal denganstok_tidak_cukupmeskisisa-nya masih 100. backend/tests/test_pembelian.py:47-5519
Jaminan di lapis database
| Objek | Definisi | Akibat |
|---|---|---|
sisa_tidak_minus | CheckConstraint(condition=Q(sisa__gte=0)) | UPDATE yang membuat sisa negatif ditolak database |
batch_unik_per_produk | UniqueConstraint(fields=["produk", "nomor_batch"]) | satu nomor_batch hanya boleh sekali per produk |
batch_produk_exp_idx | Index(fields=["produk", "kedaluwarsa"]) | mempercepat pembacaan urut kedaluwarsa per produk |
alokasi_unik | UniqueConstraint(fields=["item", "batch"]) | satu item tidak punya dua alokasi ke batch yang sama |
retur_tidak_melebihi_alokasi | CheckConstraint(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-5120 Dua baris terakhir berasal dari Meta milik ItemTransaksiBatch. backend/pembelian/models.py:120-13517
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-7921
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-4622
test | Beban | Hasil yang diharapkan |
|---|---|---|
test_stok_terakhir_tidak_terjual_dua_kali | 8 thread, satu batch berisi 1 botol | 7 hasil stok_tidak_cukup, satu Transaksi, sisa batch 0 |
test_kunci_idempoten_sama_bersamaan_hanya_satu_nota | 6 thread, kunci_idempoten sama, sisa 50 | satu nomor nota, Transaksi berjumlah 1, sisa 49 |
test_nomor_nota_unik_saat_bersamaan | 6 thread, kunci berbeda, sisa 50 | 6 nomor nota berbeda |
Tiga baris di atas bersandar pada tiga test berurutan yang masing-masing memuat assert tersebut. backend/tests/test_konkurensi.py:49-6823 backend/tests/test_konkurensi.py:71-9224 backend/tests/test_konkurensi.py:95-11325
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().