Files
lti-api/internal/modules/repports/controllers/repport.marketing.pdf.go
T

537 lines
15 KiB
Go

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()
}