add export excel and pdf report penjualan

This commit is contained in:
giovanni
2026-04-29 11:52:03 +07:00
parent 196bbf4277
commit 16ac54ff39
5 changed files with 794 additions and 0 deletions
+1
View File
@@ -52,6 +52,7 @@ require (
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-pdf/fpdf v0.9.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/google/go-cmp v0.6.0 // indirect
+2
View File
@@ -77,6 +77,8 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -246,6 +246,16 @@ func (c *RepportController) GetMarketing(ctx *fiber.Ctx) error {
if err != nil {
return err
}
if isMarketingExcelExportRequest(ctx) {
return exportMarketingReportExcel(ctx, result)
}
if isMarketingPdfExportRequest(ctx) {
meta := buildMarketingPdfMeta(query.StartDate, query.EndDate)
return exportMarketingReportPdf(ctx, result, meta)
}
total := dto.ToSummaryFromDTOItems(result)
return ctx.Status(fiber.StatusOK).
@@ -0,0 +1,271 @@
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"
)
const marketingReportExportSheetName = "Laporan Marketing Harian"
func isMarketingExcelExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel")
}
func exportMarketingReportExcel(c *fiber.Ctx, items []dto.RepportMarketingItemDTO) error {
content, err := buildMarketingReportWorkbook(items)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("laporan_marketing_harian_%s.xlsx", time.Now().Format("20060102_150405"))
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 buildMarketingReportWorkbook(items []dto.RepportMarketingItemDTO) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if defaultSheet != marketingReportExportSheetName {
if err := file.SetSheetName(defaultSheet, marketingReportExportSheetName); err != nil {
return nil, err
}
}
if err := setMarketingReportColumns(file); err != nil {
return nil, err
}
if err := setMarketingReportHeaders(file); err != nil {
return nil, err
}
if err := setMarketingReportRows(file, items); err != nil {
return nil, err
}
buffer, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func setMarketingReportColumns(file *excelize.File) error {
columnWidths := map[string]float64{
"A": 10,
"B": 15,
"C": 18,
"D": 10,
"E": 25,
"F": 25,
"G": 15,
"H": 20,
"I": 15,
"J": 15,
"K": 20,
"L": 12,
"M": 20,
"N": 18,
"O": 18,
"P": 15,
"Q": 20,
"R": 20,
}
sheet := marketingReportExportSheetName
for col, width := range columnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
return nil
}
func setMarketingReportHeaders(file *excelize.File) error {
sheet := marketingReportExportSheetName
headers := []string{
"No",
"Tanggal Jual",
"Tanggal Realisasi",
"Aging",
"Gudang Fisik",
"Pelanggan",
"No. DO",
"Sales/Marketing",
"No. Polisi",
"Marketing Type",
"Produk",
"Kuantitas",
"Bobot Rata-Rata (Kg)",
"Bobot Total (Kg)",
"Harga Jual (Rp)",
"HPP (Rp)",
"HPP Amount (Rp)",
"Total (Rp)",
}
for i, header := range headers {
colName, err := excelize.ColumnNumberToName(i + 1)
if err != nil {
return err
}
if err := file.SetCellValue(sheet, colName+"1", header); err != nil {
return err
}
}
return nil
}
func setMarketingReportRows(file *excelize.File, items []dto.RepportMarketingItemDTO) error {
sheet := marketingReportExportSheetName
summary := dto.ToSummaryFromDTOItems(items)
for idx, item := range items {
row := strconv.Itoa(idx + 2)
warehouseName := "-"
if item.Warehouse != nil {
warehouseName = safeMarketingExportText(item.Warehouse.Name)
}
customerName := "-"
if item.Customer != nil {
customerName = safeMarketingExportText(item.Customer.Name)
}
salesName := "-"
if item.Sales != nil {
salesName = safeMarketingExportText(item.Sales.Name)
}
productName := "-"
if item.Product != nil {
productName = safeMarketingExportText(item.Product.Name)
}
agingText := fmt.Sprintf("%d hari", item.AgingDays)
values := []interface{}{
idx + 1,
formatMarketingDate(item.SoDate),
formatMarketingDate(item.RealizationDate),
agingText,
warehouseName,
customerName,
safeMarketingExportText(item.DoNumber),
salesName,
safeMarketingExportText(item.VehicleNumber),
safeMarketingExportText(item.MarketingType),
productName,
item.Qty,
item.AverageWeightKg,
item.TotalWeightKg,
formatMarketingRupiah(item.SalesPricePerKg),
formatMarketingRupiah(item.HppPricePerKg),
formatMarketingRupiah(item.HppAmount),
formatMarketingRupiah(item.SalesAmount),
}
for colIdx, val := range values {
colName, err := excelize.ColumnNumberToName(colIdx + 1)
if err != nil {
return err
}
if err := file.SetCellValue(sheet, colName+row, val); err != nil {
return err
}
}
}
// Baris TOTAL
totalRow := strconv.Itoa(len(items) + 2)
if err := file.SetCellValue(sheet, "A"+totalRow, "TOTAL"); err != nil {
return err
}
if summary != nil {
if err := file.SetCellValue(sheet, "L"+totalRow, summary.TotalQty); err != nil {
return err
}
if err := file.SetCellValue(sheet, "M"+totalRow, summary.AverageWeightKg); err != nil {
return err
}
if err := file.SetCellValue(sheet, "N"+totalRow, summary.TotalWeightKg); err != nil {
return err
}
if err := file.SetCellValue(sheet, "O"+totalRow, formatMarketingRupiah(summary.AverageSalesPrice)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "P"+totalRow, formatMarketingRupiah(summary.TotalHppPricePerKg)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "Q"+totalRow, formatMarketingRupiah(float64(summary.TotalHppAmount))); err != nil {
return err
}
if err := file.SetCellValue(sheet, "R"+totalRow, formatMarketingRupiah(float64(summary.TotalSalesAmount))); err != nil {
return err
}
}
return nil
}
func formatMarketingDate(t time.Time) string {
if t.IsZero() {
return "-"
}
location, err := time.LoadLocation("Asia/Jakarta")
if err == nil {
t = t.In(location)
}
return t.Format("02 Jan 2006")
}
func safeMarketingExportText(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "-"
}
return trimmed
}
// formatMarketingRupiah formats a float64 as Indonesian Rupiah string.
// e.g. 1000000 → "Rp 1.000.000"
func formatMarketingRupiah(value float64) string {
rounded := int64(math.Round(value))
negative := rounded < 0
abs := rounded
if negative {
abs = -rounded
}
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 "Rp -" + b.String()
}
return "Rp " + b.String()
}
@@ -0,0 +1,510 @@
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 Sales Order", 16, "C"},
{"Tanggal Delivery Order", 16, "C"},
{"Aging\n(Hari)", 9, "C"},
{"Gudang Fisik", 20, "L"},
{"Pelanggan", 20, "L"},
{"Sales", 18, "L"},
{"Produk", 16, "L"},
{"Nomor DO", 14, "C"},
{"Nomor Polisi", 14, "C"},
{"Tipe\nMarketing", 14, "C"},
{"Quantity", 13, "R"},
{"Rata-Rata\n(Kg)", 13, "R"},
{"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)
rowH := 6.0
for idx, item := range items {
// page break check
if pdf.GetY()+rowH > marketingPdfPageHeight(pdf)-12 {
pdf.AddPage()
writeMarketingPdfHeader(pdf)
pdf.SetFont("Helvetica", "", 6)
}
// alternating bg
if idx%2 == 1 {
pdf.SetFillColor(rowAltR, rowAltG, rowAltB)
} else {
pdf.SetFillColor(255, 255, 255)
}
pdf.SetTextColor(40, 40, 40)
y := pdf.GetY()
writeMarketingPdfRow(pdf, idx+1, item, rowH, y)
}
}
func writeMarketingPdfRow(pdf *fpdf.Fpdf, no int, item dto.RepportMarketingItemDTO, h, y float64) {
fill := true // use the fill colour already set
cols := marketingPdfColumns
x := 10.0 // left margin
values := marketingPdfRowValues(no, item)
for i, col := range cols {
pdf.SetXY(x, y)
if i == 10 { // Tipe Marketing → badge
drawMarketingTypeBadge(pdf, x, y, col.width, h, item.MarketingType)
} else {
pdf.CellFormat(col.width, h, values[i], "1", 0, col.align, fill, 0, "")
}
x += col.width
}
pdf.SetXY(10, y+h)
}
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,
sales,
product,
safeMarketingExportText(item.DoNumber),
safeMarketingExportText(item.VehicleNumber),
safeMarketingExportText(item.MarketingType), // index 10, overridden by badge
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()
}