-- Backfill payment_allocations untuk data historis via FIFO simulation. -- Seluruh migration ini berjalan dalam 1 transaction (golang-migrate default). -- Jika ada party yang gagal di tengah loop, seluruh backfill ROLLBACK otomatis. -- Fungsi inti: FIFO greedy untuk 1 party (supplier/customer). -- Algoritma: -- 1. Hapus payment_allocations existing untuk party tsb (idempotent). -- 2. Kumpulkan eligible children sort by date ASC ke array (kind, id, amount, remaining). -- 3. Konsumsi creditCarry (SUM payment SALDO_AWAL) ke children tertua — TIDAK insert allocation row. -- 4. Loop payments (selain SALDO_AWAL) ORDER BY payment_date ASC: greedy alokasi ke child tertua dengan remaining > 0. -- 5. Sisa nominal payment tidak insert row (otomatis credit balance untuk dokumen baru). CREATE OR REPLACE FUNCTION fn_fifo_backfill_party( p_party_type TEXT, p_party_id BIGINT ) RETURNS VOID AS $func$ DECLARE v_party_type TEXT := UPPER(p_party_type); v_payment RECORD; v_child RECORD; v_remaining NUMERIC(15, 3); v_used NUMERIC(15, 3); v_eps CONSTANT NUMERIC(15, 3) := 0.001; BEGIN -- Acquire advisory lock untuk anti-race (1-arg form: hashtext returns int4, cast ke bigint) PERFORM pg_advisory_xact_lock(hashtext('payment_alloc:' || v_party_type || ':' || p_party_id::text)::bigint); -- Hapus allocations existing untuk party tsb (idempotent ulang-jalan) DELETE FROM payment_allocations pa USING payments p WHERE pa.payment_id = p.id AND p.party_type = v_party_type AND p.party_id = p_party_id; -- TEMP table untuk antrian children (sort sudah ada di INSERT...SELECT ORDER BY) CREATE TEMP TABLE IF NOT EXISTS _children_queue ( seq BIGSERIAL PRIMARY KEY, kind TEXT NOT NULL, -- 'PURCHASE_ITEM' / 'MDP' / 'EXPENSE_REALIZATION' child_id BIGINT NOT NULL, amount NUMERIC(15, 3) NOT NULL, remaining NUMERIC(15, 3) NOT NULL ) ON COMMIT DROP; TRUNCATE _children_queue; IF v_party_type = 'SUPPLIER' THEN -- purchase_items eligible: received_date IS NOT NULL, approval latest step >= 4 (Receiving), action != REJECTED INSERT INTO _children_queue (kind, child_id, amount, remaining) SELECT 'PURCHASE_ITEM', pi.id, pi.total_price, pi.total_price FROM purchase_items pi JOIN purchases p ON p.id = pi.purchase_id JOIN LATERAL ( SELECT a.step_number, a.action FROM approvals a WHERE a.approvable_type = 'PURCHASES' AND a.approvable_id = p.id ORDER BY a.action_at DESC, a.id DESC LIMIT 1 ) la ON true WHERE p.supplier_id = p_party_id AND p.deleted_at IS NULL AND pi.received_date IS NOT NULL AND la.step_number >= 4 AND (la.action IS NULL OR la.action <> 'REJECTED') AND pi.total_price > 0 ORDER BY pi.received_date ASC, pi.id ASC; -- expense_realizations eligible: parent expense approval latest step >= 5 (Realisasi), action != REJECTED. -- Sort pakai e.transaction_date supaya FIFO konsisten dengan tanggal yang di-display di report. INSERT INTO _children_queue (kind, child_id, amount, remaining) SELECT 'EXPENSE_REALIZATION', er.id, (er.qty * er.price), (er.qty * er.price) FROM expense_realizations er JOIN expense_nonstocks en ON en.id = er.expense_nonstock_id JOIN expenses e ON e.id = en.expense_id JOIN LATERAL ( SELECT a.step_number, a.action FROM approvals a WHERE a.approvable_type = 'EXPENSES' AND a.approvable_id = e.id ORDER BY a.action_at DESC, a.id DESC LIMIT 1 ) la ON true WHERE e.supplier_id = p_party_id AND e.deleted_at IS NULL AND la.step_number >= 5 AND (la.action IS NULL OR la.action <> 'REJECTED') AND (er.qty * er.price) > 0 ORDER BY e.transaction_date ASC, e.id ASC, er.id ASC; ELSIF v_party_type = 'CUSTOMER' THEN -- marketing_delivery_products eligible: delivery_date IS NOT NULL (match current report behavior, tidak filter approval) INSERT INTO _children_queue (kind, child_id, amount, remaining) SELECT 'MDP', mdp.id, mdp.total_price, mdp.total_price FROM marketing_delivery_products mdp JOIN marketing_products mp ON mp.id = mdp.marketing_product_id JOIN marketings m ON m.id = mp.marketing_id WHERE m.customer_id = p_party_id AND m.deleted_at IS NULL AND mdp.delivery_date IS NOT NULL AND mdp.total_price > 0 ORDER BY mdp.delivery_date ASC, mdp.id ASC; ELSE RETURN; END IF; -- Skip jika tidak ada children eligible IF NOT EXISTS (SELECT 1 FROM _children_queue) THEN RETURN; END IF; -- Loop SEMUA payments termasuk SALDO_AWAL ORDER BY payment_date ASC, id ASC. -- SALDO_AWAL diperlakukan sebagai payment tertua sehingga opening credit otomatis -- consume oldest debts via FIFO. Tanpa allocation row, debt yang ter-cover SaldoAwal -- akan tampak "Belum Lunas" di report. FOR v_payment IN SELECT id, nominal FROM payments WHERE party_type = v_party_type AND party_id = p_party_id AND deleted_at IS NULL AND nominal > v_eps ORDER BY payment_date ASC, id ASC LOOP v_remaining := v_payment.nominal; -- Greedy alokasi ke children tertua dengan remaining > 0 FOR v_child IN SELECT seq, kind, child_id, remaining FROM _children_queue WHERE remaining > v_eps ORDER BY seq ASC LOOP EXIT WHEN v_remaining <= v_eps; -- v_child.remaining is snapshot at cursor open; re-fetch latest to avoid drift in same payment iter SELECT remaining INTO v_used FROM _children_queue WHERE seq = v_child.seq; IF v_used <= v_eps THEN CONTINUE; END IF; v_used := LEAST(v_remaining, v_used); UPDATE _children_queue SET remaining = remaining - v_used WHERE seq = v_child.seq; v_remaining := v_remaining - v_used; IF v_child.kind = 'PURCHASE_ITEM' THEN INSERT INTO payment_allocations (payment_id, purchase_item_id, amount, allocated_at) VALUES (v_payment.id, v_child.child_id, v_used, NOW()); ELSIF v_child.kind = 'MDP' THEN INSERT INTO payment_allocations (payment_id, marketing_delivery_product_id, amount, allocated_at) VALUES (v_payment.id, v_child.child_id, v_used, NOW()); ELSIF v_child.kind = 'EXPENSE_REALIZATION' THEN INSERT INTO payment_allocations (payment_id, expense_realization_id, amount, allocated_at) VALUES (v_payment.id, v_child.child_id, v_used, NOW()); END IF; END LOOP; END LOOP; END; $func$ LANGUAGE plpgsql; -- Invoke per-party. Gagal di satu party → entire transaction ROLLBACK. DO $do$ DECLARE r RECORD; BEGIN FOR r IN SELECT DISTINCT party_type, party_id FROM payments WHERE deleted_at IS NULL AND party_id IS NOT NULL LOOP PERFORM fn_fifo_backfill_party(r.party_type, r.party_id); END LOOP; END; $do$;