diff --git a/internal/modules/repports/repositories/debt_supplier.repository.go b/internal/modules/repports/repositories/debt_supplier.repository.go index c5db5e09..70bf644b 100644 --- a/internal/modules/repports/repositories/debt_supplier.repository.go +++ b/internal/modules/repports/repositories/debt_supplier.repository.go @@ -15,13 +15,16 @@ import ( type DebtSupplierRepository interface { GetSuppliersWithPurchases(ctx context.Context, offset, limit int, filters *validation.DebtSupplierQuery) ([]entity.Supplier, int64, error) + GetSuppliersWithDebts(ctx context.Context, offset, limit int, filters *validation.DebtSupplierQuery) ([]entity.Supplier, int64, error) GetPurchasesBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Purchase, error) + GetExpensesBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Expense, error) GetPaymentsBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Payment, error) GetPaymentTotalsByReferences(ctx context.Context, supplierIDs []uint, references []string) (map[string]float64, error) GetPaymentSummariesByReferences(ctx context.Context, supplierIDs []uint, references []string) (map[string]PaymentReferenceSummary, error) GetInitialBalanceTotals(ctx context.Context, supplierIDs []uint) (map[uint]float64, error) GetPurchaseTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) GetPaymentTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) + GetExpenseTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) } type debtSupplierRepositoryImpl struct { @@ -490,3 +493,218 @@ func (r *debtSupplierRepositoryImpl) GetPaymentTotalsBeforeDate(ctx context.Cont return result, nil } + +func (r *debtSupplierRepositoryImpl) latestExpenseApproval(ctx context.Context) *gorm.DB { + return r.db.WithContext(ctx). + Table("approvals AS a"). + Select("a.approvable_id, a.step_number, a.action"). + Joins(` + JOIN ( + SELECT approvable_id, MAX(action_at) AS latest_action_at + FROM approvals + WHERE approvable_type = ? + GROUP BY approvable_id + ) AS la ON la.approvable_id = a.approvable_id AND la.latest_action_at = a.action_at`, + string(utils.ApprovalWorkflowExpense), + ) +} + +func (r *debtSupplierRepositoryImpl) baseExpenseSupplierIDs(ctx context.Context, filters *validation.DebtSupplierQuery) *gorm.DB { + db := r.db.WithContext(ctx). + Table("expenses"). + Select("DISTINCT expenses.supplier_id"). + Joins("JOIN (?) AS la ON la.approvable_id = expenses.id", r.latestExpenseApproval(ctx)). + Where("la.step_number >= ?", uint16(utils.ExpenseStepFinance)). + Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)). + Where("expenses.deleted_at IS NULL") + + if len(filters.SupplierIDs) > 0 { + db = db.Where("expenses.supplier_id IN ?", filters.SupplierIDs) + } + + if filters.AllowedLocationIDs != nil { + if len(filters.AllowedLocationIDs) == 0 { + db = db.Where("1 = 0") + } else { + db = db.Where("expenses.location_id IN ?", filters.AllowedLocationIDs) + } + } + + if filters.AllowedAreaIDs != nil { + if len(filters.AllowedAreaIDs) == 0 { + db = db.Where("1 = 0") + } else { + db = db.Joins("JOIN locations exp_loc ON exp_loc.id = expenses.location_id"). + Where("exp_loc.area_id IN ?", filters.AllowedAreaIDs) + } + } + + if filters.StartDate != "" { + if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil { + db = db.Where("DATE(expenses.transaction_date) >= ?", dateFrom) + } + } + + if filters.EndDate != "" { + if dateTo, err := utils.ParseDateString(filters.EndDate); err == nil { + db = db.Where("DATE(expenses.transaction_date) <= ?", dateTo) + } + } + + return db +} + +func (r *debtSupplierRepositoryImpl) GetSuppliersWithDebts(ctx context.Context, offset, limit int, filters *validation.DebtSupplierQuery) ([]entity.Supplier, int64, error) { + purchaseSubquery := r.baseSupplierQuery(ctx, filters). + Select("suppliers.id") + + expenseSubquery := r.baseExpenseSupplierIDs(ctx, filters) + + db := r.db.WithContext(ctx). + Model(&entity.Supplier{}). + Where("suppliers.id IN (? UNION ?) AND suppliers.deleted_at IS NULL", + purchaseSubquery, expenseSubquery) + + var totalSuppliers int64 + if err := db.Distinct("suppliers.id").Count(&totalSuppliers).Error; err != nil { + return nil, 0, err + } + if totalSuppliers == 0 { + return []entity.Supplier{}, 0, nil + } + + if offset < 0 { + offset = 0 + } + + type supplierIDResult struct { + ID uint `gorm:"column:id"` + Name string `gorm:"column:name"` + } + var idResults []supplierIDResult + if err := r.db.WithContext(ctx). + Model(&entity.Supplier{}). + Where("suppliers.id IN (? UNION ?) AND suppliers.deleted_at IS NULL", + purchaseSubquery, expenseSubquery). + Select("suppliers.id, suppliers.name"). + Group("suppliers.id, suppliers.name"). + Order(resolveDebtSupplierSortClause(filters)). + Offset(offset). + Limit(limit). + Scan(&idResults).Error; err != nil { + return nil, 0, err + } + + supplierIDs := make([]uint, 0, len(idResults)) + for _, r := range idResults { + supplierIDs = append(supplierIDs, r.ID) + } + if len(supplierIDs) == 0 { + return []entity.Supplier{}, totalSuppliers, nil + } + + var suppliers []entity.Supplier + if err := r.db.WithContext(ctx). + Where("id IN ?", supplierIDs). + Order(resolveDebtSupplierSortClause(filters)). + Find(&suppliers).Error; err != nil { + return nil, 0, err + } + + return suppliers, totalSuppliers, nil +} + +func (r *debtSupplierRepositoryImpl) GetExpensesBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Expense, error) { + if len(supplierIDs) == 0 { + return []entity.Expense{}, nil + } + + db := r.db.WithContext(ctx). + Model(&entity.Expense{}). + Joins("JOIN (?) AS la ON la.approvable_id = expenses.id", r.latestExpenseApproval(ctx)). + Where("expenses.supplier_id IN ?", supplierIDs). + Where("la.step_number >= ?", uint16(utils.ExpenseStepFinance)). + Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)). + Where("expenses.deleted_at IS NULL") + + if filters.AllowedLocationIDs != nil { + if len(filters.AllowedLocationIDs) == 0 { + db = db.Where("1 = 0") + } else { + db = db.Where("expenses.location_id IN ?", filters.AllowedLocationIDs) + } + } + + if filters.AllowedAreaIDs != nil { + if len(filters.AllowedAreaIDs) == 0 { + db = db.Where("1 = 0") + } else { + db = db.Joins("JOIN locations exp_loc ON exp_loc.id = expenses.location_id"). + Where("exp_loc.area_id IN ?", filters.AllowedAreaIDs) + } + } + + if filters.StartDate != "" { + if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil { + db = db.Where("DATE(expenses.transaction_date) >= ?", dateFrom) + } + } + + if filters.EndDate != "" { + if dateTo, err := utils.ParseDateString(filters.EndDate); err == nil { + db = db.Where("DATE(expenses.transaction_date) <= ?", dateTo) + } + } + + var expenses []entity.Expense + if err := db. + Preload("Supplier"). + Preload("Nonstocks"). + Preload("Location"). + Preload("Location.Area"). + Order("expenses.transaction_date ASC, expenses.id ASC"). + Find(&expenses).Error; err != nil { + return nil, err + } + + return expenses, nil +} + +func (r *debtSupplierRepositoryImpl) GetExpenseTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) { + if len(supplierIDs) == 0 || strings.TrimSpace(filters.StartDate) == "" { + return map[uint]float64{}, nil + } + + dateFrom, err := utils.ParseDateString(filters.StartDate) + if err != nil { + return map[uint]float64{}, nil + } + + type expenseTotalRow struct { + SupplierID uint `gorm:"column:supplier_id"` + Total float64 `gorm:"column:total"` + } + + rows := make([]expenseTotalRow, 0) + if err := r.db.WithContext(ctx). + Table("expenses"). + Select("expenses.supplier_id AS supplier_id, SUM(en.qty * en.price) AS total"). + Joins("JOIN expense_nonstocks en ON en.expense_id = expenses.id"). + Joins("JOIN (?) AS la ON la.approvable_id = expenses.id", r.latestExpenseApproval(ctx)). + Where("expenses.supplier_id IN ?", supplierIDs). + Where("la.step_number >= ?", uint16(utils.ExpenseStepFinance)). + Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)). + Where("expenses.deleted_at IS NULL"). + Where("DATE(expenses.transaction_date) < ?", dateFrom). + Group("expenses.supplier_id"). + Scan(&rows).Error; err != nil { + return nil, err + } + + result := make(map[uint]float64, len(rows)) + for _, row := range rows { + result[row.SupplierID] = row.Total + } + + return result, nil +} diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 4bccd6bd..4e2a9482 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -1782,7 +1782,7 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu offset = 0 } - suppliers, totalSuppliers, err := s.DebtSupplierRepo.GetSuppliersWithPurchases(c.Context(), offset, params.Limit, params) + suppliers, totalSuppliers, err := s.DebtSupplierRepo.GetSuppliersWithDebts(c.Context(), offset, params.Limit, params) if err != nil { return nil, 0, err } @@ -1807,11 +1807,21 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu return nil, 0, err } + expenses, err := s.DebtSupplierRepo.GetExpensesBySuppliers(c.Context(), supplierIDs, params) + if err != nil { + return nil, 0, err + } + purchasesBySupplier := make(map[uint][]entity.Purchase, len(supplierIDs)) for _, purchase := range purchases { purchasesBySupplier[purchase.SupplierId] = append(purchasesBySupplier[purchase.SupplierId], purchase) } + expensesBySupplier := make(map[uint][]entity.Expense, len(supplierIDs)) + for _, exp := range expenses { + expensesBySupplier[uint(exp.SupplierId)] = append(expensesBySupplier[uint(exp.SupplierId)], exp) + } + paymentsBySupplier := make(map[uint][]entity.Payment, len(supplierIDs)) for _, payment := range payments { paymentsBySupplier[payment.PartyId] = append(paymentsBySupplier[payment.PartyId], payment) @@ -1827,6 +1837,11 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu return nil, 0, err } + initialExpenseTotals, err := s.DebtSupplierRepo.GetExpenseTotalsBeforeDate(c.Context(), supplierIDs, params) + if err != nil { + return nil, 0, err + } + initialBalanceTotals, err := s.DebtSupplierRepo.GetInitialBalanceTotals(c.Context(), supplierIDs) if err != nil { return nil, 0, err @@ -1847,10 +1862,10 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu CountTotals bool } type debtSupplierAllocation struct { - RowIndex int - SortTime time.Time - Amount float64 - Purchase entity.Purchase + RowIndex int + SortTime time.Time + Amount float64 + CalcAging func(endDate time.Time) int } type paymentAllocation struct { Date time.Time @@ -1863,7 +1878,7 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu continue } - initialBalance := initialBalanceTotals[supplierID] + (initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID]) + initialBalance := initialBalanceTotals[supplierID] + (initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID] - initialExpenseTotals[supplierID]) items := purchasesBySupplier[supplierID] paymentItems := paymentsBySupplier[supplierID] total := dto.DebtSupplierTotalDTO{} @@ -1881,11 +1896,32 @@ 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, - Purchase: purchase, + RowIndex: rowIndex, + SortTime: sortTime, + Amount: row.TotalPrice, + CalcAging: func(endDate time.Time) int { return calculateDebtSupplierAging(capturedPurchase, endDate, location) }, + }) + } + + for _, exp := range expensesBySupplier[supplierID] { + row := buildDebtSupplierExpenseRow(exp, now, location) + sortTime := exp.TransactionDate.In(location) + rowIndex := len(combinedRows) + combinedRows = append(combinedRows, debtSupplierRowItem{ + Row: row, + SortTime: sortTime, + Order: 0, + 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) }, }) } @@ -1950,7 +1986,7 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu if remaining[purchaseIndex] <= 0.000001 { allocation := purchaseAllocations[purchaseIndex] combinedRows[allocation.RowIndex].Row.Status = "Lunas" - combinedRows[allocation.RowIndex].Row.Aging = calculateDebtSupplierAging(allocation.Purchase, pay.Date, location) + combinedRows[allocation.RowIndex].Row.Aging = allocation.CalcAging(pay.Date) purchaseIndex++ } } @@ -2224,6 +2260,62 @@ func resolveDebtSupplierReceivedDate(purchase entity.Purchase, loc *time.Locatio return time.Date(earliest.Year(), earliest.Month(), earliest.Day(), 0, 0, 0, 0, loc) } +func buildDebtSupplierExpenseRow(exp entity.Expense, now time.Time, loc *time.Location) dto.DebtSupplierRowDTO { + txDate := exp.TransactionDate.In(loc) + dateStr := txDate.Format("2006-01-02") + + startDay := time.Date(txDate.Year(), txDate.Month(), txDate.Day(), 0, 0, 0, 0, loc) + endDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + aging := 0 + if !startDay.IsZero() && !endDay.Before(startDay) { + aging = int(endDay.Sub(startDay).Hours() / 24) + } + + totalPrice := 0.0 + for _, ns := range exp.Nonstocks { + totalPrice += ns.Qty * ns.Price + } + + var area *areaDTO.AreaRelationDTO + if exp.Location != nil && exp.Location.Area.Id != 0 { + mapped := areaDTO.ToAreaRelationDTO(exp.Location.Area) + area = &mapped + } + + poNumber := "" + if strings.TrimSpace(exp.PoNumber) != "" { + poNumber = exp.PoNumber + } + + return dto.DebtSupplierRowDTO{ + PrNumber: exp.ReferenceNumber, + PoNumber: poNumber, + PoDate: dateStr, + ReceivedDate: dateStr, + Aging: aging, + Area: area, + Warehouse: nil, + DueDate: "-", + DueStatus: "-", + TotalPrice: totalPrice, + PaymentPrice: 0, + DebtPrice: 0, + Status: "Belum Lunas", + TravelNumber: "-", + Balance: 0, + } +} + +func calculateExpenseAging(exp entity.Expense, endDate time.Time, loc *time.Location) int { + start := exp.TransactionDate.In(loc) + startDay := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, loc) + stopDay := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, loc) + if stopDay.Before(startDay) { + return 0 + } + return int(stopDay.Sub(startDay).Hours() / 24) +} + func (s *repportService) GetHppV2Breakdown(ctx *fiber.Ctx, params *validation.HppV2BreakdownQuery) (*approvalService.HppV2Breakdown, error) { if err := s.Validate.Struct(params); err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, err.Error())