Merge branch 'production' into feat/transfer-laying

This commit is contained in:
giovanni
2026-05-29 16:04:46 +07:00
27 changed files with 1520 additions and 214 deletions
@@ -831,37 +831,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)
}
}
@@ -1250,28 +1241,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
@@ -1343,91 +1345,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 {
@@ -1951,15 +1881,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 {
@@ -1974,7 +1923,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)
@@ -1986,13 +1935,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] {
@@ -2006,25 +1949,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 {
@@ -2037,51 +1962,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)
}
}
}
@@ -2257,6 +2160,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() {
@@ -2360,10 +2372,10 @@ func buildDebtSupplierExpenseRow(exp entity.Expense, warehouses []entity.Warehou
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 {