Lewati ke isi

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). Tabel stok hanya 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_detail tidak boleh memicu stok, FIFO, HPP, atau jurnal.
  • Trigger stok/FIFO/jurnal tetap hanya menempel pada transaksi dan transaksi_detail.
  • Draft dipakai untuk input awal, revisi, approval, penyiapan, simulasi stok, simulasi pembelian, mutasi, dan produksi.
  • Saat draft disetujui, proses konversi membuat transaksi dan transaksi_detail final. 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:


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 UPDATE ad-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_setjurnaltransaksi lama (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).
  • BEFORE trigger boleh mengubah NEW dan harus RETURN NEW. AFTER trigger RETURN 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

  1. Logika inti (stok, HPP, jurnal) wajib di DB β†’ konsisten untuk semua aplikasi (Delphi/Flutter/Web).
  2. Setiap fungsi/trigger wajib COMMENT ON (1 kalimat: apa & kapan jalan) dan terdaftar di dokumen ini.
  3. Fungsi harus kecil & bernama kerja jelas (hitung_stok, bukan 1updatestok).
  4. Fungsi harus idempoten bila mungkin (boleh dijalankan ulang tanpa merusak) β€” penting untuk perbaikan data & migrasi.
  5. Tidak ada SQL disimpan sebagai data. Tidak ada EXECUTE string dinamis dari kolom. Logika = kode PL/pgSQL yang di-review di Git.
  6. 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 (ikut search_path, TANPA SET 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.