From 8da2b7a3ab6f4b39104420116b1a887beffa8db6 Mon Sep 17 00:00:00 2001 From: giovanni Date: Fri, 29 May 2026 00:59:42 +0700 Subject: [PATCH] ini ar fifo --- .../service/common.fifo_payment.service.go | 393 ++++++++++++++++ ...rand_total_to_marketings_expenses.down.sql | 3 + ..._grand_total_to_marketings_expenses.up.sql | 42 ++ ...175642_create_payment_allocations.down.sql | 5 + ...28175642_create_payment_allocations.up.sql | 27 ++ ...5729_backfill_payment_allocations.down.sql | 4 + ...175729_backfill_payment_allocations.up.sql | 170 +++++++ internal/entities/expense.go | 1 + internal/entities/marketing.go | 1 + internal/entities/payment_allocation.go | 23 + internal/entities/purchase.go | 1 + internal/modules/expenses/module.go | 3 +- .../expenses/services/expense.service.go | 26 +- internal/modules/finance/payments/module.go | 4 +- .../payments/services/payment.service.go | 50 ++- .../modules/inventory/transfers/module.go | 1 + internal/modules/marketing/module.go | 3 +- .../services/deliveryorder.service.go | 26 ++ internal/modules/purchases/module.go | 3 + .../purchases/services/purchase.service.go | 13 + .../repports/services/repport.service.go | 420 +++++++++--------- 21 files changed, 1010 insertions(+), 209 deletions(-) create mode 100644 internal/common/service/common.fifo_payment.service.go create mode 100644 internal/database/migrations/20260528175508_add_grand_total_to_marketings_expenses.down.sql create mode 100644 internal/database/migrations/20260528175508_add_grand_total_to_marketings_expenses.up.sql create mode 100644 internal/database/migrations/20260528175642_create_payment_allocations.down.sql create mode 100644 internal/database/migrations/20260528175642_create_payment_allocations.up.sql create mode 100644 internal/database/migrations/20260528175729_backfill_payment_allocations.down.sql create mode 100644 internal/database/migrations/20260528175729_backfill_payment_allocations.up.sql create mode 100644 internal/entities/payment_allocation.go diff --git a/internal/common/service/common.fifo_payment.service.go b/internal/common/service/common.fifo_payment.service.go new file mode 100644 index 00000000..0d3cd981 --- /dev/null +++ b/internal/common/service/common.fifo_payment.service.go @@ -0,0 +1,393 @@ +package service + +import ( + "context" + "fmt" + "math" + "strings" + "time" + + "github.com/sirupsen/logrus" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" +) + +// ParentKind enumerasi parent yang punya grand_total dari SUM children. +type ParentKind string + +const ( + ParentKindPurchase ParentKind = "PURCHASE" + ParentKindMarketing ParentKind = "MARKETING" + ParentKindExpense ParentKind = "EXPENSE" +) + +// AllocationKind enumerasi sub-row anak target FIFO allocation. +type AllocationKind string + +const ( + AllocKindPurchaseItem AllocationKind = "PURCHASE_ITEM" + AllocKindMarketingDeliveryProduct AllocationKind = "MDP" + AllocKindExpenseRealization AllocationKind = "EXPENSE_REALIZATION" +) + +// fifoEpsilon untuk float comparison saat FIFO matching. +const fifoEpsilon = 0.001 + +// FifoPaymentService meng-orchestrate FIFO allocation antara payments dan +// sub-row anak (purchase_items / marketing_delivery_products / expense_realizations). +type FifoPaymentService interface { + // ReallocateForParty wipe allocations untuk semua payment party tsb, + // lalu re-FIFO dari history (sort children by date ASC, payments by payment_date ASC). + // Caller WAJIB pass tx untuk konsistensi dengan mutasi upstream. + ReallocateForParty(ctx context.Context, tx *gorm.DB, partyType string, partyID uint) error + + // RecomputeGrandTotal refresh parent.grand_total = SUM children eligible amount. + RecomputeGrandTotal(ctx context.Context, tx *gorm.DB, kind ParentKind, parentID uint) error +} + +type fifoPaymentService struct { + db *gorm.DB + logger *logrus.Logger +} + +func NewFifoPaymentService(db *gorm.DB, logger *logrus.Logger) FifoPaymentService { + if logger == nil { + logger = logrus.StandardLogger() + } + return &fifoPaymentService{db: db, logger: logger} +} + +func (s *fifoPaymentService) txOrDB(tx *gorm.DB) *gorm.DB { + if tx != nil { + return tx + } + return s.db +} + +type childRow struct { + Kind AllocationKind + ChildID uint64 + Amount float64 + Remaining float64 +} + +type paymentRow struct { + ID uint + Nominal float64 + Date time.Time +} + +// ReallocateForParty acquire advisory lock then perform full re-FIFO. +// Jika tx nil, function buka transaction sendiri (advisory lock harus dalam TX). +func (s *fifoPaymentService) ReallocateForParty(ctx context.Context, tx *gorm.DB, partyType string, partyID uint) error { + if partyID == 0 { + return nil + } + party := strings.ToUpper(strings.TrimSpace(partyType)) + if party != string(utils.PaymentPartyCustomer) && party != string(utils.PaymentPartySupplier) { + return fmt.Errorf("fifoPayment: invalid party_type %q", partyType) + } + if tx == nil { + return s.db.WithContext(ctx).Transaction(func(innerTx *gorm.DB) error { + return s.reallocateInTx(ctx, innerTx, party, partyID) + }) + } + return s.reallocateInTx(ctx, tx, party, partyID) +} + +func (s *fifoPaymentService) reallocateInTx(ctx context.Context, tx *gorm.DB, party string, partyID uint) error { + db := tx.WithContext(ctx) + + // Advisory lock per (party_type, party_id) — 1-arg form (bigint). + // Postgres 2-arg form butuh kedua param int4, sedangkan party_id bisa lebih besar. + lockKey := fmt.Sprintf("payment_alloc:%s:%d", party, partyID) + if err := db.Exec("SELECT pg_advisory_xact_lock(hashtext(?)::bigint)", lockKey).Error; err != nil { + return fmt.Errorf("fifoPayment: advisory lock: %w", err) + } + + // Wipe existing allocations untuk semua payment party tsb + if err := db.Exec(` + DELETE FROM payment_allocations + WHERE payment_id IN ( + SELECT id FROM payments + WHERE party_type = ? AND party_id = ? AND deleted_at IS NULL + ) + `, party, partyID).Error; err != nil { + return fmt.Errorf("fifoPayment: wipe allocations: %w", err) + } + + children, err := s.fetchChildren(ctx, db, party, partyID) + if err != nil { + return err + } + if len(children) == 0 { + return nil + } + + // Fetch SEMUA payments termasuk SALDO_AWAL agar allocation tercatat di DB + // (SaldoAwal opening credit harus consume oldest debts; tanpa allocation row, + // debt yang ter-cover SaldoAwal akan tampak "Belum Lunas" di report). + payments, err := s.fetchAllPayments(ctx, db, party, partyID) + if err != nil { + return err + } + + // Greedy: per payment, alokasi ke children tertua dengan remaining > 0 + allocs := make([]entity.PaymentAllocation, 0, len(payments)) + now := time.Now() + for _, pay := range payments { + remaining := pay.Nominal + if remaining <= fifoEpsilon { + continue + } + for i := range children { + if remaining <= fifoEpsilon { + break + } + if children[i].Remaining <= fifoEpsilon { + continue + } + used := math.Min(remaining, children[i].Remaining) + children[i].Remaining -= used + remaining -= used + + alloc := entity.PaymentAllocation{ + PaymentId: pay.ID, + Amount: used, + AllocatedAt: now, + } + switch children[i].Kind { + case AllocKindPurchaseItem: + id := uint(children[i].ChildID) + alloc.PurchaseItemId = &id + case AllocKindMarketingDeliveryProduct: + id := uint(children[i].ChildID) + alloc.MarketingDeliveryProductId = &id + case AllocKindExpenseRealization: + id := children[i].ChildID + alloc.ExpenseRealizationId = &id + } + allocs = append(allocs, alloc) + } + } + + if len(allocs) == 0 { + return nil + } + // Batch insert allocations + if err := db.CreateInBatches(&allocs, 500).Error; err != nil { + return fmt.Errorf("fifoPayment: insert allocations: %w", err) + } + return nil +} + +// fetchChildren return eligible sub-rows sorted by date ASC, id ASC. +func (s *fifoPaymentService) fetchChildren(ctx context.Context, db *gorm.DB, party string, partyID uint) ([]childRow, error) { + if party == string(utils.PaymentPartySupplier) { + return s.fetchSupplierChildren(ctx, db, partyID) + } + return s.fetchCustomerChildren(ctx, db, partyID) +} + +func (s *fifoPaymentService) fetchSupplierChildren(ctx context.Context, db *gorm.DB, supplierID uint) ([]childRow, error) { + // purchase_items eligible: purchases approval latest step >= Receiving (4), action != REJECTED, received_date IS NOT NULL + var purchaseRows []chronoRow + purchaseSQL := ` + SELECT 'PURCHASE_ITEM' AS kind, + pi.id::BIGINT AS child_id, + pi.total_price AS amount, + pi.received_date AS sort_date, + pi.id::BIGINT AS sort_id + 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 = ? AND a.approvable_id = p.id + ORDER BY a.action_at DESC, a.id DESC + LIMIT 1 + ) la ON TRUE + WHERE p.supplier_id = ? + AND p.deleted_at IS NULL + AND pi.received_date IS NOT NULL + AND la.step_number >= ? + AND (la.action IS NULL OR la.action <> ?) + AND pi.total_price > 0 + ORDER BY pi.received_date ASC, pi.id ASC + ` + if err := db.WithContext(ctx).Raw(purchaseSQL, + string(utils.ApprovalWorkflowPurchase), + supplierID, + uint16(utils.PurchaseStepReceiving), + string(entity.ApprovalActionRejected), + ).Scan(&purchaseRows).Error; err != nil { + return nil, fmt.Errorf("fifoPayment: fetch purchase items: %w", err) + } + + // expense_realizations via expense_nonstocks → expenses, approval latest step >= Realisasi (5) + // Sort pakai e.transaction_date (bukan realization_date) supaya FIFO match dengan tanggal yang + // dipakai report sebagai "tanggal dokumen" — user assume FIFO = lunasi yang transaction_date paling tua dulu. + var expenseRows []chronoRow + expenseSQL := ` + SELECT 'EXPENSE_REALIZATION' AS kind, + er.id::BIGINT AS child_id, + (er.qty * er.price) AS amount, + e.transaction_date AS sort_date, + er.id::BIGINT AS sort_id + 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 = ? AND a.approvable_id = e.id + ORDER BY a.action_at DESC, a.id DESC + LIMIT 1 + ) la ON TRUE + WHERE e.supplier_id = ? + AND e.deleted_at IS NULL + AND la.step_number >= ? + AND (la.action IS NULL OR la.action <> ?) + AND (er.qty * er.price) > 0 + ORDER BY e.transaction_date ASC, e.id ASC, er.id ASC + ` + if err := db.WithContext(ctx).Raw(expenseSQL, + string(utils.ApprovalWorkflowExpense), + supplierID, + uint16(utils.ExpenseStepRealisasi), + string(entity.ApprovalActionRejected), + ).Scan(&expenseRows).Error; err != nil { + return nil, fmt.Errorf("fifoPayment: fetch expense realizations: %w", err) + } + + // Merge in chronological order (kedua list sudah sorted; merge stable) + merged := mergeSortedByDate(purchaseRows, expenseRows) + out := make([]childRow, 0, len(merged)) + for _, r := range merged { + out = append(out, childRow{ + Kind: AllocationKind(r.Kind), + ChildID: r.ChildID, + Amount: r.Amount, + Remaining: r.Amount, + }) + } + return out, nil +} + +func (s *fifoPaymentService) fetchCustomerChildren(ctx context.Context, db *gorm.DB, customerID uint) ([]childRow, error) { + var mdpRows []chronoRow + sql := ` + SELECT 'MDP' AS kind, + mdp.id::BIGINT AS child_id, + mdp.total_price AS amount, + mdp.delivery_date AS sort_date, + mdp.id::BIGINT AS sort_id + 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 = ? + 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 + ` + if err := db.WithContext(ctx).Raw(sql, customerID).Scan(&mdpRows).Error; err != nil { + return nil, fmt.Errorf("fifoPayment: fetch marketing delivery products: %w", err) + } + out := make([]childRow, 0, len(mdpRows)) + for _, r := range mdpRows { + out = append(out, childRow{ + Kind: AllocationKind(r.Kind), + ChildID: r.ChildID, + Amount: r.Amount, + Remaining: r.Amount, + }) + } + return out, nil +} + +// fetchAllPayments return SEMUA payments (termasuk SALDO_AWAL) sort by payment_date ASC, id ASC. +// SALDO_AWAL diperlakukan sebagai payment tertua agar opening credit otomatis consume oldest debts via FIFO. +func (s *fifoPaymentService) fetchAllPayments(ctx context.Context, db *gorm.DB, party string, partyID uint) ([]paymentRow, error) { + var rows []paymentRow + sql := ` + SELECT id, nominal, payment_date AS date + FROM payments + WHERE party_type = ? AND party_id = ? + AND deleted_at IS NULL + AND nominal > 0 + ORDER BY payment_date ASC, id ASC + ` + if err := db.WithContext(ctx).Raw(sql, party, partyID).Scan(&rows).Error; err != nil { + return nil, fmt.Errorf("fifoPayment: fetch payments: %w", err) + } + return rows, nil +} + +// RecomputeGrandTotal refresh parent.grand_total dari SUM children eligible amount. +func (s *fifoPaymentService) RecomputeGrandTotal(ctx context.Context, tx *gorm.DB, kind ParentKind, parentID uint) error { + db := s.txOrDB(tx).WithContext(ctx) + if parentID == 0 { + return nil + } + switch kind { + case ParentKindPurchase: + return db.Exec(` + UPDATE purchases p + SET grand_total = COALESCE((SELECT SUM(total_price) FROM purchase_items WHERE purchase_id = p.id), 0) + WHERE p.id = ? + `, parentID).Error + case ParentKindMarketing: + return db.Exec(` + UPDATE marketings m + SET grand_total = COALESCE(( + SELECT SUM(mdp.total_price) + FROM marketing_delivery_products mdp + JOIN marketing_products mp ON mp.id = mdp.marketing_product_id + WHERE mp.marketing_id = m.id AND mdp.delivery_date IS NOT NULL + ), 0) + WHERE m.id = ? + `, parentID).Error + case ParentKindExpense: + return db.Exec(` + UPDATE expenses e + SET grand_total = COALESCE(( + SELECT SUM(er.qty * er.price) + FROM expense_realizations er + JOIN expense_nonstocks en ON en.id = er.expense_nonstock_id + WHERE en.expense_id = e.id + ), 0) + WHERE e.id = ? + `, parentID).Error + default: + return fmt.Errorf("fifoPayment: unknown parent kind %q", kind) + } +} + +// chronoRow row antara untuk merge sort children. +type chronoRow struct { + Kind string + ChildID uint64 + Amount float64 + SortDate time.Time + SortID uint64 +} + +func mergeSortedByDate(a, b []chronoRow) []chronoRow { + out := make([]chronoRow, 0, len(a)+len(b)) + i, j := 0, 0 + for i < len(a) && j < len(b) { + if a[i].SortDate.Before(b[j].SortDate) || + (a[i].SortDate.Equal(b[j].SortDate) && a[i].SortID < b[j].SortID) { + out = append(out, a[i]) + i++ + } else { + out = append(out, b[j]) + j++ + } + } + out = append(out, a[i:]...) + out = append(out, b[j:]...) + return out +} diff --git a/internal/database/migrations/20260528175508_add_grand_total_to_marketings_expenses.down.sql b/internal/database/migrations/20260528175508_add_grand_total_to_marketings_expenses.down.sql new file mode 100644 index 00000000..5e1ebe7d --- /dev/null +++ b/internal/database/migrations/20260528175508_add_grand_total_to_marketings_expenses.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE marketings DROP COLUMN IF EXISTS grand_total; +ALTER TABLE expenses DROP COLUMN IF EXISTS grand_total; +ALTER TABLE purchases DROP COLUMN IF EXISTS grand_total; diff --git a/internal/database/migrations/20260528175508_add_grand_total_to_marketings_expenses.up.sql b/internal/database/migrations/20260528175508_add_grand_total_to_marketings_expenses.up.sql new file mode 100644 index 00000000..fa704cc2 --- /dev/null +++ b/internal/database/migrations/20260528175508_add_grand_total_to_marketings_expenses.up.sql @@ -0,0 +1,42 @@ +-- Marketing belum punya grand_total. Tambahkan dengan DEFAULT 0. +ALTER TABLE marketings ADD COLUMN grand_total NUMERIC(15, 3) NOT NULL DEFAULT 0; + +-- Expense grand_total sebelumnya di-drop di migration 20251125055613. Re-add. +ALTER TABLE expenses ADD COLUMN grand_total NUMERIC(15, 3) NOT NULL DEFAULT 0; + +ALTER TABLE purchases ADD COLUMN grand_total NUMERIC(15, 3) NOT NULL DEFAULT 0; + +-- Backfill nilai grand_total dari children: +-- marketings.grand_total = SUM marketing_delivery_products.total_price (WHERE delivery_date IS NOT NULL) +UPDATE marketings m +SET grand_total = COALESCE(s.t, 0) +FROM ( + SELECT mp.marketing_id AS marketing_id, SUM(mdp.total_price) AS t + FROM marketing_delivery_products mdp + JOIN marketing_products mp ON mp.id = mdp.marketing_product_id + WHERE mdp.delivery_date IS NOT NULL + GROUP BY mp.marketing_id +) s +WHERE s.marketing_id = m.id; + +-- expenses.grand_total = SUM(expense_realizations.qty * expense_realizations.price) via expense_nonstocks +UPDATE expenses e +SET grand_total = COALESCE(s.t, 0) +FROM ( + SELECT en.expense_id AS expense_id, SUM(er.qty * er.price) AS t + FROM expense_realizations er + JOIN expense_nonstocks en ON en.id = er.expense_nonstock_id + GROUP BY en.expense_id +) s +WHERE s.expense_id = e.id; + +-- purchases.grand_total sudah ada sejak migration 20251104084555. +-- Recompute juga untuk safety supaya konsisten dengan SUM purchase_items.total_price. +UPDATE purchases p +SET grand_total = COALESCE(s.t, 0) +FROM ( + SELECT purchase_id, SUM(total_price) AS t + FROM purchase_items + GROUP BY purchase_id +) s +WHERE s.purchase_id = p.id; diff --git a/internal/database/migrations/20260528175642_create_payment_allocations.down.sql b/internal/database/migrations/20260528175642_create_payment_allocations.down.sql new file mode 100644 index 00000000..b11f7cde --- /dev/null +++ b/internal/database/migrations/20260528175642_create_payment_allocations.down.sql @@ -0,0 +1,5 @@ +DROP INDEX IF EXISTS idx_payments_party_active; +DROP INDEX IF EXISTS idx_mdp_delivery_date_partial; +DROP INDEX IF EXISTS idx_purchase_items_received_date_partial; + +DROP TABLE IF EXISTS payment_allocations; diff --git a/internal/database/migrations/20260528175642_create_payment_allocations.up.sql b/internal/database/migrations/20260528175642_create_payment_allocations.up.sql new file mode 100644 index 00000000..895278af --- /dev/null +++ b/internal/database/migrations/20260528175642_create_payment_allocations.up.sql @@ -0,0 +1,27 @@ +-- Tabel payment_allocations menyimpan hasil FIFO matching antara payment dengan +-- sub-row anak (purchase_item / marketing_delivery_product / expense_realization). +-- Setiap allocation row HARUS terhubung ke tepat 1 child via 3 nullable FK +-- (polymorphic-via-multiple-nullable-FK; lebih aman dari single polymorphic kolom). +CREATE TABLE IF NOT EXISTS payment_allocations ( + id BIGSERIAL PRIMARY KEY, + payment_id BIGINT NOT NULL REFERENCES payments(id) ON DELETE CASCADE, + purchase_item_id BIGINT NULL REFERENCES purchase_items(id) ON DELETE CASCADE, + marketing_delivery_product_id BIGINT NULL REFERENCES marketing_delivery_products(id) ON DELETE CASCADE, + expense_realization_id BIGINT NULL REFERENCES expense_realizations(id) ON DELETE CASCADE, + amount NUMERIC(15, 3) NOT NULL CHECK (amount > 0), + allocated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_payment_alloc_exactly_one CHECK ( + num_nonnulls(purchase_item_id, marketing_delivery_product_id, expense_realization_id) = 1 + ) +); + +CREATE INDEX IF NOT EXISTS idx_payment_alloc_payment ON payment_allocations (payment_id); +CREATE INDEX IF NOT EXISTS idx_payment_alloc_purchase_item ON payment_allocations (purchase_item_id) WHERE purchase_item_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_payment_alloc_mdp ON payment_allocations (marketing_delivery_product_id) WHERE marketing_delivery_product_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_payment_alloc_realization ON payment_allocations (expense_realization_id) WHERE expense_realization_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_payment_alloc_allocated_at ON payment_allocations (allocated_at); + +-- Helper partial indexes untuk FIFO loop performance +CREATE INDEX IF NOT EXISTS idx_purchase_items_received_date_partial ON purchase_items (received_date) WHERE received_date IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_mdp_delivery_date_partial ON marketing_delivery_products (delivery_date) WHERE delivery_date IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_payments_party_active ON payments (party_type, party_id, payment_date) WHERE deleted_at IS NULL; diff --git a/internal/database/migrations/20260528175729_backfill_payment_allocations.down.sql b/internal/database/migrations/20260528175729_backfill_payment_allocations.down.sql new file mode 100644 index 00000000..1b39bfed --- /dev/null +++ b/internal/database/migrations/20260528175729_backfill_payment_allocations.down.sql @@ -0,0 +1,4 @@ +-- Rollback backfill: hapus semua allocations dan drop function. +TRUNCATE payment_allocations; + +DROP FUNCTION IF EXISTS fn_fifo_backfill_party(TEXT, BIGINT); diff --git a/internal/database/migrations/20260528175729_backfill_payment_allocations.up.sql b/internal/database/migrations/20260528175729_backfill_payment_allocations.up.sql new file mode 100644 index 00000000..965ebee8 --- /dev/null +++ b/internal/database/migrations/20260528175729_backfill_payment_allocations.up.sql @@ -0,0 +1,170 @@ +-- 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$; diff --git a/internal/entities/expense.go b/internal/entities/expense.go index ec02e0c0..273b07ee 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -18,6 +18,7 @@ type Expense struct { TransactionDate time.Time `gorm:"type:date;not null"` Notes string `gorm:"type:text;column:notes"` IsPaid bool `gorm:"column:is_paid;not null;default:false"` + GrandTotal float64 `gorm:"column:grand_total;type:numeric(15,3);not null;default:0"` CreatedBy uint64 `gorm:""` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/internal/entities/marketing.go b/internal/entities/marketing.go index c1ca293b..0317496b 100644 --- a/internal/entities/marketing.go +++ b/internal/entities/marketing.go @@ -15,6 +15,7 @@ type Marketing struct { SalesPersonId uint `gorm:"not null"` Notes string `gorm:"type:text"` MarketingType string `gorm:"type:varchar(50)"` + GrandTotal float64 `gorm:"column:grand_total;type:numeric(15,3);not null;default:0"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/internal/entities/payment_allocation.go b/internal/entities/payment_allocation.go new file mode 100644 index 00000000..8d3291d8 --- /dev/null +++ b/internal/entities/payment_allocation.go @@ -0,0 +1,23 @@ +package entities + +import ( + "time" +) + +// PaymentAllocation merepresentasikan hasil FIFO matching dari 1 payment ke +// tepat 1 sub-row anak (purchase_item / marketing_delivery_product / +// expense_realization). DB constraint memastikan hanya satu FK yang non-null. +type PaymentAllocation struct { + Id uint64 `gorm:"primaryKey;autoIncrement"` + PaymentId uint `gorm:"not null;index"` + PurchaseItemId *uint `gorm:"column:purchase_item_id"` + MarketingDeliveryProductId *uint `gorm:"column:marketing_delivery_product_id"` + ExpenseRealizationId *uint64 `gorm:"column:expense_realization_id"` + Amount float64 `gorm:"type:numeric(15,3);not null"` + AllocatedAt time.Time `gorm:"type:timestamptz;not null;default:NOW()"` + + Payment *Payment `gorm:"foreignKey:PaymentId;references:Id"` + PurchaseItem *PurchaseItem `gorm:"foreignKey:PurchaseItemId;references:Id"` + MarketingDeliveryProduct *MarketingDeliveryProduct `gorm:"foreignKey:MarketingDeliveryProductId;references:Id"` + ExpenseRealization *ExpenseRealization `gorm:"foreignKey:ExpenseRealizationId;references:Id"` +} diff --git a/internal/entities/purchase.go b/internal/entities/purchase.go index 66b88c63..849b6236 100644 --- a/internal/entities/purchase.go +++ b/internal/entities/purchase.go @@ -12,6 +12,7 @@ type Purchase struct { SupplierId uint `gorm:"not null"` CreditTerm int `gorm:"column:credit_term;not null;default:0"` DueDate *time.Time + GrandTotal float64 `gorm:"column:grand_total;type:numeric(15,3);not null;default:0"` Notes *string CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/internal/modules/expenses/module.go b/internal/modules/expenses/module.go index b495b5b9..1a2834d6 100644 --- a/internal/modules/expenses/module.go +++ b/internal/modules/expenses/module.go @@ -45,7 +45,8 @@ func (ExpenseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * panic(fmt.Sprintf("failed to register expense approval workflow: %v", err)) } - expenseService := sExpense.NewExpenseService(expenseRepo, supplierRepo, nonstockRepo, approvalSvc, realizationRepo, projectFlockKandangRepo, documentSvc, validate) + fifoPaymentSvc := commonSvc.NewFifoPaymentService(db, utils.Log) + expenseService := sExpense.NewExpenseService(expenseRepo, supplierRepo, nonstockRepo, approvalSvc, realizationRepo, projectFlockKandangRepo, documentSvc, fifoPaymentSvc, validate) userService := sUser.NewUserService(userRepo, validate) ExpenseRoutes(router, userService, expenseService) diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 37806d44..d60b4f20 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -54,9 +54,10 @@ type expenseService struct { RealizationRepository repository.ExpenseRealizationRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository DocumentSvc commonSvc.DocumentService + FifoPaymentSvc commonSvc.FifoPaymentService } -func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierRepo.SupplierRepository, nonstockRepo nonstockRepo.NonstockRepository, approvalSvc commonSvc.ApprovalService, realizationRepo repository.ExpenseRealizationRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, validate *validator.Validate) ExpenseService { +func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierRepo.SupplierRepository, nonstockRepo nonstockRepo.NonstockRepository, approvalSvc commonSvc.ApprovalService, realizationRepo repository.ExpenseRealizationRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, fifoPaymentSvc commonSvc.FifoPaymentService, validate *validator.Validate) ExpenseService { return &expenseService{ Log: utils.Log, Validate: validate, @@ -67,6 +68,23 @@ func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierR RealizationRepository: realizationRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, DocumentSvc: documentSvc, + FifoPaymentSvc: fifoPaymentSvc, + } +} + +// reallocateAfterRealization called after expense realization changes that may +// affect supplier debt: recompute grand_total + reallocate FIFO. +func (s *expenseService) reallocateAfterRealization(ctx context.Context, expenseID uint, supplierID uint64) { + if s.FifoPaymentSvc == nil { + return + } + if err := s.FifoPaymentSvc.RecomputeGrandTotal(ctx, nil, commonSvc.ParentKindExpense, expenseID); err != nil { + s.Log.Warnf("Failed to recompute grand_total for expense %d: %+v", expenseID, err) + } + if supplierID > 0 { + if err := s.FifoPaymentSvc.ReallocateForParty(ctx, nil, string(utils.PaymentPartySupplier), uint(supplierID)); err != nil { + s.Log.Warnf("Failed to reallocate payments for supplier %d: %+v", supplierID, err) + } } } @@ -1078,6 +1096,9 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va } invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, realizationDate, expense.RealizationDate) s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil) + + s.reallocateAfterRealization(c.Context(), expenseID, expense.SupplierId) + return responseDTO, nil } @@ -1522,6 +1543,9 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va return nil, err } s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil) + + s.reallocateAfterRealization(c.Context(), expenseID, expense.SupplierId) + return responseDTO, nil } diff --git a/internal/modules/finance/payments/module.go b/internal/modules/finance/payments/module.go index fdc0ce47..5d930144 100644 --- a/internal/modules/finance/payments/module.go +++ b/internal/modules/finance/payments/module.go @@ -29,7 +29,9 @@ func (PaymentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * panic(fmt.Sprintf("failed to register payment approval workflow: %v", err)) } - paymentService := sPayment.NewPaymentService(paymentRepo, approvalService, validate) + fifoPaymentService := commonSvc.NewFifoPaymentService(db, nil) + + paymentService := sPayment.NewPaymentService(paymentRepo, approvalService, fifoPaymentService, validate) userService := sUser.NewUserService(userRepo, validate) PaymentRoutes(router, userService, paymentService) diff --git a/internal/modules/finance/payments/services/payment.service.go b/internal/modules/finance/payments/services/payment.service.go index 8860f3f4..bfb83719 100644 --- a/internal/modules/finance/payments/services/payment.service.go +++ b/internal/modules/finance/payments/services/payment.service.go @@ -32,12 +32,14 @@ type paymentService struct { Validate *validator.Validate Repository repository.PaymentRepository ApprovalSvc commonSvc.ApprovalService + FifoPaymentSvc commonSvc.FifoPaymentService approvalWorkflow approvalutils.ApprovalWorkflowKey } func NewPaymentService( repo repository.PaymentRepository, approvalSvc commonSvc.ApprovalService, + fifoPaymentSvc commonSvc.FifoPaymentService, validate *validator.Validate, ) PaymentService { return &paymentService{ @@ -45,6 +47,7 @@ func NewPaymentService( Validate: validate, Repository: repo, ApprovalSvc: approvalSvc, + FifoPaymentSvc: fifoPaymentSvc, approvalWorkflow: utils.ApprovalWorkflowPayment, } } @@ -159,6 +162,12 @@ func (s *paymentService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit } } + if s.FifoPaymentSvc != nil { + if err := s.FifoPaymentSvc.ReallocateForParty(c.Context(), dbTransaction, createBody.PartyType, createBody.PartyId); err != nil { + return err + } + } + return nil }) if err != nil { @@ -251,7 +260,46 @@ func (s paymentService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) return s.GetOne(c, id) } - if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + // Snapshot party lama untuk reallocate kalau party baru berbeda. + existing, err := s.Repository.GetByID(c.Context(), id, nil) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Payment not found") + } + if err != nil { + s.Log.Errorf("Failed get payment for update: %+v", err) + return nil, err + } + oldPartyType := existing.PartyType + oldPartyID := existing.PartyId + + newPartyType := oldPartyType + newPartyID := oldPartyID + if v, ok := updateBody["party_type"].(string); ok { + newPartyType = v + } + if v, ok := updateBody["party_id"].(uint); ok { + newPartyID = v + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + paymentRepoTx := repository.NewPaymentRepository(tx) + if err := paymentRepoTx.PatchOne(c.Context(), id, updateBody, nil); err != nil { + return err + } + + if s.FifoPaymentSvc != nil { + if err := s.FifoPaymentSvc.ReallocateForParty(c.Context(), tx, newPartyType, newPartyID); err != nil { + return err + } + if oldPartyType != newPartyType || oldPartyID != newPartyID { + if err := s.FifoPaymentSvc.ReallocateForParty(c.Context(), tx, oldPartyType, oldPartyID); err != nil { + return err + } + } + } + return nil + }) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Payment not found") } diff --git a/internal/modules/inventory/transfers/module.go b/internal/modules/inventory/transfers/module.go index e45e0dd9..d8328a4e 100644 --- a/internal/modules/inventory/transfers/module.go +++ b/internal/modules/inventory/transfers/module.go @@ -65,6 +65,7 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate expenseRealizationRepo, projectFlockKandangRepo, documentSvc, + commonSvc.NewFifoPaymentService(db, utils.Log), validate, ) diff --git a/internal/modules/marketing/module.go b/internal/modules/marketing/module.go index ce48a06e..2694c6db 100644 --- a/internal/modules/marketing/module.go +++ b/internal/modules/marketing/module.go @@ -35,6 +35,7 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate stockLogRepo := rShared.NewStockLogRepository(db) fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) + fifoPaymentService := commonSvc.NewFifoPaymentService(db, utils.Log) approvalRepo := commonRepo.NewApprovalRepository(db) approvalSvc := commonSvc.NewApprovalService(approvalRepo) @@ -47,7 +48,7 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, fifoStockV2Service, warehouseRepo, projectFlockKandangRepo, validate) - deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, stockLogRepo, productWarehouseRepo, projectFlockPopulationRepo, approvalSvc, fifoStockV2Service, validate) + deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, stockLogRepo, productWarehouseRepo, projectFlockPopulationRepo, approvalSvc, fifoStockV2Service, fifoPaymentService, validate) userService := sUser.NewUserService(userRepo, validate) RegisterRoutes(router, userService, salesOrdersService, deliveryOrdersService) diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index d78153a0..396a06e3 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -48,6 +48,7 @@ type deliveryOrdersService struct { ProjectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository ApprovalSvc commonSvc.ApprovalService FifoStockV2Svc commonSvc.FifoStockV2Service + FifoPaymentSvc commonSvc.FifoPaymentService } func NewDeliveryOrdersService( @@ -59,6 +60,7 @@ func NewDeliveryOrdersService( projectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository, approvalSvc commonSvc.ApprovalService, fifoStockV2Svc commonSvc.FifoStockV2Service, + fifoPaymentSvc commonSvc.FifoPaymentService, validate *validator.Validate, ) DeliveryOrdersService { return &deliveryOrdersService{ @@ -71,6 +73,22 @@ func NewDeliveryOrdersService( ProjectFlockPopulationRepo: projectFlockPopulationRepo, ApprovalSvc: approvalSvc, FifoStockV2Svc: fifoStockV2Svc, + FifoPaymentSvc: fifoPaymentSvc, + } +} + +// reallocateAfterDelivery refresh marketing.grand_total + reallocate FIFO untuk customer. +func (s *deliveryOrdersService) reallocateAfterDelivery(ctx context.Context, marketingID uint, customerID uint) { + if s.FifoPaymentSvc == nil { + return + } + if err := s.FifoPaymentSvc.RecomputeGrandTotal(ctx, nil, commonSvc.ParentKindMarketing, marketingID); err != nil { + utils.Log.Warnf("Failed to recompute grand_total for marketing %d: %+v", marketingID, err) + } + if customerID > 0 { + if err := s.FifoPaymentSvc.ReallocateForParty(ctx, nil, string(utils.PaymentPartyCustomer), customerID); err != nil { + utils.Log.Warnf("Failed to reallocate payments for customer %d: %+v", customerID, err) + } } } @@ -418,6 +436,7 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery return nil, fiber.NewError(fiber.StatusBadRequest, "Delivery order already exists for this marketing") } + var capturedCustomerID uint err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction) marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction) @@ -428,6 +447,7 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch marketing") } + capturedCustomerID = marketing.CustomerId allMarketingProducts, err := marketingProductRepositoryTx.GetByMarketingID(c.Context(), req.MarketingId) if err != nil { @@ -519,6 +539,8 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create delivery order") } + s.reallocateAfterDelivery(c.Context(), req.MarketingId, capturedCustomerID) + return s.getMarketingWithDeliveries(c, req.MarketingId) } @@ -547,6 +569,7 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check approval status") } + var capturedCustomerID uint err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction) marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction) @@ -557,6 +580,7 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch marketing") } + capturedCustomerID = marketing.CustomerId allMarketingProducts, err := marketingProductRepositoryTx.GetByMarketingID(c.Context(), id) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -662,6 +686,8 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery order") } + s.reallocateAfterDelivery(c.Context(), id, capturedCustomerID) + return s.getMarketingWithDeliveries(c, id) } diff --git a/internal/modules/purchases/module.go b/internal/modules/purchases/module.go index dbd7f772..deeec194 100644 --- a/internal/modules/purchases/module.go +++ b/internal/modules/purchases/module.go @@ -61,6 +61,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate expenseRealizationRepo, projectFlockKandangRepository, documentSvc, + commonSvc.NewFifoPaymentService(db, utils.Log), validate, ) expenseBridge := service.NewExpenseBridge( @@ -72,6 +73,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate ) fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) + fifoPaymentService := commonSvc.NewFifoPaymentService(db, utils.Log) purchaseService := service.NewPurchaseService( validate, @@ -84,6 +86,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate approvalService, expenseBridge, fifoStockV2Service, + fifoPaymentService, documentSvc, ) diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 8c13d606..5f2253df 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -64,6 +64,7 @@ type purchaseService struct { ApprovalSvc commonSvc.ApprovalService ExpenseBridge PurchaseExpenseBridge FifoStockV2Svc commonSvc.FifoStockV2Service + FifoPaymentSvc commonSvc.FifoPaymentService DocumentSvc commonSvc.DocumentService approvalWorkflow approvalutils.ApprovalWorkflowKey } @@ -91,6 +92,7 @@ func NewPurchaseService( approvalSvc commonSvc.ApprovalService, expenseBridge PurchaseExpenseBridge, fifoStockV2Svc commonSvc.FifoStockV2Service, + fifoPaymentSvc commonSvc.FifoPaymentService, documentSvc commonSvc.DocumentService, ) PurchaseService { return &purchaseService{ @@ -105,6 +107,7 @@ func NewPurchaseService( ApprovalSvc: approvalSvc, ExpenseBridge: expenseBridge, FifoStockV2Svc: fifoStockV2Svc, + FifoPaymentSvc: fifoPaymentSvc, DocumentSvc: documentSvc, approvalWorkflow: utils.ApprovalWorkflowPurchase, } @@ -1406,6 +1409,16 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return nil, err } + // Refresh purchase.grand_total + reallocate payment FIFO untuk supplier (new debt baru emerges). + if s.FifoPaymentSvc != nil && receivingAction == entity.ApprovalActionApproved { + if err := s.FifoPaymentSvc.RecomputeGrandTotal(c.Context(), nil, commonSvc.ParentKindPurchase, purchase.Id); err != nil { + s.Log.Warnf("Failed to recompute grand_total for purchase %d: %+v", purchase.Id, err) + } + if err := s.FifoPaymentSvc.ReallocateForParty(c.Context(), nil, string(utils.PaymentPartySupplier), uint(purchase.SupplierId)); err != nil { + s.Log.Warnf("Failed to reallocate payments for supplier %d: %+v", purchase.SupplierId, err) + } + } + return updated, nil } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 4e2a9482..bb4dc5d7 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -750,37 +750,28 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing customerGroups[customerID] = append(customerGroups[customerID], dp) } + // Aging untuk setiap MDP berdasarkan payment_allocations: LUNAS pakai last_payment_date, + // else pakai today. agingMap := make(map[int]int) - for customerID := range customerGroups { - transactions, err := s.CustomerPaymentRepo.GetCustomerPaymentTransactions(c.Context(), &customerID) - if err != nil { - continue - } - - initialBalance, err := s.CustomerPaymentRepo.GetInitialBalanceByCustomer(c.Context(), customerID) - if err != nil { - initialBalance = 0 - } - - runningBalance := initialBalance - for i, tx := range transactions { - if tx.TransactionType == "SALES" { - previousBalance := runningBalance - runningBalance -= tx.TotalPrice - currentBalance := runningBalance - - _, paymentDate := s.determineSalesStatusAndPaymentDate(transactions, i, previousBalance, currentBalance) - - if paymentDate != nil { - agingDays := int(paymentDate.Sub(tx.TransDate).Hours() / 24) - agingMap[int(tx.TransactionID)] = agingDays - } else { - agingDays := int(time.Since(tx.TransDate).Hours() / 24) - agingMap[int(tx.TransactionID)] = agingDays - } - } else if tx.TransactionType == "PAYMENT" { - runningBalance += tx.PaymentAmount + allMdpIDsForAging := make([]uint, 0) + for _, dp := range deliveryProducts { + allMdpIDsForAging = append(allMdpIDsForAging, dp.Id) + } + mdpAllocSummaryForMarketing, err := s.fetchMdpAllocationSummary(c.Context(), allMdpIDsForAging) + if err != nil { + return nil, 0, err + } + for _, dp := range deliveryProducts { + summary := mdpAllocSummaryForMarketing[dp.Id] + soDate := dp.MarketingProduct.Marketing.SoDate + if customerPaymentStatusFromAllocation(dp.TotalPrice, summary.PaidAmount) == "LUNAS" && !summary.LastPaymentDate.IsZero() { + days := int(summary.LastPaymentDate.Sub(soDate).Hours() / 24) + if days < 0 { + days = 0 } + agingMap[int(dp.Id)] = days + } else { + agingMap[int(dp.Id)] = int(time.Since(soDate).Hours() / 24) } } @@ -1169,28 +1160,39 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID return dto.CustomerPaymentReportItem{}, err } + // Batch fetch payment allocation summaries untuk semua SALES rows (per MDP). + mdpIDs := make([]uint, 0) + for _, tx := range transactions { + if tx.TransactionType == "SALES" && tx.TransactionID > 0 { + mdpIDs = append(mdpIDs, uint(tx.TransactionID)) + } + } + mdpAllocSummary, err := s.fetchMdpAllocationSummary(ctx, mdpIDs) + if err != nil { + return dto.CustomerPaymentReportItem{}, err + } + rows := make([]dto.CustomerPaymentReportRow, 0, len(transactions)) runningBalance := initialBalance - for i, tx := range transactions { - - previousBalance := runningBalance + for _, tx := range transactions { row := dto.ToCustomerPaymentReportRow(tx) if tx.TransactionType == "SALES" { runningBalance -= tx.TotalPrice - status, paymentDate := s.determineSalesStatusAndPaymentDate(transactions, i, previousBalance, runningBalance) - row.Status = status + summary := mdpAllocSummary[uint(tx.TransactionID)] + row.Status = customerPaymentStatusFromAllocation(tx.TotalPrice, summary.PaidAmount) - if status == "LUNAS" { - if paymentDate != nil { - days := int(paymentDate.Sub(tx.TransDate).Hours() / 24) - row.AgingDay = &days - } else { - days := 0 - row.AgingDay = &days + if row.Status == "LUNAS" && !summary.LastPaymentDate.IsZero() { + days := int(summary.LastPaymentDate.Sub(tx.TransDate).Hours() / 24) + if days < 0 { + days = 0 } + row.AgingDay = &days + } else if row.Status == "LUNAS" { + zero := 0 + row.AgingDay = &zero } else { days := int(time.Since(tx.TransDate).Hours() / 24) row.AgingDay = &days @@ -1262,91 +1264,19 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID return dto.ToCustomerPaymentReportItem(*customer, initialBalance, rows, summary), nil } -func (s *repportService) determineSalesStatusAndPaymentDate(transactions []repportRepo.CustomerPaymentTransaction, currentIndex int, previousBalance, currentBalance float64) (string, *time.Time) { - currentSales := transactions[currentIndex] - - if previousBalance >= currentSales.TotalPrice { - type paymentAllocation struct { - date time.Time - amount float64 - consumed float64 - } - allocations := []paymentAllocation{} - runningBalance := 0.0 - - for i := 0; i < currentIndex; i++ { - if transactions[i].TransactionType == "PAYMENT" { - allocations = append(allocations, paymentAllocation{ - date: transactions[i].TransDate, - amount: transactions[i].PaymentAmount, - consumed: 0, - }) - runningBalance += transactions[i].PaymentAmount - } else if transactions[i].TransactionType == "SALES" { - salesAmount := transactions[i].TotalPrice - remainingToConsume := salesAmount - - for j := range allocations { - if remainingToConsume <= 0 { - break - } - available := allocations[j].amount - allocations[j].consumed - if available > 0 { - consume := available - if consume > remainingToConsume { - consume = remainingToConsume - } - allocations[j].consumed += consume - remainingToConsume -= consume - } - } - runningBalance -= salesAmount - } - } - - amountNeeded := currentSales.TotalPrice - for _, alloc := range allocations { - available := alloc.amount - alloc.consumed - if available > 0 { - if amountNeeded <= available { - return "LUNAS", &alloc.date - } else { - amountNeeded -= available - } - } - } - - if len(allocations) > 0 { - return "LUNAS", &allocations[0].date - } - return "LUNAS", nil +// customerPaymentStatusFromAllocation menentukan status per-MDP berdasarkan +// SUM(payment_allocations.amount) vs MDP total_price. +func customerPaymentStatusFromAllocation(totalPrice, paidAmount float64) string { + if totalPrice <= fifoAllocationEpsilon { + return "LUNAS" } - - hasPartialPaymentFromBalance := previousBalance > 0 && previousBalance < currentSales.TotalPrice - - futureBalance := currentBalance - hasPayment := false - var paymentDateThatMadeItLunas *time.Time - - for i := currentIndex + 1; i < len(transactions); i++ { - if transactions[i].TransactionType == "PAYMENT" { - futureBalance += transactions[i].PaymentAmount - hasPayment = true - - if futureBalance >= 0 { - paymentDateThatMadeItLunas = &transactions[i].TransDate - return "LUNAS", paymentDateThatMadeItLunas - } - } else if transactions[i].TransactionType == "SALES" { - futureBalance -= transactions[i].TotalPrice - } + if paidAmount+fifoAllocationEpsilon >= totalPrice { + return "LUNAS" } - - if hasPayment || hasPartialPaymentFromBalance { - return "DIBAYAR SEBAGIAN", nil + if paidAmount > fifoAllocationEpsilon { + return "DIBAYAR SEBAGIAN" } - - return "BELUM LUNAS", nil + return "BELUM LUNAS" } func mapRecordingToProductionResultDTO(record entity.Recording) dto.ProductionResultDTO { @@ -1861,15 +1791,34 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu DeltaBalance float64 CountTotals bool } - type debtSupplierAllocation struct { - RowIndex int - SortTime time.Time - Amount float64 - CalcAging func(endDate time.Time) int + + // Batch fetch payment allocation summaries (per purchase + per expense) untuk semua supplier. + // FIFO matching dilakukan saat payment di-create/update; report tinggal baca dari DB. + allPurchaseIDs := make([]uint, 0) + allExpenseIDs := make([]uint64, 0) + for _, sid := range supplierIDs { + for _, p := range purchasesBySupplier[sid] { + allPurchaseIDs = append(allPurchaseIDs, p.Id) + } + for _, e := range expensesBySupplier[sid] { + allExpenseIDs = append(allExpenseIDs, e.Id) + } } - type paymentAllocation struct { - Date time.Time - Amount float64 + purchaseAllocSummary, err := s.fetchPurchaseAllocationSummary(c.Context(), allPurchaseIDs) + if err != nil { + return nil, 0, err + } + expenseAllocSummary, err := s.fetchExpenseAllocationSummary(c.Context(), allExpenseIDs) + if err != nil { + return nil, 0, err + } + + // rowRef tracks which combinedRows index belongs to which purchase/expense untuk update status di-akhir. + type rowRef struct { + Index int + Kind string // "PURCHASE" / "EXPENSE" + Purchase entity.Purchase + Expense entity.Expense } for _, supplierID := range supplierIDs { @@ -1884,7 +1833,7 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu total := dto.DebtSupplierTotalDTO{} combinedRows := make([]debtSupplierRowItem, 0, len(items)+len(paymentItems)) - purchaseAllocations := make([]debtSupplierAllocation, 0, len(items)) + rowRefs := make([]rowRef, 0, len(items)+len(expensesBySupplier[supplierID])) for _, purchase := range items { row := buildDebtSupplierRow(purchase, now, location) sortTime := resolveDebtSupplierSortTime(purchase, params.FilterBy, location) @@ -1896,13 +1845,7 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu DeltaBalance: -row.TotalPrice, CountTotals: true, }) - capturedPurchase := purchase - purchaseAllocations = append(purchaseAllocations, debtSupplierAllocation{ - RowIndex: rowIndex, - SortTime: sortTime, - Amount: row.TotalPrice, - CalcAging: func(endDate time.Time) int { return calculateDebtSupplierAging(capturedPurchase, endDate, location) }, - }) + rowRefs = append(rowRefs, rowRef{Index: rowIndex, Kind: "PURCHASE", Purchase: purchase}) } for _, exp := range expensesBySupplier[supplierID] { @@ -1916,25 +1859,7 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu DeltaBalance: -row.TotalPrice, CountTotals: true, }) - capturedExp := exp - purchaseAllocations = append(purchaseAllocations, debtSupplierAllocation{ - RowIndex: rowIndex, - SortTime: sortTime, - Amount: row.TotalPrice, - CalcAging: func(endDate time.Time) int { return calculateExpenseAging(capturedExp, endDate, location) }, - }) - } - - paymentAllocations := make([]paymentAllocation, 0, len(paymentItems)+1) - initialAllocation := initialBalanceTotals[supplierID] + initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID] - paymentCarry := 0.0 - if initialAllocation > 0 && len(purchaseAllocations) > 0 { - paymentAllocations = append(paymentAllocations, paymentAllocation{ - Date: purchaseAllocations[0].SortTime, - Amount: initialAllocation, - }) - } else if initialAllocation < 0 { - paymentCarry = -initialAllocation + rowRefs = append(rowRefs, rowRef{Index: rowIndex, Kind: "EXPENSE", Expense: exp}) } for _, payment := range paymentItems { @@ -1947,51 +1872,29 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu DeltaBalance: payment.Nominal, CountTotals: false, }) - paymentAllocations = append(paymentAllocations, paymentAllocation{ - Date: sortTime, - Amount: payment.Nominal, - }) } - if len(purchaseAllocations) > 0 && len(paymentAllocations) > 0 { - sort.SliceStable(purchaseAllocations, func(i, j int) bool { - return purchaseAllocations[i].SortTime.Before(purchaseAllocations[j].SortTime) - }) - sort.SliceStable(paymentAllocations, func(i, j int) bool { - return paymentAllocations[i].Date.Before(paymentAllocations[j].Date) - }) - remaining := make([]float64, len(purchaseAllocations)) - for i := range purchaseAllocations { - remaining[i] = purchaseAllocations[i].Amount + // Determine Status & Aging dari payment_allocations DB. + for _, ref := range rowRefs { + rowTotal := combinedRows[ref.Index].Row.TotalPrice + if rowTotal <= fifoAllocationEpsilon { + continue } - purchaseIndex := 0 - for _, pay := range paymentAllocations { - amount := pay.Amount - if amount <= 0 { - continue - } - if paymentCarry > 0 { - used := math.Min(amount, paymentCarry) - paymentCarry -= used - amount -= used - } - for amount > 0 && purchaseIndex < len(remaining) { - if remaining[purchaseIndex] <= 0 { - purchaseIndex++ - continue - } - used := math.Min(amount, remaining[purchaseIndex]) - remaining[purchaseIndex] -= used - amount -= used - if remaining[purchaseIndex] <= 0.000001 { - allocation := purchaseAllocations[purchaseIndex] - combinedRows[allocation.RowIndex].Row.Status = "Lunas" - combinedRows[allocation.RowIndex].Row.Aging = allocation.CalcAging(pay.Date) - purchaseIndex++ - } - } - if purchaseIndex >= len(remaining) { - break + var summary paymentAllocationSummary + if ref.Kind == "PURCHASE" { + summary = purchaseAllocSummary[ref.Purchase.Id] + } else { + summary = expenseAllocSummary[ref.Expense.Id] + } + if summary.PaidAmount+fifoAllocationEpsilon < rowTotal { + continue + } + combinedRows[ref.Index].Row.Status = "Lunas" + if !summary.LastPaymentDate.IsZero() { + if ref.Kind == "PURCHASE" { + combinedRows[ref.Index].Row.Aging = calculateDebtSupplierAging(ref.Purchase, summary.LastPaymentDate.In(location), location) + } else { + combinedRows[ref.Index].Row.Aging = calculateExpenseAging(ref.Expense, summary.LastPaymentDate.In(location), location) } } } @@ -2168,6 +2071,115 @@ func buildDebtSupplierPaymentRow(payment entity.Payment, loc *time.Location) dto } } +// fifoAllocationEpsilon untuk float comparison saat membandingkan paid vs total. +const fifoAllocationEpsilon = 0.001 + +// paymentAllocationSummary aggregates per-document paid amount + latest payment date +// from payment_allocations table, sebagai pengganti FIFO greedy in-memory. +type paymentAllocationSummary struct { + PaidAmount float64 + LastPaymentDate time.Time +} + +// fetchPurchaseAllocationSummary returns map[purchase_id]{paid_amount, last_payment_date}. +// paid_amount = SUM(payment_allocations.amount) untuk semua items dalam purchase. +// last_payment_date = MAX(payments.payment_date) untuk allocation tersebut. +func (s *repportService) fetchPurchaseAllocationSummary(ctx context.Context, purchaseIDs []uint) (map[uint]paymentAllocationSummary, error) { + out := make(map[uint]paymentAllocationSummary) + if len(purchaseIDs) == 0 { + return out, nil + } + type row struct { + PurchaseID uint + Total float64 + LastPayment *time.Time + } + var rows []row + if err := s.db.WithContext(ctx). + Table("payment_allocations pa"). + Joins("JOIN purchase_items pi ON pi.id = pa.purchase_item_id"). + Joins("JOIN payments p ON p.id = pa.payment_id"). + Select("pi.purchase_id AS purchase_id, SUM(pa.amount) AS total, MAX(p.payment_date) AS last_payment"). + Where("pi.purchase_id IN ?", purchaseIDs). + Group("pi.purchase_id"). + Scan(&rows).Error; err != nil { + return nil, err + } + for _, r := range rows { + summary := paymentAllocationSummary{PaidAmount: r.Total} + if r.LastPayment != nil { + summary.LastPaymentDate = *r.LastPayment + } + out[r.PurchaseID] = summary + } + return out, nil +} + +// fetchExpenseAllocationSummary returns map[expense_id]{paid_amount, last_payment_date}. +// Allocation di expense_realization_id → JOIN expense_nonstocks → expenses.id. +func (s *repportService) fetchExpenseAllocationSummary(ctx context.Context, expenseIDs []uint64) (map[uint64]paymentAllocationSummary, error) { + out := make(map[uint64]paymentAllocationSummary) + if len(expenseIDs) == 0 { + return out, nil + } + type row struct { + ExpenseID uint64 + Total float64 + LastPayment *time.Time + } + var rows []row + if err := s.db.WithContext(ctx). + Table("payment_allocations pa"). + Joins("JOIN expense_realizations er ON er.id = pa.expense_realization_id"). + Joins("JOIN expense_nonstocks en ON en.id = er.expense_nonstock_id"). + Joins("JOIN payments p ON p.id = pa.payment_id"). + Select("en.expense_id AS expense_id, SUM(pa.amount) AS total, MAX(p.payment_date) AS last_payment"). + Where("en.expense_id IN ?", expenseIDs). + Group("en.expense_id"). + Scan(&rows).Error; err != nil { + return nil, err + } + for _, r := range rows { + summary := paymentAllocationSummary{PaidAmount: r.Total} + if r.LastPayment != nil { + summary.LastPaymentDate = *r.LastPayment + } + out[r.ExpenseID] = summary + } + return out, nil +} + +// fetchMdpAllocationSummary returns map[mdp_id]{paid_amount, last_payment_date}. +func (s *repportService) fetchMdpAllocationSummary(ctx context.Context, mdpIDs []uint) (map[uint]paymentAllocationSummary, error) { + out := make(map[uint]paymentAllocationSummary) + if len(mdpIDs) == 0 { + return out, nil + } + type row struct { + MdpID uint + Total float64 + LastPayment *time.Time + } + var rows []row + if err := s.db.WithContext(ctx). + Table("payment_allocations pa"). + Joins("JOIN payments p ON p.id = pa.payment_id"). + Select("pa.marketing_delivery_product_id AS mdp_id, SUM(pa.amount) AS total, MAX(p.payment_date) AS last_payment"). + Where("pa.marketing_delivery_product_id IN ?", mdpIDs). + Group("pa.marketing_delivery_product_id"). + Scan(&rows).Error; err != nil { + return nil, err + } + for _, r := range rows { + summary := paymentAllocationSummary{PaidAmount: r.Total} + if r.LastPayment != nil { + summary.LastPaymentDate = *r.LastPayment + } + out[r.MdpID] = summary + } + return out, nil +} + func resolveDebtSupplierSortTime(purchase entity.Purchase, filterBy string, loc *time.Location) time.Time { if strings.EqualFold(strings.TrimSpace(filterBy), "po_date") { if purchase.PoDate != nil && !purchase.PoDate.IsZero() { @@ -2271,10 +2283,10 @@ func buildDebtSupplierExpenseRow(exp entity.Expense, now time.Time, loc *time.Lo aging = int(endDay.Sub(startDay).Hours() / 24) } - totalPrice := 0.0 - for _, ns := range exp.Nonstocks { - totalPrice += ns.Qty * ns.Price - } + // TotalPrice pakai expense.GrandTotal (= SUM realisasi) supaya konsisten dengan + // FIFO allocation yang juga pakai realisasi. Hindari pakai SUM nonstock pengajuan + // karena bisa beda nilai dari realisasi → mismatch dengan paid_amount → status salah. + totalPrice := exp.GrandTotal var area *areaDTO.AreaRelationDTO if exp.Location != nil && exp.Location.Area.Id != 0 {