05 β Logika Bisnis: Stok, HPP FIFO, & Jurnal (Trigger-First di PL/pgSQL)¶
Dibaca oleh: Database Engineer (dokumen terpenting bagian teknis). Tujuan: memahami tiga mesin inti sistem lama dan cara memindahkannya ke PostgreSQL apa adanya secara perilaku, tetapi rapi & terdokumentasi.
Keputusan pemilik: trigger-first dipertahankan ("supaya logika terkontrol"). Dokumen ini menghormati itu. Yang kita ubah: kerapian, penamaan, keamanan, dan dokumentasi β bukan rumus bisnisnya.
1. Tiga Mesin Inti¶
| Mesin | Tugas | Tabel terkait | Dipicu oleh |
|---|---|---|---|
| Stok Realtime | Jaga stok = jumlah mutasi |
transaksi_detail β stok |
trigger detail (AI/AU/AD) |
| HPP FIFO | Hitung biaya barang keluar (lapisan tertua dulu) | fifo_lapisan, fifo_konsumsi |
trigger detail + saat hitung harga |
| Jurnal | Buat ayat double-entry dari transaksi | transaksi β jurnal |
saat posting transaksi |
Ketiganya saling berurutan dalam satu alur transaksi. Pahami alur di Β§2.
2. Alur Lengkap Satu Transaksi (acuan mental tim)¶
Contoh Penjualan (PJ) β pola yang sama berlaku untuk PB/RJ/RB/OS/PL/BB:
INSERT transaksi (jenis_kode='PJ')
ββ TRIGGER BEFORE INSERT (fn_transaksi_sebelum_insert)
β’ id : otomatis (IDENTITY) β TIDAK lagi max+1
β’ nomor_bukti : ambil_nomor_bukti('PJ') (counter ber-lock, anti-bentrok)
β’ nomor_transaksi = klien.PJ.nomor_bukti
β’ isi default akun dari jenis_transaksi (eks datakode)
INSERT transaksi_detail (per barang)
ββ TRIGGER BEFORE INSERT (fn_detail_sebelum_insert)
β’ urut = max(urut)+1 per transaksi
β’ ambil konfigurasi jenis transaksi (pengaruh_stok, barang_di_debit, ...)
β’ hitung qty = qty_nota * isi_konversi
β’ mutasi_stok = arah(barang_di_debit) * qty (+ masuk / β keluar)
β’ jika barang keluar & hitung_hpp β hpp = ambil_hpp_fifo(barang, qty)
ββ TRIGGER AFTER INSERT (fn_detail_setelah_insert)
β’ hitung_stok(barang, lokasi) β perbarui cache `stok`
β’ catat_fifo(detail, mutasi, hpp) β isi fifo_lapisan / fifo_konsumsi
POSTING transaksi (saat transaksi difinalkan)
ββ posting_jurnal(transaksi_id)
β’ DELETE jurnal lama transaksi ini
β’ INSERT pasangan debit/kredit ke `jurnal` sesuai jenis_transaksi + barang_golongan
β’ verifikasi SUM(debit) = SUM(kredit) (BARU: ditegakkan, dulu tidak)
Prinsip penting yang dipertahankan: stok BUKAN angka yang disimpan lalu ditambah/kurang manual. Stok = hasil hitung
SUM(mutasi_stok). Tabelstokhanya cache yang bisa dibangun ulang kapan saja. Ini desain bagus dari sistem lama β jangan diganti jadi "stok += qty".
2A. Batas Draft vs Transaksi Final (fitur baru V900+)¶
Keputusan desain untuk fitur tracking baru: dokumen yang masih bebas diedit
tidak langsung masuk ke transaksi. Area kerja memakai:
transaksi_draft
transaksi_draft_detail
Dokumen resmi/posting tetap memakai:
transaksi
transaksi_detail
Aturan business logic:
transaksi_draft_detailtidak boleh memicu stok, FIFO, HPP, atau jurnal.- Trigger stok/FIFO/jurnal tetap hanya menempel pada
transaksidantransaksi_detail. - Draft dipakai untuk input awal, revisi, approval, penyiapan, simulasi stok, simulasi pembelian, mutasi, dan produksi.
- Saat draft disetujui, proses konversi membuat
transaksidantransaksi_detailfinal. Pada titik ini trigger existing boleh berjalan. - Tracking realisasi sebagian dari draft ke transaksi final dicatat di:
transaksi_draft_relasi
transaksi_draft_detail_relasi
Relasi antar transaksi final tidak dibuat tabel baru dalam fitur V900+ karena
struktur final sudah punya kolom referensi seperti ref_transaksi_id dan
ref_detail_id.
Contoh:
SO draft 10 pcs
-> transaksi PJ 4 pcs
-> transaksi PJ 6 pcs
Tracking:
qty_draft = SUM(transaksi_draft_detail.qty)
qty_realisasi = SUM(transaksi_draft_detail_relasi.qty)
qty_sisa = qty_draft - qty_realisasi
Konsekuensi penting: status=0 tidak dipakai di transaksi final. Hanya
PJ yang memakai status legacy operasional trsts: 1=Dalam Proses,
2=Cetak Nota, 3=Ready, 4=Selesai. Transaksi non-PJ selalu
4=Selesai karena legacy tidak menerapkan status operasional pada non-PJ
secara konsisten. Editing fleksibel sebelum approval berada di tabel draft.
DDL dan TODO desain ada di:
database/fitur/TRACKING-DRAFT.mdβ desain & alur draft workflowdatabase/fitur/TRACKING-DDL.mdβ DDL tabel (V900βV901)database/fitur/TODO.md
3. Mesin 1 β Stok Realtime¶
Logika asli (disederhanakan dari calc_stok_by_id_il)¶
stok(barang, lokasi) =
SUM(mutasi) dari detail di transaksi yang lokasi-nya = lokasi
β SUM(mutasi) dari detail di transaksi yang lokasi_tujuan-nya = lokasi (pemindahan)
dengan syarat: barang bukan "tanpa_stok", jenis β 'GP', mutasi β 0
Lalu hasilnya di-UPSERT ke tabel cache stok, dan total semua lokasi
diringkas (dulu ke brginfo.stok).
Port ke PL/pgSQL¶
CREATE OR REPLACE FUNCTION logika.hitung_stok(p_barang_id bigint, p_lokasi_id bigint)
RETURNS void
LANGUAGE plpgsql
AS $$
DECLARE
v_qty numeric(18,2);
BEGIN
SELECT COALESCE(SUM(m), 0) INTO v_qty
FROM (
-- barang masuk/keluar di lokasi ini
SELECT d.mutasi_stok AS m
FROM transaksi_detail d
JOIN transaksi t ON t.id = d.transaksi_id
JOIN barang b ON b.id = d.barang_id AND b.tanpa_stok = false
WHERE t.lokasi_id = p_lokasi_id
AND d.barang_id = p_barang_id
AND d.mutasi_stok <> 0
AND t.jenis_kode <> 'GP'
UNION ALL
-- sisi tujuan pemindahan lokasi (tanda dibalik)
SELECT -d.mutasi_stok
FROM transaksi_detail d
JOIN transaksi t ON t.id = d.transaksi_id
JOIN barang b ON b.id = d.barang_id AND b.tanpa_stok = false
WHERE t.lokasi_tujuan_id = p_lokasi_id
AND d.barang_id = p_barang_id
AND d.mutasi_stok <> 0
AND t.jenis_kode <> 'GP'
) x;
INSERT INTO stok (barang_id, lokasi_id, qty, updated_at)
VALUES (p_barang_id, p_lokasi_id, v_qty, now())
ON CONFLICT (barang_id, lokasi_id)
DO UPDATE SET qty = EXCLUDED.qty, updated_at = now();
END;
$$;
COMMENT ON FUNCTION logika.hitung_stok IS
'Hitung ulang cache stok 1 barang di 1 lokasi = SUM(mutasi_stok). Idempoten.';
Trigger pemanggilnya (AFTER INSERT/UPDATE/DELETE pada transaksi_detail):
CREATE OR REPLACE FUNCTION logika.fn_detail_setelah_ubah()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE r record;
BEGIN
r := COALESCE(NEW, OLD); -- baris yang relevan
IF r.mutasi_stok <> 0 THEN
IF r.lokasi_id IS NOT NULL THEN PERFORM logika.hitung_stok(r.barang_id, r.lokasi_id); END IF;
IF r.lokasi_tujuan_id IS NOT NULL THEN PERFORM logika.hitung_stok(r.barang_id, r.lokasi_tujuan_id); END IF;
END IF;
-- pada UPDATE, lokasi/barang bisa berubah β hitung ulang sisi lama juga
IF TG_OP = 'UPDATE' THEN
IF OLD.lokasi_id IS NOT NULL AND OLD.lokasi_id IS DISTINCT FROM NEW.lokasi_id THEN
PERFORM logika.hitung_stok(OLD.barang_id, OLD.lokasi_id);
END IF;
END IF;
RETURN NULL; -- AFTER trigger
END;
$$;
CREATE TRIGGER trg_trxd_ai AFTER INSERT ON transaksi_detail
FOR EACH ROW EXECUTE FUNCTION logika.fn_detail_setelah_ubah();
CREATE TRIGGER trg_trxd_au AFTER UPDATE ON transaksi_detail
FOR EACH ROW EXECUTE FUNCTION logika.fn_detail_setelah_ubah();
CREATE TRIGGER trg_trxd_ad AFTER DELETE ON transaksi_detail
FOR EACH ROW EXECUTE FUNCTION logika.fn_detail_setelah_ubah();
4. Mesin 2 β HPP FIFO¶
Konsep¶
Setiap pembelian membuat satu lapisan (fifo_lapisan): qty masuk + harga.
Saat barang keluar (jual/opname/produksi), sistem mengambil dari lapisan
tertua (urut tanggal & id transaksi) sampai kebutuhan terpenuhi. HPP barang
keluar = rata-rata tertimbang harga lapisan-lapisan yang terpakai.
Dua operasi¶
| Fungsi lama | Fungsi baru | Tugas |
|---|---|---|
a_getfifo |
logika.ambil_hpp_fifo(barang, qty) β numeric |
Hitung HPP (tanpa mengubah data). Dipakai trigger BEFORE INSERT detail untuk mengisi hpp. |
a_setfifo |
logika.catat_fifo(detail_id, transaksi_id, barang, qty_signed, harga) |
Catat pergerakan: qty>0 β buat/ubah lapisan masuk; qty<0 β konsumsi lapisan tertua, isi fifo_konsumsi. |
ambil_hpp_fifo (port dari a_getfifo)¶
CREATE OR REPLACE FUNCTION logika.ambil_hpp_fifo(p_barang_id bigint, p_butuh numeric)
RETURNS numeric
LANGUAGE plpgsql AS $$
DECLARE
v_butuh numeric := ABS(p_butuh);
v_ambil numeric := 0;
v_total numeric := 0; -- akumulasi (harga * ambil)
v_terambil numeric := 0;
v_harga numeric := 0;
rec record;
BEGIN
FOR rec IN
SELECT f.qty_sisa, f.harga
FROM fifo_lapisan f
JOIN transaksi t ON t.id = f.transaksi_id
WHERE f.qty_sisa > 0 AND f.barang_id = p_barang_id
ORDER BY t.tanggal, t.id, f.id -- FIFO: tertua dulu
LOOP
v_ambil := LEAST(v_butuh - v_terambil, rec.qty_sisa);
v_total := v_total + (rec.harga * v_ambil);
v_terambil := v_terambil + v_ambil;
EXIT WHEN v_terambil >= v_butuh;
END LOOP;
IF v_terambil > 0 THEN
v_harga := v_total / v_terambil; -- rata-rata tertimbang
END IF;
-- fallback: bila stok habis, pakai harga beli terakhir (perilaku lama)
IF COALESCE(v_harga, 0) = 0 THEN
SELECT f.harga INTO v_harga
FROM fifo_lapisan f
JOIN transaksi t ON t.id = f.transaksi_id
WHERE f.barang_id = p_barang_id AND f.harga <> 0
ORDER BY t.tanggal DESC, t.id DESC
LIMIT 1;
END IF;
RETURN COALESCE(v_harga, 0);
END;
$$;
catat_fifo (port dari a_setfifo)¶
CREATE OR REPLACE FUNCTION logika.catat_fifo(
p_detail_id bigint, p_transaksi_id bigint,
p_barang_id bigint, p_qty numeric, p_harga numeric)
RETURNS void
LANGUAGE plpgsql AS $$
DECLARE
v_butuh numeric;
v_ambil numeric;
v_terambil numeric := 0;
rec record;
BEGIN
IF p_qty = 0 THEN RETURN; END IF;
IF p_qty > 0 THEN
-- BARANG MASUK β buat / perbarui lapisan
INSERT INTO fifo_lapisan
(detail_masuk_id, transaksi_id, barang_id, qty_masuk, harga, qty_keluar, qty_sisa)
VALUES (p_detail_id, p_transaksi_id, p_barang_id, p_qty, p_harga, 0, p_qty)
ON CONFLICT (detail_masuk_id) DO UPDATE
SET qty_masuk = EXCLUDED.qty_masuk,
harga = EXCLUDED.harga,
qty_sisa = EXCLUDED.qty_masuk - fifo_lapisan.qty_keluar;
ELSE
-- BARANG KELUAR β konsumsi lapisan tertua (FIFO)
v_butuh := -p_qty;
DELETE FROM fifo_konsumsi WHERE detail_keluar_id = p_detail_id;
-- kunci baris agar aman saat banyak transaksi bersamaan
PERFORM 1 FROM fifo_lapisan
WHERE barang_id = p_barang_id AND qty_sisa > 0 FOR UPDATE;
FOR rec IN
SELECT f.id, f.qty_sisa
FROM fifo_lapisan f
JOIN transaksi t ON t.id = f.transaksi_id
WHERE f.qty_sisa > 0 AND f.barang_id = p_barang_id
ORDER BY t.tanggal, t.id, f.id
LOOP
v_ambil := LEAST(v_butuh - v_terambil, rec.qty_sisa);
INSERT INTO fifo_konsumsi
(lapisan_id, detail_keluar_id, transaksi_id, barang_id, qty)
VALUES (rec.id, p_detail_id, p_transaksi_id, p_barang_id, v_ambil);
UPDATE fifo_lapisan
SET qty_keluar = qty_keluar + v_ambil,
qty_sisa = qty_sisa - v_ambil
WHERE id = rec.id;
v_terambil := v_terambil + v_ambil;
EXIT WHEN v_terambil >= v_butuh;
END LOOP;
END IF;
END;
$$;
Perubahan penting vs lama: di MySQL, kunci antar-transaksi pakai
SELECT ... FOR UPDATEad-hoc & variabel@dummy. Di PG kita pertahankan penguncian baris (FOR UPDATE) supaya HPP tetap benar saat transaksi padat, tetapi dengan variabel lokal yang aman untuk connection pool.
5. Mesin 3 β Jurnal (Posting Double-Entry)¶
Logika asli (a_setjurnaltransaksi)¶
Untuk satu transaksi:
1. Hapus jurnal lama transaksi itu (DELETE FROM jurnal WHERE transaksi = ...).
2. Ambil konfigurasi akun dari jenis_transaksi (eks datakode): akun_utama,
akun_kredit, akun_diskon, akun_biaya, akun_hpp, akun_pembulatan,
serta flag kas_di_debit.
3. Susun pasangan debit/kredit ke jurnal berdasarkan jenis transaksi:
- Nilai pokok (piutang/hutang/kas) ke akun lawan.
- Diskon, biaya, pembulatan ke akun masing-masing.
- Pembayaran (dibayar) ke akun kas.
- Mutasi persediaan: qty Γ hpp ke akun persediaan per golongan barang
(barang_golongan.akun_*).
- Untuk PJ/RJ: tambahan akun HPP dan akun penjualan per golongan.
- Untuk OS (opname): akun opname per golongan.
4. (Baru) Verifikasi SUM(debit) = SUM(kredit) sebelum commit.
Pendekatan port¶
Karena ini logika bercabang panjang berbasis jenis_kode, jangan port jadi
satu fungsi raksasa 500 baris yang sulit dibaca (itu mengulang dosa sistem lama).
Pecah jadi fungsi kecil yang jelas:
posting_jurnal(transaksi_id)
ββ hapus_jurnal(transaksi_id)
ββ jurnal_nilai_pokok(transaksi_id)
ββ jurnal_diskon_biaya_pembulatan(transaksi_id)
ββ jurnal_pembayaran(transaksi_id)
ββ jurnal_mutasi_persediaan(transaksi_id) -- per barang_golongan
ββ jurnal_hpp_dan_penjualan(transaksi_id) -- khusus PJ/RJ
ββ jurnal_opname(transaksi_id) -- khusus OS
ββ verifikasi_jurnal_imbang(transaksi_id) -- RAISE EXCEPTION bila tak imbang
Kerangka fungsi pengatur:
CREATE OR REPLACE FUNCTION logika.posting_jurnal(p_transaksi_id bigint)
RETURNS void LANGUAGE plpgsql AS $$
DECLARE v_jenis varchar(3);
BEGIN
SELECT jenis_kode INTO v_jenis FROM transaksi WHERE id = p_transaksi_id;
PERFORM logika.hapus_jurnal(p_transaksi_id);
PERFORM logika.jurnal_nilai_pokok(p_transaksi_id);
PERFORM logika.jurnal_diskon_biaya_pembulatan(p_transaksi_id);
PERFORM logika.jurnal_pembayaran(p_transaksi_id);
IF v_jenis NOT IN ('PL','GP') THEN
PERFORM logika.jurnal_mutasi_persediaan(p_transaksi_id);
END IF;
IF v_jenis IN ('PJ','RJ') THEN
PERFORM logika.jurnal_hpp_dan_penjualan(p_transaksi_id);
END IF;
IF v_jenis = 'OS' THEN
PERFORM logika.jurnal_opname(p_transaksi_id);
END IF;
PERFORM logika.verifikasi_jurnal_imbang(p_transaksi_id); -- penjaga BARU
END;
$$;
CREATE OR REPLACE FUNCTION logika.verifikasi_jurnal_imbang(p_transaksi_id bigint)
RETURNS void LANGUAGE plpgsql AS $$
DECLARE v_d numeric; v_k numeric;
BEGIN
SELECT COALESCE(SUM(debit),0), COALESCE(SUM(kredit),0)
INTO v_d, v_k FROM jurnal WHERE transaksi_id = p_transaksi_id;
IF round(v_d,2) <> round(v_k,2) THEN
RAISE EXCEPTION 'Jurnal tidak imbang utk transaksi %: debit=% kredit=%',
p_transaksi_id, v_d, v_k;
END IF;
END;
$$;
Detail isi tiap sub-fungsi diturunkan langsung dari
a_setjurnaltransaksilama (sudah dianalisis; lihat Lampiran Β§9). Tugas Database Engineer: port per sub-fungsi, lalu bandingkan baris jurnal lama vs baru untuk transaksi sampel sampai identik (lihat Dokumen 06 Β§rekonsiliasi).
6. Urutan Eksekusi Trigger di PostgreSQL (penting!)¶
MySQL lama punya beberapa trigger untuk event yang sama (mis. t punya
t_tr_bi, t_tr_bi_cek). PostgreSQL menjalankan banyak trigger pada event sama
berurutan menurut NAMA trigger (alfabet). Konsekuensi untuk tim:
- Jangan andalkan urutan kebetulan. Beri nama bernomor jika urutan penting:
trg_10_trx_bi_nomor,trg_20_trx_bi_default. - Lebih disarankan: satu fungsi trigger per event yang memanggil langkah-langkah secara eksplisit (lebih mudah dibaca tim daripada banyak trigger tersembunyi).
BEFOREtrigger boleh mengubahNEWdan harusRETURN NEW.AFTERtriggerRETURN NULL. Patuhi konsisten.
7. Mengganti "Sinyal Kontrol cek" (utang K5)¶
Sistem lama memakai cek = 56/66/7 untuk menyuruh trigger melewati sebagian
logika (mis. saat impor massal / proses internal). Ini logika tak terbaca.
Gantinya di sistem baru:
Kebutuhan lama (via cek) |
Pengganti baru |
|---|---|
| "jangan jalankan logika berat saat impor data" | Saat ETL/migrasi: nonaktifkan trigger lalu hitung_stok/posting_jurnal massal di akhir (lihat Dokumen 06) |
| "baris ini hasil proses internal, perlakukan beda" | Kolom eksplisit asal_proses varchar (mis. 'manual','import','produksi') yang dicek fungsi dengan nama jelas |
| "tahap perhitungan, jangan posting dulu" | Untuk fitur baru V900+: pakai transaksi_draft / transaksi_draft_detail; untuk dokumen final tetap gunakan transaksi.status sebagai state final/posting |
Aturan tim: tidak boleh ada angka ajaib di trigger. Setiap percabangan harus lewat kolom/parameter bernama yang ada di Kamus Data (Dokumen 02).
8. Prinsip Trigger-First yang Dipegang Tim¶
- Logika inti (stok, HPP, jurnal) wajib di DB β konsisten untuk semua aplikasi (Delphi/Flutter/Web).
- Setiap fungsi/trigger wajib
COMMENT ON(1 kalimat: apa & kapan jalan) dan terdaftar di dokumen ini. - Fungsi harus kecil & bernama kerja jelas (
hitung_stok, bukan1updatestok). - Fungsi harus idempoten bila mungkin (boleh dijalankan ulang tanpa merusak) β penting untuk perbaikan data & migrasi.
- Tidak ada SQL disimpan sebagai data. Tidak ada
EXECUTEstring dinamis dari kolom. Logika = kode PL/pgSQL yang di-review di Git. - Setiap perubahan logika = skrip migrasi bernomor + uji rekonsiliasi.
9. Lampiran β Inventaris Fungsi Lama β Baru (kerangka)¶
Database Engineer melengkapi tabel ini saat porting (status: β belum / β sebagian / β selesai & diuji).
β οΈ Penyesuaian schema-per-tenant (database/docs/00): fungsi bersama berada di schema
logika(ikutsearch_path, TANPASET search_path). Tabel tenant dirujuk tanpa prefix. Implementasi nyata:database/ddl/11_logika_fungsi.sql. Diuji end-to-end (PBβFIFO/stok, PJβHPP/stok) & lintas-tenant 2026-05-17.
| Lama | Baru (schema logika) |
Mesin | Status |
|---|---|---|---|
getidtrans / getnobukti |
logika.ambil_nomor_bukti() (IDENTITY + advisory-lock) |
ID | β diuji (PB.1/PJ.1) |
calc_stok_by_id_il |
logika.hitung_stok |
Stok | β diuji (stok=SUM mutasi) |
a_getfifo / getfifo |
logika.ambil_hpp_fifo |
FIFO | β diuji (HPP=biaya FIFO) |
a_setfifo / setfifo |
logika.catat_fifo |
FIFO | β diuji (lapisan/konsumsi) |
a_setjurnaltransaksi |
logika.posting_jurnal (+ sub-fungsi) |
Jurnal | β orchestrator+verifier β; sub-fungsi STUB β FASE E (rekonsiliasi CoA) |
a_posttrans / posttrans |
logika.posting_jurnal |
Jurnal | β (sda) |
a_recalckredit / a_repair_kredit* |
logika.hitung_ulang_piutang |
Jurnal | β FASE E |
d_tr_bi/ai/au/ad, t_tr_bi/ai/... |
logika.fn_transaksi_* + trigger trg_transaksi* |
semua | β inti diuji; nuansa legacy (harga tier, retur refid) FASE E |
updatestok*, a_repairstok*, cekstok |
logika.bangun_ulang_stok() (utilitas) |
Stok | β dibuat & diuji untuk rebuild cache stok tenant |
periodic*,omset*,neraca*,rugilaba* |
view/fungsi laporan (per-tenant / logika SRF) |
Laporan | β FASE E |
| ~150 prosedur sisanya | inventaris: dipakai? duplikat? buang? | β | β FASE E |
Tanpa Lampiran ini terisi penuh, cutover tidak boleh dilakukan β bisa ada fitur yang diam-diam hilang. Ini syarat di Dokumen 06.