diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index cc505ee8..1a8ba034 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -392,6 +392,13 @@ func (c *RepportController) GetDebtSupplier(ctx *fiber.Ctx) error { return err } + if isDebtSupplierExcelExportRequest(ctx) { + return exportDebtSupplierExcel(ctx, result) + } + if isDebtSupplierExcelAllExportRequest(ctx) { + return exportDebtSupplierExcelAll(ctx, result) + } + supplierIDs = query.SupplierIDs if supplierIDs == nil { supplierIDs = []int64{} @@ -505,6 +512,83 @@ func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error { }) } +type BalanceMonitoringResponse struct { + Code int `json:"code"` + Status string `json:"status"` + Message string `json:"message"` + Meta response.Meta `json:"meta"` + Data []dto.BalanceMonitoringRowDTO `json:"data"` + Totals dto.BalanceMonitoringTotalsDTO `json:"totals"` +} + +func (c *RepportController) GetBalanceMonitoring(ctx *fiber.Ctx) error { + customerIDs, err := parseUintCSV(ctx.Query("customer_ids")) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "customer_ids must be comma separated positive integers") + } + salesIDs, err := parseUintCSV(ctx.Query("sales_ids")) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "sales_ids must be comma separated positive integers") + } + + query := &validation.BalanceMonitoringQuery{ + Page: ctx.QueryInt("page", 1), + Limit: ctx.QueryInt("limit", 10), + CustomerIDs: customerIDs, + SalesIDs: salesIDs, + FilterBy: strings.ToLower(ctx.Query("filter_by", "")), + SortBy: ctx.Query("sort_by", ""), + SortOrder: ctx.Query("sort_order", ""), + StartDate: ctx.Query("start_date", ""), + EndDate: ctx.Query("end_date", ""), + } + + result, totals, totalResults, err := c.RepportService.GetBalanceMonitoring(ctx, query) + if err != nil { + return err + } + + limit := query.Limit + if limit < 1 { + limit = 10 + } + + return ctx.Status(fiber.StatusOK).JSON(BalanceMonitoringResponse{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get balance monitoring report successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(limit))), + TotalResults: totalResults, + }, + Data: result, + Totals: totals, + }) +} + +func parseUintCSV(raw string) ([]uint, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + parts := strings.Split(raw, ",") + result := make([]uint, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + id, err := strconv.ParseUint(part, 10, 32) + if err != nil || id == 0 { + return nil, fmt.Errorf("invalid id: %s", part) + } + result = append(result, uint(id)) + } + return result, nil +} + func (c *RepportController) GetProductionResult(ctx *fiber.Ctx) error { idParam := ctx.Params("idProjectFlockKandang") if idParam == "" { diff --git a/internal/modules/repports/controllers/repport.debt_supplier.export.go b/internal/modules/repports/controllers/repport.debt_supplier.export.go new file mode 100644 index 00000000..37a8710b --- /dev/null +++ b/internal/modules/repports/controllers/repport.debt_supplier.export.go @@ -0,0 +1,452 @@ +package controller + +import ( + "fmt" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/xuri/excelize/v2" + "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" +) + +func isDebtSupplierExcelExportRequest(c *fiber.Ctx) bool { + return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") +} + +func isDebtSupplierExcelAllExportRequest(c *fiber.Ctx) bool { + return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel-all") +} + +func exportDebtSupplierExcel(c *fiber.Ctx, items []dto.DebtSupplierDTO) error { + content, err := buildDebtSupplierWorkbook(items) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file") + } + + filename := fmt.Sprintf("laporan-hutang-supplier-%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 exportDebtSupplierExcelAll(c *fiber.Ctx, items []dto.DebtSupplierDTO) error { + content, err := buildDebtSupplierAllWorkbook(items) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file") + } + + filename := fmt.Sprintf("laporan-hutang-supplier-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) +} + +// buildDebtSupplierWorkbook creates a workbook with one sheet per supplier. +func buildDebtSupplierWorkbook(items []dto.DebtSupplierDTO) ([]byte, error) { + file := excelize.NewFile() + defer file.Close() + + defaultSheet := file.GetSheetName(file.GetActiveSheetIndex()) + + if len(items) == 0 { + if err := writeDebtSupplierSheet(file, defaultSheet, dto.DebtSupplierDTO{}); 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 := sanitizeDebtSupplierSheetName(debtSupplierName(item)) + if sheetName == "" { + sheetName = fmt.Sprintf("Supplier %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 := writeDebtSupplierSheet(file, sheetName, item); err != nil { + return nil, err + } + } + + buf, err := file.WriteToBuffer() + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// buildDebtSupplierAllWorkbook creates a single-sheet workbook with purchase-supplier styling. +func buildDebtSupplierAllWorkbook(items []dto.DebtSupplierDTO) ([]byte, error) { + file := excelize.NewFile() + defer file.Close() + + const sheet = "Rekap Hutang Supplier" + defaultSheet := file.GetSheetName(file.GetActiveSheetIndex()) + if defaultSheet != sheet { + if err := file.SetSheetName(defaultSheet, sheet); err != nil { + return nil, err + } + } + + if err := setDebtSupplierAllColumns(file, sheet); err != nil { + return nil, err + } + if err := setDebtSupplierAllHeaders(file, sheet); err != nil { + return nil, err + } + if err := writeDebtSupplierAllRows(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 debtSupplierSheetHeaders = []string{ + "No", + "Nomor PR", + "Nomor PO", + "Tanggal Terima/Bayar", + "Tanggal PO", + "Aging (Hari)", + "Area", + "Gudang", + "Jatuh Tempo", + "Status Jatuh Tempo", + "Nominal Pembelian (Rp)", + "Pembayaran (Rp)", + "Sisa Saldo Hutang (Rp)", + "Status", + "Nomor Perjalanan", +} + +var debtSupplierAllSheetHeaders = append([]string{"Supplier"}, debtSupplierSheetHeaders...) + +var debtSupplierSheetColumnWidths = map[string]float64{ + "A": 5, + "B": 14, + "C": 12, + "D": 20, + "E": 10, + "F": 12, + "G": 15, + "H": 20, + "I": 12, + "J": 20, + "K": 20, + "L": 15, + "M": 20, + "N": 12, + "O": 15, +} + +var debtSupplierAllSheetColumnWidths = map[string]float64{ + "A": 24, + "B": 6, + "C": 14, + "D": 14, + "E": 20, + "F": 12, + "G": 10, + "H": 16, + "I": 22, + "J": 12, + "K": 22, + "L": 20, + "M": 18, + "N": 22, + "O": 14, + "P": 18, +} + +func writeDebtSupplierSheet(file *excelize.File, sheet string, item dto.DebtSupplierDTO) error { + for col, width := range debtSupplierSheetColumnWidths { + if err := file.SetColWidth(sheet, col, col, width); err != nil { + return err + } + } + + // Row 1: headers + for i, h := range debtSupplierSheetHeaders { + col, _ := excelize.ColumnNumberToName(i + 1) + if err := file.SetCellValue(sheet, col+"1", h); err != nil { + return err + } + } + + // Row 2: saldo awal + if err := file.SetCellValue(sheet, "M2", item.InitialBalance); err != nil { + return err + } + + // Rows 3+: data + redStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Color: "FF0000"}, + }) + if err != nil { + return err + } + + for i, row := range item.Rows { + rowNum := i + 3 + rowStr := fmt.Sprintf("%d", rowNum) + + values := debtSupplierRowCells(row, i+1) + for colIdx, val := range values { + col, _ := excelize.ColumnNumberToName(colIdx + 1) + if err := file.SetCellValue(sheet, col+rowStr, val); err != nil { + return err + } + } + + if row.DebtPrice < 0 { + if err := file.SetCellStyle(sheet, "M"+rowStr, "M"+rowStr, redStyle); err != nil { + return err + } + } + } + + // Total row + totalRowNum := len(item.Rows) + 3 + totalRowStr := fmt.Sprintf("%d", totalRowNum) + totalCells := map[string]interface{}{ + "A": "Total", + "F": item.Total.Aging, + "K": item.Total.TotalPrice, + "L": item.Total.PaymentPrice, + "M": item.Total.DebtPrice, + } + for col, val := range totalCells { + if err := file.SetCellValue(sheet, col+totalRowStr, val); err != nil { + return err + } + } + if item.Total.DebtPrice < 0 { + if err := file.SetCellStyle(sheet, "M"+totalRowStr, "M"+totalRowStr, redStyle); err != nil { + return err + } + } + + return nil +} + +func setDebtSupplierAllColumns(file *excelize.File, sheet string) error { + for col, width := range debtSupplierAllSheetColumnWidths { + if err := file.SetColWidth(sheet, col, col, width); err != nil { + return err + } + } + if err := file.SetRowHeight(sheet, 1, 24); err != nil { + return err + } + return nil +} + +func setDebtSupplierAllHeaders(file *excelize.File, sheet string) error { + 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: []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}, + }, + }) + if err != nil { + return err + } + + for i, h := range debtSupplierAllSheetHeaders { + col, _ := excelize.ColumnNumberToName(i + 1) + if err := file.SetCellValue(sheet, col+"1", h); err != nil { + return err + } + } + + lastCol, _ := excelize.ColumnNumberToName(len(debtSupplierAllSheetHeaders)) + return file.SetCellStyle(sheet, "A1", lastCol+"1", headerStyle) +} + +func writeDebtSupplierAllRows(file *excelize.File, sheet string, items []dto.DebtSupplierDTO) 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 + } + + lastHeaderCol, _ := excelize.ColumnNumberToName(len(debtSupplierAllSheetHeaders)) + + currentRow := 2 + for _, item := range items { + supplierName := debtSupplierName(item) + + // Saldo awal row + saldoRowStr := fmt.Sprintf("%d", currentRow) + if err := file.SetCellValue(sheet, "A"+saldoRowStr, supplierName); err != nil { + return err + } + if err := file.SetCellValue(sheet, "N"+saldoRowStr, item.InitialBalance); err != nil { + return err + } + if err := file.SetCellStyle(sheet, "A"+saldoRowStr, lastHeaderCol+saldoRowStr, dataStyle); 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, supplierName); err != nil { + return err + } + values := debtSupplierRowCells(row, seq+1) + for colIdx, val := range values { + 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 + } + currentRow++ + } + + // Total row + totalRowStr := fmt.Sprintf("%d", currentRow) + totalCells := map[string]interface{}{ + "A": supplierName, + "B": "Total", + "L": item.Total.TotalPrice, + "M": item.Total.PaymentPrice, + "N": item.Total.DebtPrice, + } + for col, val := range totalCells { + if err := file.SetCellValue(sheet, col+totalRowStr, val); err != nil { + return err + } + } + if err := file.SetCellStyle(sheet, "A"+totalRowStr, lastHeaderCol+totalRowStr, totalStyle); err != nil { + return err + } + currentRow++ + + // Empty separator row + currentRow++ + } + + return nil +} + +// debtSupplierRowCells returns cell values for one data row (columns: No, PR, PO, ReceivedDate, PoDate, Aging, Area, Warehouse, DueDate, DueStatus, TotalPrice, PaymentPrice, DebtPrice, Status, TravelNumber). +func debtSupplierRowCells(row dto.DebtSupplierRowDTO, seq int) []interface{} { + areaName := "-" + if row.Area != nil && strings.TrimSpace(row.Area.Name) != "" { + areaName = row.Area.Name + } + warehouseName := "-" + if row.Warehouse != nil && strings.TrimSpace(row.Warehouse.Name) != "" { + warehouseName = row.Warehouse.Name + } + + return []interface{}{ + seq, + safeDebtSupplierText(row.PrNumber), + safeDebtSupplierText(row.PoNumber), + safeDebtSupplierText(row.ReceivedDate), + safeDebtSupplierText(row.PoDate), + row.Aging, + areaName, + warehouseName, + safeDebtSupplierText(row.DueDate), + safeDebtSupplierText(row.DueStatus), + row.TotalPrice, + row.PaymentPrice, + row.DebtPrice, + safeDebtSupplierText(row.Status), + safeDebtSupplierText(row.TravelNumber), + } +} + +func debtSupplierName(item dto.DebtSupplierDTO) string { + if item.Supplier != nil && strings.TrimSpace(item.Supplier.Name) != "" { + return item.Supplier.Name + } + return "Supplier" +} + +func sanitizeDebtSupplierSheetName(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 +} + +func safeDebtSupplierText(s string) string { + t := strings.TrimSpace(s) + if t == "" { + return "-" + } + return t +}