This commit is contained in:
Aditya Siregar
2025-08-10 20:41:34 +07:00
parent dc23a318cd
commit 5a42523f0f
10 changed files with 1019 additions and 3 deletions
+133
View File
@@ -0,0 +1,133 @@
package service
import (
"bytes"
"fmt"
"html/template"
"os"
"os/exec"
"path/filepath"
)
func renderTemplateToPDF(templatePath string, data interface{}) ([]byte, error) {
// Create template with custom functions
funcMap := template.FuncMap{
"add": func(a, b int) int {
return a + b
},
}
// Parse and execute HTML template
tmpl, err := template.New(filepath.Base(templatePath)).Funcs(funcMap).ParseFiles(templatePath)
if err != nil {
return nil, fmt.Errorf("parse template: %w", err)
}
var htmlBuf bytes.Buffer
if err := tmpl.Execute(&htmlBuf, data); err != nil {
return nil, fmt.Errorf("execute template: %w", err)
}
// Write HTML to a temp file
tmpDir, err := os.MkdirTemp("", "daily_report_")
if err != nil {
return nil, fmt.Errorf("tmp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
htmlPath := filepath.Join(tmpDir, "report.html")
pdfPath := filepath.Join(tmpDir, "report.pdf")
if err := os.WriteFile(htmlPath, htmlBuf.Bytes(), 0644); err != nil {
return nil, fmt.Errorf("write html: %w", err)
}
// Use Chrome headless for better CSS rendering
chromeArgs := []string{
"--headless",
"--disable-gpu",
"--no-sandbox",
"--disable-dev-shm-usage",
"--print-to-pdf=" + pdfPath,
"--print-to-pdf-no-header",
"--print-to-pdf-no-footer",
"--print-to-pdf-margin-top=0",
"--print-to-pdf-margin-bottom=0",
"--print-to-pdf-margin-left=0",
"--print-to-pdf-margin-right=0",
"--print-to-pdf-paper-width=210mm",
"--print-to-pdf-paper-height=297mm",
htmlPath,
}
// Try Google Chrome first, then Chromium, then wkhtmltopdf as fallback
chromeCmd := exec.Command("google-chrome", chromeArgs...)
if out, err := chromeCmd.CombinedOutput(); err != nil {
// Fallback to Chromium
chromiumCmd := exec.Command("chromium", chromeArgs...)
if chromiumOut, err := chromiumCmd.CombinedOutput(); err != nil {
// Final fallback to wkhtmltopdf
wkhtmlArgs := []string{
"--enable-local-file-access",
"--dpi", "300",
"--page-size", "A4",
"--orientation", "Portrait",
"--margin-top", "0",
"--margin-bottom", "0",
"--margin-left", "0",
"--margin-right", "0",
htmlPath,
pdfPath,
}
wkhtmlCmd := exec.Command("wkhtmltopdf", wkhtmlArgs...)
if wkhtmlOut, err := wkhtmlCmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("all PDF generators failed. Chrome error: %v, Chromium error: %v, wkhtmltopdf error: %v, chrome output: %s, chromium output: %s, wkhtmltopdf output: %s", chromeCmd.Err, chromiumCmd.Err, err, string(out), string(chromiumOut), string(wkhtmlOut))
}
}
}
// Read PDF bytes
pdfBytes, err := os.ReadFile(pdfPath)
if err != nil {
return nil, fmt.Errorf("read pdf: %w", err)
}
return pdfBytes, nil
}
func formatCurrency(amount float64) string {
// Simple currency formatting: Rp with thousands separators
// Note: For exact locale formatting, integrate a locale library later
s := fmt.Sprintf("%.0f", amount) // Remove decimal places for cleaner display
intPart := addThousandsSep(s)
return "Rp " + intPart
}
func splitAmount(s string) (string, string) {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == '.' {
return s[:i], s[i+1:]
}
}
return s, "00"
}
func addThousandsSep(s string) string {
n := len(s)
if n <= 3 {
return s
}
var out bytes.Buffer
pre := n % 3
if pre > 0 {
out.WriteString(s[:pre])
if n > pre {
out.WriteByte('.')
}
}
for i := pre; i < n; i += 3 {
out.WriteString(s[i : i+3])
if i+3 < n {
out.WriteByte('.')
}
}
return out.String()
}
+198
View File
@@ -0,0 +1,198 @@
package service
import (
"apskel-pos-be/internal/repository"
"context"
"fmt"
"path/filepath"
"strings"
"time"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/processor"
"github.com/google/uuid"
)
type ReportService interface {
// Returns (publicURL, fileName, error)
GenerateDailyTransactionPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error)
}
type ReportServiceImpl struct {
analyticsService AnalyticsService
organizationRepo *repository.OrganizationRepositoryImpl
outletRepo *repository.OutletRepositoryImpl
fileClient processor.FileClient
}
func NewReportService(analyticsService *AnalyticsServiceImpl, organizationRepo *repository.OrganizationRepositoryImpl, outletRepo *repository.OutletRepositoryImpl, fileClient processor.FileClient) *ReportServiceImpl {
return &ReportServiceImpl{
analyticsService: analyticsService,
organizationRepo: organizationRepo,
outletRepo: outletRepo,
fileClient: fileClient,
}
}
// reportTemplateData holds the data passed to the HTML template
type reportTemplateData struct {
OrganizationName string
OutletName string
ReportDate string
StartDate string
EndDate string
GeneratedBy string
PrintTime string
Summary reportSummary
Items []reportItem
}
type reportSummary struct {
TotalTransactions int64
TotalItems int64
GrossSales string
Discount string
Tax string
NetSales string
COGS string
GrossProfit string
GrossMarginPercent string
}
type reportItem struct {
Name string
Quantity int64
GrossSales string
Discount string
NetSales string
COGS string
GrossProfit string
}
func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error) {
// Parse IDs
orgID, err := uuid.Parse(organizationID)
if err != nil {
return "", "", fmt.Errorf("invalid organization id: %w", err)
}
outID, err := uuid.Parse(outletID)
if err != nil {
return "", "", fmt.Errorf("invalid outlet id: %w", err)
}
// Resolve organization and outlet names
org, err := s.organizationRepo.GetByID(ctx, orgID)
if err != nil {
return "", "", fmt.Errorf("organization not found: %w", err)
}
outlet, err := s.outletRepo.GetByID(ctx, outID)
if err != nil {
return "", "", fmt.Errorf("outlet not found: %w", err)
}
// Determine timezone (fallback to system local if not available)
tzName := "Asia/Jakarta"
if outlet.Timezone != nil && *outlet.Timezone != "" {
tzName = *outlet.Timezone
}
loc, locErr := time.LoadLocation(tzName)
if locErr != nil || loc == nil {
loc = time.Local
}
// Compute day range in the chosen location
var day time.Time
if reportDate != nil {
t := reportDate.UTC()
day = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
} else {
now := time.Now().In(loc)
day = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
}
start := day
end := day.Add(24*time.Hour - time.Nanosecond)
// Build requests
salesReq := &models.SalesAnalyticsRequest{OrganizationID: orgID, OutletID: &outID, DateFrom: start, DateTo: end, GroupBy: "day"}
plReq := &models.ProfitLossAnalyticsRequest{OrganizationID: orgID, OutletID: &outID, DateFrom: start, DateTo: end, GroupBy: "day"}
// Call services
sales, err := s.analyticsService.GetSalesAnalytics(ctx, salesReq)
if err != nil {
return "", "", fmt.Errorf("get sales analytics: %w", err)
}
pl, err := s.analyticsService.GetProfitLossAnalytics(ctx, plReq)
if err != nil {
return "", "", fmt.Errorf("get profit/loss analytics: %w", err)
}
// Compose template data
data := reportTemplateData{
OrganizationName: org.Name,
OutletName: outlet.Name,
ReportDate: day.Format("02/01/2006"),
StartDate: start.Format("02/01/2006 15:04"),
EndDate: end.Format("02/01/2006 15:04"),
GeneratedBy: generatedBy,
PrintTime: time.Now().Format("02/01/2006 15:04:05"),
Summary: reportSummary{
TotalTransactions: pl.Summary.TotalOrders,
TotalItems: sales.Summary.TotalItems,
GrossSales: formatCurrency(pl.Summary.TotalRevenue),
Discount: formatCurrency(pl.Summary.TotalDiscount),
Tax: formatCurrency(pl.Summary.TotalTax),
NetSales: formatCurrency(sales.Summary.NetSales),
COGS: formatCurrency(pl.Summary.TotalCost),
GrossProfit: formatCurrency(pl.Summary.GrossProfit),
GrossMarginPercent: fmt.Sprintf("%.2f", pl.Summary.GrossProfitMargin),
},
}
// Items by product
items := make([]reportItem, 0, len(pl.ProductData))
for _, p := range pl.ProductData {
items = append(items, reportItem{
Name: p.ProductName,
Quantity: p.QuantitySold,
GrossSales: formatCurrency(p.Revenue),
Discount: formatCurrency(0),
NetSales: formatCurrency(p.Revenue),
COGS: formatCurrency(p.Cost),
GrossProfit: formatCurrency(p.GrossProfit),
})
}
data.Items = items
// Render to PDF
templatePath := filepath.Join("templates", "daily_transaction.html")
pdfBytes, err := renderTemplateToPDF(templatePath, data)
if err != nil {
return "", "", fmt.Errorf("render pdf: %w", err)
}
// Upload to bucket
safeOutlet := outID.String()
safeOrg := orgID.String()
// Clean outlet name for filename (remove spaces and special characters)
cleanOutletName := strings.ReplaceAll(outlet.Name, " ", "-")
cleanOutletName = strings.ReplaceAll(cleanOutletName, "/", "-")
cleanOutletName = strings.ReplaceAll(cleanOutletName, "\\", "-")
cleanOutletName = strings.ReplaceAll(cleanOutletName, ":", "-")
cleanOutletName = strings.ReplaceAll(cleanOutletName, "*", "-")
cleanOutletName = strings.ReplaceAll(cleanOutletName, "?", "-")
cleanOutletName = strings.ReplaceAll(cleanOutletName, "\"", "-")
cleanOutletName = strings.ReplaceAll(cleanOutletName, "<", "-")
cleanOutletName = strings.ReplaceAll(cleanOutletName, ">", "-")
cleanOutletName = strings.ReplaceAll(cleanOutletName, "|", "-")
fileName := fmt.Sprintf("laporan-transaksi-harian-%s-%s-%s.pdf", cleanOutletName, day.Format("2006-01-02"), time.Now().Format("20060102-150405"))
objectKey := fmt.Sprintf("/reports/%s/%s/%s", safeOrg, safeOutlet, fileName)
publicURL, err := s.fileClient.UploadFile(ctx, objectKey, pdfBytes)
if err != nil {
return "", "", fmt.Errorf("upload pdf: %w", err)
}
return publicURL, fileName, nil
}