Lompat ke konten
dokumentasi.id
Commit 32b2830
v1
Star0

Kamu membaca versi lama. Halaman ini hanya bisa dibaca.

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-44. 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-18).

Satu pintu perubahan stok

Docstring ubah_stok menyatakan syaratnya secara eksplisit: baris batch harus sudah dikunci pemanggil (backend/stok/layanan.py:21-44). 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-51). Uji test_database_menolak_sisa_minus membuktikan constraint itu aktif: update(sisa=-1) langsung melempar IntegrityError (backend/tests/test_stok.py:66-69).

Jenis mutasi yang tercatat ditentukan oleh JenisMutasi, sehingga laporan mutasi bisa memisahkan penjualan dari barang rusak dan opname (backend/stok/models.py:57-79).

JenisMutasiNilaiDipakai oleh
MASUKmasukcatat_barang_masuk
PENJUALANpenjualanalokasi FEFO di _buat_transaksi
RETURreturbuat_retur
RUSAKrusakkurangi_stok dan retur rusak
KEDALUWARSAkedaluwarsakurangi_stok
OPNAMEopnamesetujui_opname
BATALbatalsetujui_pembatalan

MutasiStok.jumlah memakai konvensi tanda: positif masuk, negatif keluar, semuanya dalam botol (backend/stok/models.py:57-79).

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-50). 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-78).

layak_jual() sendiri berarti sisa__gt=0 dan kedaluwarsa__gte tanggal hari ini (backend/stok/models.py:29-51). 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-102).

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.
Fig. 1: Pemotongan stok lewat jalur FEFOPenjualan 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-156). 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-156).

⚠️ ubah_stok tidak mengunci apa pun sendiri. Memanggilnya tanpa kunci_batch atau _kunci_batch_fefo lebih dulu membuka peluang dua penjualan membaca sisa yang sama. Uji konkurensi menjalankan 8 penjualan paralel untuk 1 botol: 7 gagal dengan kode stok_tidak_cukup, hanya 1 nota terbentuk, dan sisa berakhir 0 (backend/tests/test_konkurensi.py:49-68).

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-34). 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-34). Ambang "hampir kedaluwarsa" memakai HARI_HAMPIR_KEDALUWARSA dengan nilai bawaan 60 hari (backend/config/settings.py:176).

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-88). Nomor dokumen diambil dari NomorUrut yang dikunci select_for_update() sehingga unik antar transaksi (backend/inti/models.py:12-18). Satuan masuk dikonversi ke botol lewat jumlah * satuan.isi_botol (backend/stok/layanan.py:53-88).

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-126).

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-102). 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-50).

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-133). Nomor opname memakai awalan OP- (backend/stok/layanan.py:105-133), dan satu batch hanya boleh muncul sekali dalam satu opname lewat constraint batch_unik_per_opname (backend/stok/models.py:82-125).

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.
Fig. 2: Siklus status StokOpnameOpname 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-160). 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-160).

LangkahStok batch 10 botolstok_fisikselisihsisa sesudah
buat_opname107-3tetap 10
setujui_opname107-37
setujui_opname ulang77-37, 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-113). 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-127).

tolak_opname() hanya mengubah status menjadi ditolak dan mengisi diputuskan_oleh serta diputuskan_pada, tanpa menyentuh sisa (backend/stok/layanan.py:163-170). Uji penolakan memastikan sisa tetap 10 (backend/tests/test_stok.py:130-137).

⚠️ Satu batch tidak boleh berada di dua opname yang masih menunggu. Percobaan kedua atas batch yang sama ditolak 409 dengan kode bernilai opname_bentrok (backend/tests/test_stok.py:140-146). 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-30).

EndpointAksiIzin
GET /api/batch/listLIHAT_PRODUK
GET /api/batch/{id}/retrieveLIHAT_PRODUK
POST /api/batch/{id}/kurangi/kurangiKELOLA_STOK
GET /api/mutasi/listLIHAT_PRODUK
GET /api/barang-masuk/listKELOLA_STOK
POST /api/barang-masuk/createKELOLA_STOK
POST /api/opname/createKELOLA_STOK
POST /api/opname/{id}/setujui/setujuiSETUJUI_OPNAME
POST /api/opname/{id}/tolak/tolakSETUJUI_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-127). Uji menegaskan hal ini: klien_gudang mendapat 403 saat mencoba menyetujui, sedangkan klien_pemilik berhasil (backend/tests/test_stok.py:91-113). Nama izinnya didefinisikan di backend/inti/izin.py:6-9, 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-54). 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-180).

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.