5
Alur Stok, Batch FEFO, dan Opname
Stok di Hydro disimpan per batch, bukan satu angka per produk. Setiap batch lahir dari satu BarangMasuk, menyimpan jumlah_awal dan sisa dalam botol, lalu hanya boleh berubah lewat satu fungsi: ubah_stok() di backend/stok/layanan.py:21-441. Fungsi itu menambah atau mengurangi sisa, menolak hasil negatif dengan AturanBisnisError berkode stok_minus, dan selalu menulis satu baris MutasiStok berisi sisa_sesudah sebagai jejak audit. Karena AturanBisnisError memetakan ke HTTP 409, pelanggaran aturan stok muncul sebagai konflik, bukan 500 (backend/inti/galat.py:8-182).
Satu pintu perubahan stok
Docstring ubah_stok menyatakan syaratnya secara eksplisit: baris batch harus sudah dikunci pemanggil (backend/stok/layanan.py:21-441). sisa sendiri bertipe IntegerField dengan CheckConstraint bernama sisa_tidak_minus, jadi database ikut menolak nilai negatif meski ada jalur tulis di luar layanan (backend/stok/models.py:29-513). Uji test_database_menolak_sisa_minus membuktikan constraint itu aktif: update(sisa=-1) langsung melempar IntegrityError (backend/tests/test_stok.py:66-694).
Jenis mutasi yang tercatat ditentukan oleh JenisMutasi, sehingga laporan mutasi bisa memisahkan penjualan dari barang rusak dan opname (backend/stok/models.py:57-795).
JenisMutasi | Nilai | Dipakai oleh |
|---|---|---|
MASUK | masuk | catat_barang_masuk |
PENJUALAN | penjualan | alokasi FEFO di _buat_transaksi |
RETUR | retur | buat_retur |
RUSAK | rusak | kurangi_stok dan retur rusak |
KEDALUWARSA | kedaluwarsa | kurangi_stok |
OPNAME | opname | setujui_opname |
BATAL | batal | setujui_pembatalan |
MutasiStok.jumlah memakai konvensi tanda: positif masuk, negatif keluar, semuanya dalam botol (backend/stok/models.py:57-795).
Kunci batch dan urutan FEFO
Ada dua lapis penguncian. kunci_batch() mengunci baris dengan select_for_update() dan selalu mengurutkan id, supaya dua transaksi tidak saling menunggu (backend/stok/layanan.py:47-506). Untuk penjualan, _kunci_batch_fefo() juga mengunci dengan select_for_update(), menyaring lewat layak_jual(), mengurutkan hasil query per id, lalu mengurutkan ulang di memori dengan kunci (kedaluwarsa, id) dan mengelompokkannya per produk (backend/pembelian/layanan.py:68-787).
layak_jual() sendiri berarti sisa__gt=0 dan kedaluwarsa__gte tanggal hari ini (backend/stok/models.py:29-513). Batch kedaluwarsa tetap tersimpan dan tetap bisa dikurangi lewat kurangi_stok dengan jenis kedaluwarsa, tetapi tidak akan pernah terpilih untuk penjualan (backend/stok/layanan.py:91-1028).
sequenceDiagram
autonumber
participant V as TransaksiViewSet
participant L as buat_transaksi
participant F as _kunci_batch_fefo
participant B as BatchProduk
participant S as ubah_stok
participant M as MutasiStok
V->>L: POST transaksi + kunci_idempoten
L->>F: himpunan produk_id
F->>B: select_for_update + layak_jual
B-->>F: batch terkunci urut id
F-->>L: dikelompokkan urut kedaluwarsa
alt stok tersedia kurang
L-->>V: AturanBisnisError stok_tidak_cukup
else stok cukup
loop tiap item dan batch terdekat
L->>S: ubah_stok(batch, -ambil, PENJUALAN)
S->>B: sisa += jumlah lalu save
S->>M: create sisa_sesudah
end
L-->>V: Transaksi tersimpan
end
%% fig: Pemotongan stok lewat jalur FEFO
%% desc: Penjualan mengunci batch layak jual, memilih kedaluwarsa terdekat, lalu memotong sisa hanya lewat ubah_stok yang mencatat mutasi.Pemeriksaan kecukupan dilakukan sebelum satu botol pun dipotong: total sisa per produk dibandingkan dengan kebutuhan, dan kekurangan dilaporkan sebagai stok_tidak_cukup beserta rincian per produk (backend/pembelian/layanan.py:96-1569). Alokasi kemudian berjalan per item dengan ambil = min(perlu, batch.sisa), menulis ItemTransaksiBatch lalu memanggil ubah_stok(batch, -ambil, JenisMutasi.PENJUALAN, ...) (backend/pembelian/layanan.py:96-1569).
⚠️
ubah_stoktidak mengunci apa pun sendiri. Memanggilnya tanpakunci_batchatau_kunci_batch_fefolebih dulu membuka peluang dua penjualan membacasisayang sama. Uji konkurensi menjalankan 8 penjualan paralel untuk 1 botol: 7 gagal dengan kodestok_tidak_cukup, hanya 1 nota terbentuk, dansisaberakhir 0 (backend/tests/test_konkurensi.py:49-6810).
Stok yang dibaca untuk tampilan
Angka stok untuk daftar produk tidak dihitung dari sisa mentah, melainkan dari anotasi di dengan_stok() (backend/produk/layanan.py:15-3411). Tiga nilai dihasilkan sekaligus: stok_tersedia menjumlahkan sisa batch bersisa dan belum kedaluwarsa, stok_kedaluwarsa menjumlahkan batch yang sudah lewat tanggal, dan kedaluwarsa_terdekat mengambil tanggal minimum dari batch layak jual (backend/produk/layanan.py:15-3411). Ambang "hampir kedaluwarsa" memakai HARI_HAMPIR_KEDALUWARSA dengan nilai bawaan 60 hari (backend/config/settings.py:17612).
Barang masuk dan pengurangan manual
catat_barang_masuk() membuat BarangMasuk dengan nomor dari nomor_berikutnya(f"BM-{tanggal:%Y%m%d}", 3), lalu membuat batch baru dengan sisa=0 dan langsung memanggil ubah_stok untuk JenisMutasi.MASUK (backend/stok/layanan.py:53-8813). Nomor dokumen diambil dari NomorUrut yang dikunci select_for_update() sehingga unik antar transaksi (backend/inti/models.py:12-1814). Satuan masuk dikonversi ke botol lewat jumlah * satuan.isi_botol (backend/stok/layanan.py:53-8813).
Validasi sebelum penulisan ada di serializer: kedaluwarsa harus setelah tanggal masuk, satuan harus milik produk yang dipilih, dan nomor_batch tidak boleh dipakai ulang untuk produk yang sama (backend/stok/serializers.py:114-12615).
kurangi_stok() menangani barang rusak dan kedaluwarsa: ia mengunci batch, menolak jumlah > batch.sisa dengan stok_minus, lalu memanggil ubah_stok dengan jumlah negatif (backend/stok/layanan.py:91-1028). Hanya dua jenis yang diizinkan dari endpoint ini, rusak dan kedaluwarsa, dengan jumlah antara 1 dan 10.000.000 serta catatan maksimal 255 karakter (backend/stok/serializers.py:47-5016).
Opname berstatus persetujuan
Opname tidak langsung mengubah stok. buat_opname() mengunci semua batch yang dihitung, menolak pengajuan bila batch yang sama masih berada di opname berstatus menunggu dengan kode opname_bentrok, lalu menyimpan stok_tercatat, stok_fisik, dan selisih per item (backend/stok/layanan.py:105-13317). Nomor opname memakai awalan OP- (backend/stok/layanan.py:105-13317), dan satu batch hanya boleh muncul sekali dalam satu opname lewat constraint batch_unik_per_opname (backend/stok/models.py:82-12518).
stateDiagram-v2
[*] --> menunggu : buat_opname
menunggu --> disetujui : setujui_opname
menunggu --> ditolak : tolak_opname
disetujui --> [*]
ditolak --> [*]
note right of menunggu
setujui_opname memanggil ubah_stok
dengan JenisMutasi.OPNAME bila selisih
end note
note right of disetujui
keputusan ulang ditolak dengan kode opname_selesai
end note
%% fig: Siklus status StokOpname
%% desc: Opname lahir berstatus menunggu, hanya bisa diputuskan sekali, dan stok baru berubah saat disetujui.setujui_opname() mengambil opname dengan select_for_update(), memastikan statusnya masih menunggu (jika tidak, kode opname_selesai), mengunci ulang semua batch item, lalu menerapkan item.selisih lewat ubah_stok bertipe JenisMutasi.OPNAME (backend/stok/layanan.py:136-16019). Komentar pada fungsi itu menjelaskan alasan desainnya: yang diterapkan adalah selisih saat penghitungan, sehingga penjualan yang terjadi setelah hitung tetap terbawa ke sisa akhir (backend/stok/layanan.py:136-16019).
| Langkah | Stok batch 10 botol | stok_fisik | selisih | sisa sesudah |
|---|---|---|---|---|
buat_opname | 10 | 7 | -3 | tetap 10 |
setujui_opname | 10 | 7 | -3 | 7 |
setujui_opname ulang | 7 | 7 | -3 | 7, ditolak 409 |
Baris tabel di atas mengikuti uji test_opname_menerapkan_selisih_saat_disetujui: sebelum persetujuan sisa masih 10, sesudahnya 7, dan permintaan setuju kedua mengembalikan 409 tanpa mengubah apa pun (backend/tests/test_stok.py:91-11320). Kasus penjualan di tengah opname diuji terpisah: batch 10 botol, fisik dihitung 9, lalu terjual 4 botol, dan setelah persetujuan sisa menjadi 5 (backend/tests/test_stok.py:115-12721).
tolak_opname() hanya mengubah status menjadi ditolak dan mengisi diputuskan_oleh serta diputuskan_pada, tanpa menyentuh sisa (backend/stok/layanan.py:163-17022). Uji penolakan memastikan sisa tetap 10 (backend/tests/test_stok.py:130-13723).
⚠️ Satu batch tidak boleh berada di dua opname yang masih
menunggu. Percobaan kedua atas batch yang sama ditolak 409 dengankodebernilaiopname_bentrok(backend/tests/test_stok.py:140-14624). Selesaikan atau tolak opname yang menggantung sebelum menghitung ulang.
Endpoint yang melayani alur ini
Semua rute didaftarkan pada SimpleRouter dengan basename batch, mutasi, barang-masuk, dan opname (backend/config/urls.py:27-3025).
| Endpoint | Aksi | Izin |
|---|---|---|
GET /api/batch/ | list | LIHAT_PRODUK |
GET /api/batch/{id}/ | retrieve | LIHAT_PRODUK |
POST /api/batch/{id}/kurangi/ | kurangi | KELOLA_STOK |
GET /api/mutasi/ | list | LIHAT_PRODUK |
GET /api/barang-masuk/ | list | KELOLA_STOK |
POST /api/barang-masuk/ | create | KELOLA_STOK |
POST /api/opname/ | create | KELOLA_STOK |
POST /api/opname/{id}/setujui/ | setujui | SETUJUI_OPNAME |
POST /api/opname/{id}/tolak/ | tolak | SETUJUI_OPNAME |
Pemisahan izin terlihat pada OpnameViewSet: staf gudang cukup KELOLA_STOK untuk membuat opname, tetapi keputusan hanya untuk pemegang SETUJUI_OPNAME (backend/stok/views.py:91-12726). Uji menegaskan hal ini: klien_gudang mendapat 403 saat mencoba menyetujui, sedangkan klien_pemilik berhasil (backend/tests/test_stok.py:91-11320). Nama izinnya didefinisikan di backend/inti/izin.py:6-927, dan rinciannya dibahas di Autentikasi, 2FA, dan Izin Peran.
BatchViewSet juga menyediakan penyaring produk, ada_sisa=1, q (nomor batch, nama produk, SKU), dan status=kedaluwarsa (backend/stok/views.py:24-5428). Payload opname memakai OpnameBuatSerializer dengan daftar items tidak boleh kosong, tiap item berisi batch dan stok_fisik antara 0 dan 10.000.000 (backend/stok/serializers.py:173-18029).
Alur penjualan, retur, dan pembatalan yang memanggil ubah_stok dari sisi lain dibahas di Alur Transaksi, Pembayaran, Retur, dan Piutang, sedangkan bentuk batch_produk, mutasi_stok, dan stok_opname_item sebagai tabel dibahas di Model Domain dan Constraint Database.