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 }