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) } 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) } 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 } 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) 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"} 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) } 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 := 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 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 }