package controller import ( "fmt" "math" "strconv" "strings" "time" "github.com/go-pdf/fpdf" "github.com/gofiber/fiber/v2" "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" ) // --------------------------------------------------------------------------- // Trigger // --------------------------------------------------------------------------- func isMarketingPdfExportRequest(c *fiber.Ctx) bool { return strings.EqualFold(strings.TrimSpace(c.Query("export")), "pdf") } // --------------------------------------------------------------------------- // HTTP handler // --------------------------------------------------------------------------- func exportMarketingReportPdf(c *fiber.Ctx, items []dto.RepportMarketingItemDTO, query marketingPdfQueryMeta) error { content, err := buildMarketingReportPdf(items, query) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to generate pdf file") } filename := fmt.Sprintf("laporan_marketing_harian_%s.pdf", time.Now().Format("20060102_150405")) c.Set("Content-Type", "application/pdf") c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) return c.Status(fiber.StatusOK).Send(content) } // marketingPdfQueryMeta holds display metadata for the PDF header. type marketingPdfQueryMeta struct { StartDate string // e.g. "01 April 2026" EndDate string // e.g. "29 April 2026" PrintedAt time.Time } // --------------------------------------------------------------------------- // Column definitions // --------------------------------------------------------------------------- type pdfColumn struct { header string width float64 // mm align string // "L", "C", "R" } var marketingPdfColumns = []pdfColumn{ {"No", 6, "C"}, {"Tanggal\nJual", 16, "C"}, {"Tanggal\nRealisasi", 16, "C"}, {"Aging\n(Hari)", 9, "C"}, {"Gudang\nFisik", 20, "L"}, {"Pelanggan", 20, "L"}, {"No. DO", 18, "L"}, {"Sales", 18, "L"}, {"No. Polisi", 18, "L"}, {"Tipe\nMarketing", 14, "C"}, {"Produk", 16, "L"}, {"Kuantitas", 13, "R"}, {"Bobot Rata-Rata\n(Kg)", 13, "R"}, {"Bobot Total Berat\n(Kg)", 14, "R"}, {"Harga Jual\n(Rp)", 17, "R"}, {"HPP\n(Rp)", 17, "R"}, {"Total Jual\n(Rp)", 18, "R"}, {"Total HPP\n(Rp)", 18, "R"}, } // --------------------------------------------------------------------------- // Colours // --------------------------------------------------------------------------- const ( headerR, headerG, headerB = 30, 64, 120 // dark blue header bg headerTextR, headerTextG, headerTextB = 255, 255, 255 // white header text rowAltR, rowAltG, rowAltB = 245, 247, 250 // alternating row bg borderR, borderG, borderB = 200, 200, 200 // light border badgeTelurR, badgeTelurG, badgeTelurB = 59, 130, 246 // blue badgeAyamR, badgeAyamG, badgeAyamB = 34, 197, 94 // green badgeTradingR, badgeTradingG, badgeTradingB = 249, 115, 22 // orange badgeDefaultR, badgeDefaultG, badgeDefaultB = 107, 114, 128 // gray ) // --------------------------------------------------------------------------- // Workbook builder // --------------------------------------------------------------------------- func buildMarketingReportPdf(items []dto.RepportMarketingItemDTO, meta marketingPdfQueryMeta) ([]byte, error) { pdf := fpdf.New("L", "mm", "A4", "") pdf.SetMargins(10, 12, 10) pdf.SetAutoPageBreak(true, 12) pdf.AddPage() // ---- title ---- pdf.SetFont("Helvetica", "B", 14) pdf.SetTextColor(30, 64, 120) pdf.CellFormat(0, 8, "Laporan > Penjualan Harian", "", 1, "L", false, 0, "") // ---- subtitle ---- pdf.SetFont("Helvetica", "", 8) pdf.SetTextColor(80, 80, 80) dateLabel := buildMarketingPdfDateLabel(meta) printedAt := formatMarketingPdfDateTime(meta.PrintedAt) pdf.CellFormat(0, 5, fmt.Sprintf("%s Dicetak: %s", dateLabel, printedAt), "", 1, "L", false, 0, "") pdf.Ln(3) // ---- table ---- writeMarketingPdfHeader(pdf) writeMarketingPdfRows(pdf, items) writeMarketingPdfTotal(pdf, items) return marshalMarketingPdf(pdf) } func marshalMarketingPdf(pdf *fpdf.Fpdf) ([]byte, error) { w := &pdfByteBuffer{} err := pdf.Output(w) if err != nil { return nil, err } return w.Bytes(), nil } // pdfByteBuffer implements io.Writer and accumulates bytes. type pdfByteBuffer struct { buf []byte } func (b *pdfByteBuffer) Write(p []byte) (n int, err error) { b.buf = append(b.buf, p...) return len(p), nil } func (b *pdfByteBuffer) Bytes() []byte { return b.buf } // --------------------------------------------------------------------------- // Header row // --------------------------------------------------------------------------- func writeMarketingPdfHeader(pdf *fpdf.Fpdf) { pdf.SetFont("Helvetica", "B", 6.5) pdf.SetFillColor(headerR, headerG, headerB) pdf.SetTextColor(headerTextR, headerTextG, headerTextB) pdf.SetDrawColor(borderR, borderG, borderB) pdf.SetLineWidth(0.1) headerH := 9.0 // height of header row (mm) x0 := pdf.GetX() y0 := pdf.GetY() for _, col := range marketingPdfColumns { lines := strings.Split(col.header, "\n") lineH := headerH / float64(len(lines)) x := pdf.GetX() for li, line := range lines { pdf.SetXY(x, y0+float64(li)*lineH) pdf.CellFormat(col.width, lineH, line, "", 0, "C", true, 0, "") } pdf.SetXY(x+col.width, y0) } _ = x0 pdf.SetXY(10, y0+headerH) pdf.Ln(0) } // --------------------------------------------------------------------------- // Data rows // --------------------------------------------------------------------------- func writeMarketingPdfRows(pdf *fpdf.Fpdf, items []dto.RepportMarketingItemDTO) { pdf.SetFont("Helvetica", "", 6) pdf.SetDrawColor(borderR, borderG, borderB) pdf.SetLineWidth(0.1) lineH := 5.0 for idx, item := range items { values := marketingPdfRowValues(idx+1, item) rowH := calcMarketingRowHeight(pdf, values, lineH) if pdf.GetY()+rowH > marketingPdfPageHeight(pdf)-12 { pdf.AddPage() writeMarketingPdfHeader(pdf) pdf.SetFont("Helvetica", "", 6) } var fillR, fillG, fillB int if idx%2 == 1 { fillR, fillG, fillB = rowAltR, rowAltG, rowAltB } else { fillR, fillG, fillB = 255, 255, 255 } pdf.SetTextColor(40, 40, 40) y := pdf.GetY() writeMarketingPdfRow(pdf, item, values, lineH, rowH, y, fillR, fillG, fillB) } } func calcMarketingRowHeight(pdf *fpdf.Fpdf, values []string, lineH float64) float64 { margin := pdf.GetCellMargin() cols := marketingPdfColumns maxLines := 1 for i, col := range cols { if i >= len(values) || i == 10 { continue } usableW := col.width - 2*margin if usableW <= 0 { continue } lines := pdf.SplitLines([]byte(values[i]), usableW) n := len(lines) if n == 0 { n = 1 } if n > maxLines { maxLines = n } } return float64(maxLines) * lineH } func writeMarketingPdfRow(pdf *fpdf.Fpdf, item dto.RepportMarketingItemDTO, values []string, lineH, rowH, y float64, fillR, fillG, fillB int) { cols := marketingPdfColumns x := 10.0 for i, col := range cols { if i == 10 { drawMarketingTypeBadge(pdf, x, y, col.width, rowH, item.MarketingType) pdf.SetDrawColor(borderR, borderG, borderB) pdf.SetTextColor(40, 40, 40) } else { pdf.SetFillColor(fillR, fillG, fillB) pdf.SetDrawColor(borderR, borderG, borderB) pdf.Rect(x, y, col.width, rowH, "FD") pdf.SetTextColor(40, 40, 40) pdf.SetXY(x, y) pdf.MultiCell(col.width, lineH, values[i], "", col.align, false) } x += col.width } pdf.SetXY(10, y+rowH) } func marketingPdfRowValues(no int, item dto.RepportMarketingItemDTO) []string { warehouse := "-" if item.Warehouse != nil { warehouse = safeMarketingExportText(item.Warehouse.Name) } customer := "-" if item.Customer != nil { customer = safeMarketingExportText(item.Customer.Name) } sales := "-" if item.Sales != nil { sales = safeMarketingExportText(item.Sales.Name) } product := "-" if item.Product != nil { product = safeMarketingExportText(item.Product.Name) } return []string{ strconv.Itoa(no), formatMarketingDate(item.SoDate), formatMarketingDate(item.RealizationDate), strconv.Itoa(item.AgingDays), warehouse, customer, safeMarketingExportText(item.DoNumber), sales, safeMarketingExportText(item.VehicleNumber), safeMarketingExportText(item.MarketingType), // index 10, overridden by badge product, formatMarketingPdfNumber(item.Qty), formatMarketingPdfDecimal(item.AverageWeightKg), formatMarketingPdfDecimal(item.TotalWeightKg), formatMarketingPdfRupiah(item.SalesPricePerKg), formatMarketingPdfRupiah(item.HppPricePerKg), formatMarketingPdfRupiah(item.SalesAmount), formatMarketingPdfRupiah(item.HppAmount), } } // --------------------------------------------------------------------------- // Total row // --------------------------------------------------------------------------- func writeMarketingPdfTotal(pdf *fpdf.Fpdf, items []dto.RepportMarketingItemDTO) { summary := dto.ToSummaryFromDTOItems(items) if summary == nil { return } rowH := 6.5 if pdf.GetY()+rowH > marketingPdfPageHeight(pdf)-12 { pdf.AddPage() writeMarketingPdfHeader(pdf) } pdf.SetFont("Helvetica", "B", 6) pdf.SetFillColor(220, 230, 245) pdf.SetTextColor(30, 64, 120) pdf.SetDrawColor(borderR, borderG, borderB) pdf.SetLineWidth(0.1) y := pdf.GetY() x := 10.0 // merge first 11 cols (No … Tipe Marketing) into "TOTAL" label mergedWidth := 0.0 for i := 0; i < 11; i++ { mergedWidth += marketingPdfColumns[i].width } pdf.SetXY(x, y) pdf.CellFormat(mergedWidth, rowH, "TOTAL", "1", 0, "R", true, 0, "") x += mergedWidth totals := []string{ formatMarketingPdfNumber(float64(summary.TotalQty)), formatMarketingPdfDecimal(summary.AverageWeightKg), formatMarketingPdfDecimal(summary.TotalWeightKg), formatMarketingPdfRupiah(summary.AverageSalesPrice), formatMarketingPdfRupiah(summary.TotalHppPricePerKg), formatMarketingPdfRupiah(float64(summary.TotalSalesAmount)), formatMarketingPdfRupiah(float64(summary.TotalHppAmount)), } for i, val := range totals { col := marketingPdfColumns[11+i] pdf.SetXY(x, y) pdf.CellFormat(col.width, rowH, val, "1", 0, "R", true, 0, "") x += col.width } pdf.SetXY(10, y+rowH) } // --------------------------------------------------------------------------- // Badge drawer // --------------------------------------------------------------------------- func drawMarketingTypeBadge(pdf *fpdf.Fpdf, x, y, w, h float64, marketingType string) { lower := strings.ToLower(strings.TrimSpace(marketingType)) var r, g, b int switch lower { case "telur": r, g, b = badgeTelurR, badgeTelurG, badgeTelurB case "ayam": r, g, b = badgeAyamR, badgeAyamG, badgeAyamB case "trading": r, g, b = badgeTradingR, badgeTradingG, badgeTradingB default: r, g, b = badgeDefaultR, badgeDefaultG, badgeDefaultB } // badge background (slightly inset from the cell border) padH := 1.2 padV := 1.2 bx := x + padH by := y + padV bw := w - padH*2 bh := h - padV*2 pdf.SetFillColor(r, g, b) pdf.SetDrawColor(r, g, b) pdf.RoundedRect(bx, by, bw, bh, 1.5, "1234", "FD") // border of the cell itself (transparent bg, just border) pdf.SetDrawColor(borderR, borderG, borderB) pdf.SetFillColor(255, 255, 255) pdf.SetXY(x, y) pdf.CellFormat(w, h, "", "1", 0, "C", false, 0, "") // badge label pdf.SetFont("Helvetica", "B", 5.5) pdf.SetTextColor(255, 255, 255) label := strings.Title(strings.ToLower(marketingType)) if label == "" { label = "-" } textW := pdf.GetStringWidth(label) pdf.SetXY(bx+(bw-textW)/2, by+(bh-3)/2) pdf.CellFormat(textW, 3, label, "", 0, "C", false, 0, "") // reset pdf.SetFont("Helvetica", "", 6) pdf.SetTextColor(40, 40, 40) pdf.SetDrawColor(borderR, borderG, borderB) } // --------------------------------------------------------------------------- // Helpers — date/time // --------------------------------------------------------------------------- // buildMarketingPdfMeta converts raw query string dates into display meta. func buildMarketingPdfMeta(startDate, endDate string) marketingPdfQueryMeta { loc, _ := time.LoadLocation("Asia/Jakarta") format := func(s string) string { t, err := time.ParseInLocation("2006-01-02", s, loc) if err != nil { return s } return t.Format("02 January 2006") } return marketingPdfQueryMeta{ StartDate: format(startDate), EndDate: format(endDate), PrintedAt: time.Now(), } } func buildMarketingPdfDateLabel(meta marketingPdfQueryMeta) string { if meta.StartDate != "" && meta.EndDate != "" { return fmt.Sprintf("Tanggal: %s - %s", meta.StartDate, meta.EndDate) } if meta.StartDate != "" { return fmt.Sprintf("Tanggal: %s", meta.StartDate) } if meta.EndDate != "" { return fmt.Sprintf("Tanggal: %s", meta.EndDate) } return fmt.Sprintf("Tanggal: %s", time.Now().Format("02 January 2006")) } func formatMarketingPdfDateTime(t time.Time) string { if t.IsZero() { t = time.Now() } loc, err := time.LoadLocation("Asia/Jakarta") if err == nil { t = t.In(loc) } return t.Format("02 Jan 2006 15:04") } // --------------------------------------------------------------------------- // Helpers — number formatting // --------------------------------------------------------------------------- // formatMarketingPdfNumber formats a float64 as an integer with period-thousands separator. // e.g. 7299 → "7.299" func formatMarketingPdfNumber(v float64) string { return formatMarketingPdfThousands(int64(math.Round(v))) } // formatMarketingPdfDecimal formats a float64 with 2 decimal places (Indonesian locale). // e.g. 452.54 → "452,54" func formatMarketingPdfDecimal(v float64) string { rounded := math.Round(v*100) / 100 intPart := int64(rounded) fracPart := int64(math.Round((rounded - float64(intPart)) * 100)) if fracPart < 0 { fracPart = -fracPart } return fmt.Sprintf("%s,%02d", formatMarketingPdfThousands(intPart), fracPart) } // formatMarketingPdfRupiah formats a float64 as Rupiah with Indonesian locale. // Drops trailing ",00" decimals for whole numbers. // e.g. 25500 → "Rp 25.500" | 240896.94 → "Rp 240.896,94" func formatMarketingPdfRupiah(v float64) string { rounded := math.Round(v*100) / 100 intPart := int64(rounded) fracPart := int64(math.Round((rounded - float64(intPart)) * 100)) if fracPart < 0 { fracPart = -fracPart } negative := intPart < 0 absInt := intPart if negative { absInt = -intPart } s := formatMarketingPdfThousands(absInt) var result string if fracPart == 0 { result = "Rp " + s } else { result = fmt.Sprintf("Rp %s,%02d", s, fracPart) } if negative { return "Rp -" + s } return result } // marketingPdfPageHeight returns the page height in mm. func marketingPdfPageHeight(pdf *fpdf.Fpdf) float64 { _, h := pdf.GetPageSize() return h } // formatMarketingPdfThousands inserts period every 3 digits. func formatMarketingPdfThousands(v int64) string { negative := v < 0 abs := v if negative { abs = -v } numStr := strconv.FormatInt(abs, 10) n := len(numStr) var b strings.Builder for i, c := range numStr { if i > 0 && (n-i)%3 == 0 { b.WriteByte('.') } b.WriteRune(c) } if negative { return "-" + b.String() } return b.String() }