diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 1a8ba034..7a66f247 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -485,6 +485,13 @@ func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error { return err } + if isCustomerPaymentExcelExportRequest(ctx) { + return exportCustomerPaymentExcel(ctx, result) + } + if isCustomerPaymentExcelAllExportRequest(ctx) { + return exportCustomerPaymentExcelAll(ctx, result) + } + // If single customer mode (only 1 customer ID), return without pagination if len(customerIDs) == 1 { return ctx.Status(fiber.StatusOK). diff --git a/internal/modules/repports/controllers/repport.customer_payment.export.go b/internal/modules/repports/controllers/repport.customer_payment.export.go new file mode 100644 index 00000000..a0e9e4b0 --- /dev/null +++ b/internal/modules/repports/controllers/repport.customer_payment.export.go @@ -0,0 +1,585 @@ +package controller + +import ( + "fmt" + "math" + "strconv" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/xuri/excelize/v2" + "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" +) + +func isCustomerPaymentExcelExportRequest(c *fiber.Ctx) bool { + return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") +} + +func isCustomerPaymentExcelAllExportRequest(c *fiber.Ctx) bool { + return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel-all") +} + +func exportCustomerPaymentExcel(c *fiber.Ctx, items []dto.CustomerPaymentReportItem) error { + content, err := buildCustomerPaymentWorkbook(items) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file") + } + + filename := fmt.Sprintf("laporan-kontrol-pembayaran-customer-%s.xlsx", time.Now().Format("2006-01-02-1504")) + c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + return c.Status(fiber.StatusOK).Send(content) +} + +func exportCustomerPaymentExcelAll(c *fiber.Ctx, items []dto.CustomerPaymentReportItem) error { + content, err := buildCustomerPaymentAllWorkbook(items) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file") + } + + filename := fmt.Sprintf("laporan-kontrol-pembayaran-customer-all-%s.xlsx", time.Now().Format("2006-01-02-1504")) + c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + return c.Status(fiber.StatusOK).Send(content) +} + +func buildCustomerPaymentWorkbook(items []dto.CustomerPaymentReportItem) ([]byte, error) { + file := excelize.NewFile() + defer file.Close() + + defaultSheet := file.GetSheetName(file.GetActiveSheetIndex()) + + if len(items) == 0 { + if err := writeCustomerPaymentSheet(file, defaultSheet, dto.CustomerPaymentReportItem{}); err != nil { + return nil, err + } + buf, err := file.WriteToBuffer() + if err != nil { + return nil, err + } + return buf.Bytes(), nil + } + + for idx, item := range items { + sheetName := sanitizeCustomerPaymentSheetName(customerPaymentName(item)) + if sheetName == "" { + sheetName = fmt.Sprintf("Customer %d", idx+1) + } + + if idx == 0 { + if defaultSheet != sheetName { + if err := file.SetSheetName(defaultSheet, sheetName); err != nil { + return nil, err + } + } + } else { + if _, err := file.NewSheet(sheetName); err != nil { + return nil, err + } + } + + if err := writeCustomerPaymentSheet(file, sheetName, item); err != nil { + return nil, err + } + } + + buf, err := file.WriteToBuffer() + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func buildCustomerPaymentAllWorkbook(items []dto.CustomerPaymentReportItem) ([]byte, error) { + file := excelize.NewFile() + defer file.Close() + + const sheet = "Kontrol Pembayaran Customer" + defaultSheet := file.GetSheetName(file.GetActiveSheetIndex()) + if defaultSheet != sheet { + if err := file.SetSheetName(defaultSheet, sheet); err != nil { + return nil, err + } + } + + if err := setCustomerPaymentAllColumns(file, sheet); err != nil { + return nil, err + } + if err := setCustomerPaymentAllHeaders(file, sheet); err != nil { + return nil, err + } + if err := writeCustomerPaymentAllRows(file, sheet, items); err != nil { + return nil, err + } + if err := file.SetPanes(sheet, &excelize.Panes{ + Freeze: true, + YSplit: 1, + TopLeftCell: "A2", + ActivePane: "bottomLeft", + }); err != nil { + return nil, err + } + + buf, err := file.WriteToBuffer() + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +var cpSheetHeaders = []string{ + "No", + "Tanggal DO/Bayar", + "Tanggal Realisasi", + "Aging", + "Referensi", + "Nomor Polisi", + "Ekor/Qty", + "Berat (Kg)", + "AVG", + "Harga/Unit (Rp)", + "Harga Akhir (Rp)", + "Total (Rp)", + "Pembayaran (Rp)", + "Saldo Piutang (Rp)", + "Keterangan", + "Pengambilan", + "Sales/Marketing", +} + +var cpAllSheetHeaders = append([]string{"Customer"}, cpSheetHeaders...) + +var cpSheetColumnWidths = map[string]float64{ + "A": 5, + "B": 15, + "C": 12, + "D": 8, + "E": 12, + "F": 15, + "G": 10, + "H": 12, + "I": 10, + "J": 15, + "K": 15, + "L": 15, + "M": 15, + "N": 15, + "O": 20, + "P": 15, + "Q": 20, +} + +var cpAllSheetColumnWidths = map[string]float64{ + "A": 22, + "B": 6, + "C": 15, + "D": 15, + "E": 8, + "F": 12, + "G": 15, + "H": 10, + "I": 12, + "J": 10, + "K": 15, + "L": 15, + "M": 15, + "N": 15, + "O": 15, + "P": 20, + "Q": 15, + "R": 20, +} + +func writeCustomerPaymentSheet(file *excelize.File, sheet string, item dto.CustomerPaymentReportItem) error { + for col, width := range cpSheetColumnWidths { + if err := file.SetColWidth(sheet, col, col, width); err != nil { + return err + } + } + + // Row 1: headers + for i, h := range cpSheetHeaders { + col, _ := excelize.ColumnNumberToName(i + 1) + if err := file.SetCellValue(sheet, col+"1", h); err != nil { + return err + } + } + + redStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Color: "FF0000"}, + }) + if err != nil { + return err + } + + // Row 2: saldo awal + initialFormatted := formatCPRupiah(item.InitialBalance) + if err := file.SetCellValue(sheet, "N2", initialFormatted); err != nil { + return err + } + if item.InitialBalance < 0 { + if err := file.SetCellStyle(sheet, "N2", "N2", redStyle); err != nil { + return err + } + } + + // Rows 3+: data rows + for i, row := range item.Rows { + rowNum := i + 3 + rowStr := fmt.Sprintf("%d", rowNum) + + cells := customerPaymentRowCells(row, i+1) + for colIdx, val := range cells { + col, _ := excelize.ColumnNumberToName(colIdx + 1) + if err := file.SetCellValue(sheet, col+rowStr, val); err != nil { + return err + } + } + + if row.AccountsReceivable < 0 { + if err := file.SetCellStyle(sheet, "N"+rowStr, "N"+rowStr, redStyle); err != nil { + return err + } + } + } + + // Total row + totalRowNum := len(item.Rows) + 3 + totalRowStr := fmt.Sprintf("%d", totalRowNum) + + totalCells := map[string]string{ + "A": "Total", + "G": formatCPIDInteger(item.Summary.TotalQty), + "H": formatCPIDInteger(item.Summary.TotalWeight), + "K": formatCPRupiah(item.Summary.TotalFinalAmount), + "L": formatCPRupiah(item.Summary.TotalGrandAmount), + "M": formatCPRupiah(item.Summary.TotalPayment), + "N": formatCPRupiah(item.Summary.TotalAccountsReceivable), + } + for col, val := range totalCells { + if err := file.SetCellValue(sheet, col+totalRowStr, val); err != nil { + return err + } + } + if item.Summary.TotalAccountsReceivable < 0 { + if err := file.SetCellStyle(sheet, "N"+totalRowStr, "N"+totalRowStr, redStyle); err != nil { + return err + } + } + + return nil +} + +func setCustomerPaymentAllColumns(file *excelize.File, sheet string) error { + for col, width := range cpAllSheetColumnWidths { + if err := file.SetColWidth(sheet, col, col, width); err != nil { + return err + } + } + return file.SetRowHeight(sheet, 1, 24) +} + +func setCustomerPaymentAllHeaders(file *excelize.File, sheet string) error { + borderStyle := []excelize.Border{ + {Type: "left", Color: "000000", Style: 1}, + {Type: "top", Color: "000000", Style: 1}, + {Type: "bottom", Color: "000000", Style: 1}, + {Type: "right", Color: "000000", Style: 1}, + } + headerStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true, Color: "FFFFFF", Family: "Arial", Size: 10}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}}, + Alignment: &excelize.Alignment{ + Horizontal: "center", + Vertical: "center", + WrapText: true, + }, + Border: borderStyle, + }) + if err != nil { + return err + } + + for i, h := range cpAllSheetHeaders { + col, _ := excelize.ColumnNumberToName(i + 1) + if err := file.SetCellValue(sheet, col+"1", h); err != nil { + return err + } + } + + lastCol, _ := excelize.ColumnNumberToName(len(cpAllSheetHeaders)) + return file.SetCellStyle(sheet, "A1", lastCol+"1", headerStyle) +} + +func writeCustomerPaymentAllRows(file *excelize.File, sheet string, items []dto.CustomerPaymentReportItem) error { + borderStyle := []excelize.Border{ + {Type: "left", Color: "000000", Style: 1}, + {Type: "top", Color: "000000", Style: 1}, + {Type: "bottom", Color: "000000", Style: 1}, + {Type: "right", Color: "000000", Style: 1}, + } + + dataStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Color: "000000", Family: "Arial", Size: 10}, + Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}, + Border: borderStyle, + }) + if err != nil { + return err + } + + totalStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true, Color: "000000", Family: "Arial", Size: 10}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"E2EFDA"}}, + Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}, + Border: borderStyle, + }) + if err != nil { + return err + } + + redDataStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Color: "FF0000", Family: "Arial", Size: 10}, + Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}, + Border: borderStyle, + }) + if err != nil { + return err + } + + redTotalStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true, Color: "FF0000", Family: "Arial", Size: 10}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"E2EFDA"}}, + Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}, + Border: borderStyle, + }) + if err != nil { + return err + } + + lastHeaderCol, _ := excelize.ColumnNumberToName(len(cpAllSheetHeaders)) + currentRow := 2 + + for _, item := range items { + name := customerPaymentName(item) + + // Saldo awal row + saldoStr := fmt.Sprintf("%d", currentRow) + if err := file.SetCellValue(sheet, "A"+saldoStr, name); err != nil { + return err + } + initialFormatted := formatCPRupiah(item.InitialBalance) + if err := file.SetCellValue(sheet, "O"+saldoStr, initialFormatted); err != nil { + return err + } + if err := file.SetCellStyle(sheet, "A"+saldoStr, lastHeaderCol+saldoStr, dataStyle); err != nil { + return err + } + if item.InitialBalance < 0 { + if err := file.SetCellStyle(sheet, "O"+saldoStr, "O"+saldoStr, redDataStyle); err != nil { + return err + } + } + currentRow++ + + // Data rows + for seq, row := range item.Rows { + rowStr := fmt.Sprintf("%d", currentRow) + if err := file.SetCellValue(sheet, "A"+rowStr, name); err != nil { + return err + } + cells := customerPaymentRowCells(row, seq+1) + for colIdx, val := range cells { + col, _ := excelize.ColumnNumberToName(colIdx + 2) + if err := file.SetCellValue(sheet, col+rowStr, val); err != nil { + return err + } + } + if err := file.SetCellStyle(sheet, "A"+rowStr, lastHeaderCol+rowStr, dataStyle); err != nil { + return err + } + if row.AccountsReceivable < 0 { + if err := file.SetCellStyle(sheet, "O"+rowStr, "O"+rowStr, redDataStyle); err != nil { + return err + } + } + currentRow++ + } + + // Total row + totalStr := fmt.Sprintf("%d", currentRow) + totalCells := map[string]string{ + "A": name, + "B": "Total", + "H": formatCPIDInteger(item.Summary.TotalQty), + "I": formatCPIDInteger(item.Summary.TotalWeight), + "L": formatCPRupiah(item.Summary.TotalFinalAmount), + "M": formatCPRupiah(item.Summary.TotalGrandAmount), + "N": formatCPRupiah(item.Summary.TotalPayment), + "O": formatCPRupiah(item.Summary.TotalAccountsReceivable), + } + for col, val := range totalCells { + if err := file.SetCellValue(sheet, col+totalStr, val); err != nil { + return err + } + } + if err := file.SetCellStyle(sheet, "A"+totalStr, lastHeaderCol+totalStr, totalStyle); err != nil { + return err + } + if item.Summary.TotalAccountsReceivable < 0 { + if err := file.SetCellStyle(sheet, "O"+totalStr, "O"+totalStr, redTotalStyle); err != nil { + return err + } + } + currentRow++ + + // Empty separator row + currentRow++ + } + + return nil +} + +// customerPaymentRowCells returns 17 cell values for cols A..Q. +func customerPaymentRowCells(row dto.CustomerPaymentReportRow, seq int) []interface{} { + return []interface{}{ + seq, + formatCPDate(row.TransDate), + formatCPOptionalDate(row.DeliveryDate), + formatCPAging(row.AgingDay), + safeCPText(row.Reference), + joinCPStrings(row.VehicleNumbers), + formatCPIDInteger(row.Qty), + formatCPIDInteger(row.Weight), + formatCPAvg(row.AverageWeight), + formatCPRupiah(row.UnitPrice), + formatCPRupiah(row.FinalPrice), + formatCPRupiah(row.TotalPrice), + formatCPRupiah(row.PaymentAmount), + formatCPRupiah(row.AccountsReceivable), + safeCPText(row.Status), + joinCPStrings(row.PickupInfo), + safeCPText(row.SalesPerson), + } +} + +func customerPaymentName(item dto.CustomerPaymentReportItem) string { + name := strings.TrimSpace(item.Customer.Name) + if name == "" { + return "Customer" + } + return name +} + +func sanitizeCustomerPaymentSheetName(name string) string { + replacer := strings.NewReplacer( + ":", " ", "\\", " ", "/", " ", + "?", " ", "*", " ", "[", " ", "]", " ", + ) + sanitized := strings.TrimSpace(replacer.Replace(name)) + if sanitized == "" { + return "Sheet" + } + runes := []rune(sanitized) + if len(runes) > 31 { + return string(runes[:31]) + } + return sanitized +} + +var cpIndonesianMonths = [12]string{ + "Jan", "Feb", "Mar", "Apr", "Mei", "Jun", + "Jul", "Agu", "Sep", "Okt", "Nov", "Des", +} + +func formatCPDate(t time.Time) string { + if t.IsZero() { + return "-" + } + loc, err := time.LoadLocation("Asia/Jakarta") + if err == nil { + t = t.In(loc) + } + return fmt.Sprintf("%02d %s %d", t.Day(), cpIndonesianMonths[t.Month()-1], t.Year()) +} + +func formatCPOptionalDate(t *time.Time) string { + if t == nil || t.IsZero() { + return "-" + } + return formatCPDate(*t) +} + +func formatCPAging(v *int) string { + if v == nil { + return "-" + } + return strconv.Itoa(*v) +} + +func formatCPIDInteger(v float64) string { + n := int64(math.Round(v)) + if n == 0 { + return "0" + } + negative := n < 0 + abs := n + if negative { + abs = -n + } + s := strconv.FormatInt(abs, 10) + // insert dots as thousand separators + var b strings.Builder + start := len(s) % 3 + if start == 0 { + start = 3 + } + b.WriteString(s[:start]) + for i := start; i < len(s); i += 3 { + b.WriteByte('.') + b.WriteString(s[i : i+3]) + } + if negative { + return "-" + b.String() + } + return b.String() +} + +func formatCPRupiah(v float64) string { + const nbsp = " " + if v < 0 { + return "-Rp" + nbsp + formatCPIDInteger(-v) + } + return "Rp" + nbsp + formatCPIDInteger(v) +} + +func formatCPAvg(v float64) string { + if v == 0 { + return "0" + } + s := strconv.FormatFloat(v, 'f', 2, 64) + return strings.ReplaceAll(s, ".", ",") +} + +func safeCPText(s string) string { + t := strings.TrimSpace(s) + if t == "" { + return "-" + } + return t +} + +func joinCPStrings(ss []string) string { + var parts []string + for _, s := range ss { + s = strings.TrimSpace(s) + if s != "" { + parts = append(parts, s) + } + } + if len(parts) == 0 { + return "-" + } + return strings.Join(parts, "\n") +}