package routes import ( "bytes" "context" "database/sql" "fmt" "net/http" "sort" "strconv" "strings" "time" "bssapp-backend/db" "bssapp-backend/models" "bssapp-backend/queries" "bssapp-backend/utils" "github.com/jung-kurt/gofpdf" ) // GET /api/pricing/production-product-costing/onml/pdf?n_onml_no=100001 // Generates a PDF export for the costing detail screen (has-cost). func GetProductionProductCostingOnMLPDFHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/pdf") uretimDB := db.GetUretimDB() if uretimDB == nil { http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable) return } nOnMLNo := parsePositiveIntOrDefault(r.URL.Query().Get("n_onml_no"), 0) if nOnMLNo <= 0 { http.Error(w, "n_onml_no zorunlu", http.StatusBadRequest) return } traceID := utils.TraceIDFromRequest(r) ctx := utils.ContextWithTraceID(r.Context(), traceID) logger := utils.SlogFromContext(ctx).With("handler", "production-product-costing.onml.pdf", "n_onml_no", nOnMLNo) logger.Info("request start") // Header hRow, err := queries.GetProductionHasCostDetailHeaderByOnMLNo(ctx, uretimDB, nOnMLNo) if err != nil { logger.Error("header query prepare error", "err", err) http.Error(w, "Veritabani hatasi", http.StatusInternalServerError) return } var header models.ProductionHasCostDetailHeader if err := hRow.Scan( &header.UretimiYapanFirma, &header.SonIsEmriVeren, &header.FirmaKodu, &header.NFirmaID, &header.NOnMLNo, &header.UrunKodu, &header.UrunAdi, &header.UretimSekliID, &header.UretimSekli, &header.MaliyetTarihi, &header.DteKayitTarihi, &header.SKullaniciAdi, &header.LTutarTL, &header.LTutarUSD, &header.LTutarEURO, &header.LTutarGBP, &header.SDovizCinsi, &header.LTutarDoviz, &header.DteGuncellemeTarihi, &header.SGuncellemeKullaniciAdi, &header.NUrtReceteID, ); err != nil { if err == sql.ErrNoRows { http.Error(w, "Kayit bulunamadi", http.StatusNotFound) return } logger.Error("header scan error", "err", err) http.Error(w, "Veritabani hatasi", http.StatusInternalServerError) return } // Detail groups groups, err := loadHasCostDetailGroups(ctx, uretimDB, nOnMLNo) if err != nil { logger.Error("groups load error", "err", err) http.Error(w, "Veritabani hatasi", http.StatusInternalServerError) return } // Exchange rates (needed for EUR conversions in PDF summary tables) usdRate := 0.0 eurRate := 0.0 if mssqlDB := db.GetDB(); mssqlDB != nil { row, err := queries.GetProductionHasCostDetailExchangeRatesByDate(ctx, mssqlDB, header.MaliyetTarihi) if err == nil { var rateDate string var gbpRate float64 _ = row.Scan(&rateDate, &usdRate, &eurRate, &gbpRate) } } pdf := gofpdf.New("L", "mm", "A4", "") pdf.SetMargins(8, 8, 8) pdf.SetAutoPageBreak(false, 10) if err := registerDejavuFonts(pdf, "dejavu"); err != nil { http.Error(w, "pdf font error: "+err.Error(), http.StatusInternalServerError) return } export := &costingPDF{ pdf: pdf, header: header, groups: groups, usdRate: usdRate, eurRate: eurRate, } export.draw() if err := pdf.Error(); err != nil { http.Error(w, "pdf render error: "+err.Error(), http.StatusInternalServerError) return } var buf bytes.Buffer if err := pdf.Output(&buf); err != nil { http.Error(w, "pdf output error: "+err.Error(), http.StatusInternalServerError) return } filename := fmt.Sprintf("onml-%d.pdf", nOnMLNo) w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, filename)) w.WriteHeader(http.StatusOK) _, _ = w.Write(buf.Bytes()) logger.Info("request done") } func loadHasCostDetailGroups(ctx context.Context, uretimDB *sql.DB, nOnMLNo int) ([]models.ProductionHasCostDetailGroup, error) { rows, err := queries.GetProductionHasCostDetailRowsByOnMLNo(ctx, uretimDB, nOnMLNo) if err != nil { return nil, err } defer rows.Close() groups := make([]models.ProductionHasCostDetailGroup, 0, 16) groupIndexByName := map[string]int{} for rows.Next() { var ( groupName string groupTotal float64 groupTotalUSD float64 nOnMLNoStr string nOnMLDetNoStr string hNoStr string mtBolumStr string fiyatGirilen sql.NullFloat64 fiyatDoviz sql.NullString maliyeteDahil sql.NullBool cmPriceTypeID sql.NullInt64 item models.ProductionHasCostDetailGroupItem ) if err := rows.Scan( &groupName, &groupTotal, &groupTotalUSD, &nOnMLNoStr, &nOnMLDetNoStr, &hNoStr, &mtBolumStr, &item.SKodu, &item.SAciklama, &item.SRenk, &item.SBeden, &item.SAciklama2, &item.LMiktar, &item.LFiyat, &item.LTutar, &item.SFiyatTipi, &item.SDovizCinsi, &item.LDovizKuru, &item.LDovizFiyati, &fiyatGirilen, &fiyatDoviz, &maliyeteDahil, &cmPriceTypeID, &item.USDTutar, &item.EURTutar, &item.GBPTutar, &item.SBirim, &item.SHammaddeTuruAdi, &item.SParcaAdi, ); err != nil { continue } item.NOnMLNo = strings.TrimSpace(nOnMLNoStr) item.NOnMLDetNo = strings.TrimSpace(nOnMLDetNoStr) item.NHammaddeTuruNo = strings.TrimSpace(hNoStr) item.NUrtMTBolumID = strings.TrimSpace(mtBolumStr) if fiyatGirilen.Valid { item.FiyatGirilen = new(float64) *item.FiyatGirilen = fiyatGirilen.Float64 } if fiyatDoviz.Valid { item.FiyatDoviz = strings.TrimSpace(fiyatDoviz.String) } item.MaliyeteDahil = maliyeteDahil.Valid && maliyeteDahil.Bool if cmPriceTypeID.Valid { v := int(cmPriceTypeID.Int64) item.CMPriceTypeID = &v } idx, ok := groupIndexByName[groupName] if !ok { groups = append(groups, models.ProductionHasCostDetailGroup{ SAciklama3: groupName, TotalTutar: groupTotal, TotalUSDTutar: groupTotalUSD, Items: make([]models.ProductionHasCostDetailGroupItem, 0, 24), }) idx = len(groups) - 1 groupIndexByName[groupName] = idx } groups[idx].Items = append(groups[idx].Items, item) } if err := rows.Err(); err != nil { return nil, err } return groups, nil } // --- PDF drawing --- type costingPDF struct { pdf *gofpdf.Fpdf header models.ProductionHasCostDetailHeader groups []models.ProductionHasCostDetailGroup usdRate float64 eurRate float64 } func (c *costingPDF) draw() { c.addPage(true) for gi, g := range c.groups { c.drawGroup(g, gi == 0) } } func (c *costingPDF) addPage(fullHeader bool) { c.pdf.AddPage() if fullHeader { c.drawHeaderFull() } else { c.drawHeaderCompact() } } func (c *costingPDF) drawHeaderFull() { pdf := c.pdf pdf.SetFont("dejavu", "B", 14) pdf.CellFormat(0, 7, "Maliyet Detay", "", 1, "L", false, 0, "") pdf.SetFont("dejavu", "", 9) pdf.SetTextColor(60, 60, 60) line1 := fmt.Sprintf( "OnML No: %s | Maliyet Tarihi: %s | Kayit Tarihi: %s | Uretim Sekli: %s", c.header.NOnMLNo, formatDateTRDot(c.header.MaliyetTarihi), formatDateTRDot(c.header.DteKayitTarihi), strings.TrimSpace(c.header.UretimSekli), ) pdf.CellFormat(0, 5, line1, "", 1, "L", false, 0, "") line2 := fmt.Sprintf("Urun: %s - %s", strings.TrimSpace(c.header.UrunKodu), strings.TrimSpace(c.header.UrunAdi)) pdf.CellFormat(0, 5, line2, "", 1, "L", false, 0, "") firmaLabel := strings.TrimSpace(c.header.FirmaKodu) if strings.TrimSpace(c.header.UretimiYapanFirma) != "" { firmaLabel = fmt.Sprintf("%s - %s", firmaLabel, strings.TrimSpace(c.header.UretimiYapanFirma)) } line3 := fmt.Sprintf( "Firma: %s | 2.Firma: %s | Kaydeden: %s | Son Guncelleme: %s (%s)", firmaLabel, strings.TrimSpace(c.header.SonIsEmriVeren), strings.TrimSpace(c.header.SKullaniciAdi), formatDateTRDot(c.header.DteGuncellemeTarihi), strings.TrimSpace(c.header.SGuncellemeKullaniciAdi), ) pdf.CellFormat(0, 5, line3, "", 1, "L", false, 0, "") pdf.SetTextColor(0, 0, 0) pdf.Ln(2) // Header summary tables (stacked) c.drawHeaderSummaryTables() } func (c *costingPDF) drawHeaderCompact() { pdf := c.pdf pdf.SetFont("dejavu", "B", 10.5) firmaLabel := strings.TrimSpace(c.header.FirmaKodu) if strings.TrimSpace(c.header.UretimiYapanFirma) != "" { firmaLabel = fmt.Sprintf("%s - %s", firmaLabel, strings.TrimSpace(c.header.UretimiYapanFirma)) } title := fmt.Sprintf( "OnML %s | %s - %s | %s | %s", c.header.NOnMLNo, strings.TrimSpace(c.header.UrunKodu), strings.TrimSpace(c.header.UrunAdi), formatDateTRDot(c.header.MaliyetTarihi), firmaLabel, ) pdf.CellFormat(0, 6, title, "", 1, "L", false, 0, "") pdf.Ln(1) } type pdfPartSummaryRow struct { name string try float64 usd float64 eur float64 } type pdfGroupTotalRow struct { group string try float64 usd float64 eur float64 } func (c *costingPDF) drawHeaderSummaryTables() { pdf := c.pdf groupRows, grandTRY, grandUSD, grandEUR := c.computeGroupTotals() // Table styling (use same brand palette as statements PDF) // colorPrimary/colorSecondary/colorDetailFill are in statements_pdf.go (same package). pdf.SetFont("dejavu", "B", 9.0) pdf.SetTextColor(colorPrimary[0], colorPrimary[1], colorPrimary[2]) pdf.CellFormat(0, 5.5, "Ozet", "", 1, "L", false, 0, "") pdf.SetTextColor(0, 0, 0) // Group totals table pdf.SetFont("dejavu", "B", 8.2) pdf.CellFormat(0, 4.8, "Grup Toplamlari", "", 1, "L", false, 0, "") gCols := []string{"Grup", "TRY", "USD", "EUR"} gW := []float64{30, 22, 22, 22} totalRows := append(groupRows, pdfGroupTotalRow{group: "TOPLAM", try: grandTRY, usd: grandUSD, eur: grandEUR}) c.drawMiniTable(gCols, gW, func(i int) []string { if i >= len(totalRows) { return nil } r := totalRows[i] return []string{r.group, pdfMoney(r.try), pdfMoney(r.usd), pdfMoney(r.eur)} }, len(totalRows), true, true) pdf.Ln(2) } func (c *costingPDF) computePartSummary() []pdfPartSummaryRow { byName := map[string]*pdfPartSummaryRow{} for _, g := range c.groups { for _, it := range g.Items { name := strings.TrimSpace(it.SParcaAdi) if name == "" { name = "-" } row := byName[name] if row == nil { row = &pdfPartSummaryRow{name: name} byName[name] = row } row.try += it.LTutar row.usd += it.USDTutar // EUR isn't directly returned by the has-cost query; derive from USD using exchange rates when available. if c.usdRate > 0 && c.eurRate > 0 { row.eur += it.USDTutar * (c.usdRate / c.eurRate) } } } out := make([]pdfPartSummaryRow, 0, len(byName)) for _, v := range byName { out = append(out, *v) } // stable order sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name }) return out } func (c *costingPDF) computeGroupTotals() (rows []pdfGroupTotalRow, grandTRY float64, grandUSD float64, grandEUR float64) { want := []string{"CM2", "FABRIC", "DT", "TP"} by := map[string]*pdfGroupTotalRow{} for _, w := range want { by[w] = &pdfGroupTotalRow{group: w} } for _, g := range c.groups { grp := strings.ToUpper(strings.TrimSpace(g.SAciklama3)) t := by[grp] if t == nil { continue } // g.TotalTutar is TRY total; g.TotalUSDTutar is USD total t.try += g.TotalTutar t.usd += g.TotalUSDTutar if c.usdRate > 0 && c.eurRate > 0 { t.eur += g.TotalUSDTutar * (c.usdRate / c.eurRate) } } for _, w := range want { r := by[w] rows = append(rows, *r) grandTRY += r.try grandUSD += r.usd grandEUR += r.eur } return rows, grandTRY, grandUSD, grandEUR } func (c *costingPDF) drawMiniTable(cols []string, widths []float64, rowFn func(i int) []string, rowCount int, zebra bool, emphasizeLastRow bool) { pdf := c.pdf // Header row pdf.SetFont("dejavu", "B", 7.8) pdf.SetFillColor(colorSecondary[0], colorSecondary[1], colorSecondary[2]) pdf.SetDrawColor(160, 140, 100) pdf.SetTextColor(0, 0, 0) x0 := pdf.GetX() y0 := pdf.GetY() x := x0 hH := 5.4 for i, col := range cols { pdf.Rect(x, y0, widths[i], hH, "DF") pdf.SetXY(x+0.8, y0+1.2) pdf.CellFormat(widths[i]-1.6, hH-2.4, col, "", 0, "C", false, 0, "") x += widths[i] } pdf.SetXY(x0, y0+hH) // Data rows pdf.SetDrawColor(220, 220, 220) for i := 0; i < rowCount; i++ { row := rowFn(i) if row == nil { break } isLast := emphasizeLastRow && (i == rowCount-1) fill := zebra && (i%2 == 1) // Style rules: // - Total row: primary fill + bigger bold text // - Zebra rows: detail fill // - Normal: white if isLast { pdf.SetFont("dejavu", "B", 8.6) pdf.SetTextColor(255, 255, 255) pdf.SetFillColor(colorPrimary[0], colorPrimary[1], colorPrimary[2]) } else { pdf.SetFont("dejavu", "", 7.4) pdf.SetTextColor(30, 30, 30) if fill { pdf.SetFillColor(colorDetailFill[0], colorDetailFill[1], colorDetailFill[2]) } else { pdf.SetFillColor(255, 255, 255) } } x = x0 y := pdf.GetY() rh := 5.0 if isLast { rh = 5.6 } for cidx, val := range row { style := "" if fill || isLast { style = "DF" } pdf.Rect(x, y, widths[cidx], rh, style) align := "L" if cidx > 0 { align = "R" } pdf.SetXY(x+0.8, y+(rh-3.5)/2) pdf.CellFormat(widths[cidx]-1.6, 3.5, val, "", 0, align, false, 0, "") x += widths[cidx] } pdf.SetXY(x0, y+rh) } pdf.SetTextColor(0, 0, 0) pdf.SetFont("dejavu", "", 7.4) } func formatDateTRDot(s string) string { s = strings.TrimSpace(s) if s == "" { return "-" } layouts := []string{ "2006-01-02 15:04:05", "2006-01-02 15:04", "2006-01-02", } for _, l := range layouts { if t, err := time.ParseInLocation(l, s, time.Local); err == nil { return t.Format("02.01.2006") } } return s } func (c *costingPDF) drawGroup(g models.ProductionHasCostDetailGroup, firstGroup bool) { pdf := c.pdf // Reset any font/color left over from header summary tables. pdf.SetFont("dejavu", "", 7.2) pdf.SetTextColor(0, 0, 0) // Group bar c.drawGroupBar(g, false) // Columns // Keep total width <= A4 landscape printable width (297 - left/right margins). // Also force USD/TRY unit+total columns to always be visible. cols := []string{ "No", "Parca", "Hammadde", "Kod", "Aciklama", "Renk", "Miktar", "Br", "Br\nFiyat", "Pr\nBr", "USD\nFiyat", "USD\nTutar", "TRY\nFiyat", "TRY\nTutar", } wn := []float64{8, 20, 22, 30, 56, 14, 14, 10, 16, 12, 16, 16, 16, 16} // sum = 252 c.drawTableHeader(cols, wn) // PDF-specific ordering: by hammadde turu no, then code. items := append([]models.ProductionHasCostDetailGroupItem(nil), g.Items...) sort.Slice(items, func(i, j int) bool { ai, _ := strconv.Atoi(strings.TrimSpace(items[i].NHammaddeTuruNo)) aj, _ := strconv.Atoi(strings.TrimSpace(items[j].NHammaddeTuruNo)) if ai != aj { return ai < aj } return strings.TrimSpace(items[i].SKodu) < strings.TrimSpace(items[j].SKodu) }) for i, it := range items { c.drawRowWithGroup(it, wn, cols, g, i) } pdf.Ln(2) _ = firstGroup } func (c *costingPDF) drawGroupBar(g models.ProductionHasCostDetailGroup, continued bool) { pdf := c.pdf pdf.SetFont("dejavu", "B", 10) pdf.SetFillColor(colorSecondary[0], colorSecondary[1], colorSecondary[2]) pdf.SetTextColor(0, 0, 0) name := strings.TrimSpace(g.SAciklama3) if continued { name = name + " (devam)" } pdf.CellFormat(0, 6, fmt.Sprintf("%s | Toplam TRY: %s | Toplam USD: %s", name, pdfMoney(g.TotalTutar), pdfMoney(g.TotalUSDTutar)), "1", 1, "L", true, 0, "") pdf.SetTextColor(0, 0, 0) } func (c *costingPDF) drawTableHeader(cols []string, wn []float64) { pdf := c.pdf pdf.SetFont("dejavu", "B", 8) pdf.SetFillColor(colorPrimary[0], colorPrimary[1], colorPrimary[2]) pdf.SetDrawColor(120, 90, 20) pdf.SetTextColor(255, 255, 255) // Compute a stable header height based on wrapped labels. maxLines := 1 for i, col := range cols { lines := pdf.SplitLines([]byte(col), wn[i]-1.6) if len(lines) > maxLines { maxLines = len(lines) } } lineH := 3.5 headerH := float64(maxLines)*lineH + 1.2 if headerH < 7.0 { headerH = 7.0 } x0 := pdf.GetX() y0 := pdf.GetY() x := x0 for i, col := range cols { c.drawHeaderCellWrap(x, y0, wn[i], headerH, col) x += wn[i] } pdf.SetXY(x0, y0+headerH) pdf.SetTextColor(0, 0, 0) pdf.SetDrawColor(220, 220, 220) } func (c *costingPDF) ensureSpace(h float64) (newPage bool) { pdf := c.pdf _, pageH := pdf.GetPageSize() _, _, _, mb := pdf.GetMargins() if pdf.GetY()+h > pageH-mb { c.addPage(false) return true } return false } func (c *costingPDF) drawRowWithGroup(it models.ProductionHasCostDetailGroupItem, wn []float64, cols []string, g models.ProductionHasCostDetailGroup, rowIndex int) { pdf := c.pdf pdf.SetFont("dejavu", "", 7.2) pdf.SetDrawColor(220, 220, 220) // Compute row height based on key wrapped cells. parcaLines := pdf.SplitLines([]byte(strings.TrimSpace(it.SParcaAdi)), wn[1]-2) hLabel := strings.TrimSpace(it.NHammaddeTuruNo) if strings.TrimSpace(it.SHammaddeTuruAdi) != "" { hLabel = hLabel + " " + strings.TrimSpace(it.SHammaddeTuruAdi) } hLines := pdf.SplitLines([]byte(hLabel), wn[2]-2) kodLines := pdf.SplitLines([]byte(strings.TrimSpace(it.SKodu)), wn[3]-2) descLines := pdf.SplitLines([]byte(strings.TrimSpace(it.SAciklama)), wn[4]-2) maxLines := len(descLines) if len(parcaLines) > maxLines { maxLines = len(parcaLines) } if len(hLines) > maxLines { maxLines = len(hLines) } if len(kodLines) > maxLines { maxLines = len(kodLines) } rowH := float64(maxLines) * 3.5 if rowH < 5.0 { rowH = 5.0 } if c.ensureSpace(rowH + 12.0) { // Redraw group bar + table header on new page. c.drawGroupBar(g, true) c.drawTableHeader(cols, wn) } x0 := pdf.GetX() y0 := pdf.GetY() fill := rowIndex%2 == 1 if fill { pdf.SetFillColor(colorDetailFill[0], colorDetailFill[1], colorDetailFill[2]) } else { pdf.SetFillColor(255, 255, 255) } c.drawCell(x0, y0, wn[0], rowH, it.NOnMLDetNo, "R", fill) x := x0 + wn[0] c.drawCellWrap(x, y0, wn[1], rowH, strings.TrimSpace(it.SParcaAdi), "L", fill) x += wn[1] c.drawCellWrap(x, y0, wn[2], rowH, hLabel, "L", fill) x += wn[2] c.drawCellWrap(x, y0, wn[3], rowH, strings.TrimSpace(it.SKodu), "L", fill) x += wn[3] c.drawCellWrap(x, y0, wn[4], rowH, strings.TrimSpace(it.SAciklama), "L", fill) x += wn[4] c.drawCell(x, y0, wn[5], rowH, strings.TrimSpace(it.SRenk), "L", fill) x += wn[5] c.drawCell(x, y0, wn[6], rowH, pdfQty(it.LMiktar), "R", fill) x += wn[6] c.drawCell(x, y0, wn[7], rowH, strings.TrimSpace(it.SBirim), "C", fill) x += wn[7] // Prefer input price if present; otherwise lFiyat. price := it.LFiyat cur := strings.TrimSpace(it.SDovizCinsi) if it.FiyatGirilen != nil && *it.FiyatGirilen > 0 { price = *it.FiyatGirilen if strings.TrimSpace(it.FiyatDoviz) != "" { cur = strings.TrimSpace(it.FiyatDoviz) } } c.drawCell(x, y0, wn[8], rowH, pdfMoney(price), "R", fill) x += wn[8] c.drawCell(x, y0, wn[9], rowH, cur, "C", fill) x += wn[9] // Always show USD/TRY unit+total. // In URETIM schema: lFiyat/lTutar are in TRY, lDovizFiyati/usdTutar are in USD. c.drawCell(x, y0, wn[10], rowH, pdfMoney(it.LDovizFiyati), "R", fill) x += wn[10] usdTotal := it.USDTutar if usdTotal == 0 && it.LMiktar != 0 && it.LDovizFiyati != 0 { usdTotal = it.LMiktar * it.LDovizFiyati } c.drawCell(x, y0, wn[11], rowH, pdfMoney(usdTotal), "R", fill) x += wn[11] // Prefer input price if present; otherwise lFiyat. unitTRY := it.LFiyat if it.FiyatGirilen != nil && *it.FiyatGirilen > 0 && strings.EqualFold(strings.TrimSpace(it.FiyatDoviz), "TRY") { unitTRY = *it.FiyatGirilen } c.drawCell(x, y0, wn[12], rowH, pdfMoney(unitTRY), "R", fill) x += wn[12] c.drawCell(x, y0, wn[13], rowH, pdfMoney(it.LTutar), "R", fill) pdf.SetXY(x0, y0+rowH) } func (c *costingPDF) drawHeaderCellWrap(x, y, w, h float64, txt string) { pdf := c.pdf pdf.Rect(x, y, w, h, "DF") pdf.SetXY(x+0.8, y+0.6) pdf.MultiCell(w-1.6, 3.5, txt, "", "C", true) // restore cursor (MultiCell moves Y) pdf.SetXY(x+w, y) } func (c *costingPDF) drawCell(x, y, w, h float64, txt, align string, fill bool) { pdf := c.pdf style := "" if fill { style = "DF" } pdf.Rect(x, y, w, h, style) pdf.SetXY(x+0.8, y+(h-3.5)/2) pdf.CellFormat(w-1.6, 3.5, txt, "", 0, align, false, 0, "") } func (c *costingPDF) drawCellWrap(x, y, w, h float64, txt, align string, fill bool) { pdf := c.pdf style := "" if fill { style = "DF" } pdf.Rect(x, y, w, h, style) pdf.SetXY(x+0.8, y+0.6) pdf.MultiCell(w-1.6, 3.5, txt, "", align, false) // restore cursor (MultiCell moves Y) pdf.SetXY(x+w, y) } func pdfMoney(v float64) string { // 2 decimals, dot return fmt.Sprintf("%.2f", v) } func pdfQty(v float64) string { // 4 decimals (screen-like) return fmt.Sprintf("%.4f", v) }