mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-06-09 15:07:49 +00:00
394 lines
12 KiB
Go
394 lines
12 KiB
Go
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
|
|
}
|