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) GenerateProfitLossPDF(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} productReq := &models.ProductAnalyticsRequest{OrganizationID: orgID, OutletID: &outID, DateFrom: start, DateTo: end, Limit: 1000} 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) } products, err := s.analyticsService.GetProductAnalytics(ctx, productReq) if err != nil { return "", "", fmt.Errorf("get product analytics: %w", err) } totalOmset := getPLNominalByID(pl.MainSummary, "total_omset") hpp := getPLNominalByID(pl.MainSummary, "hpp") labaKotor := getPLNominalByID(pl.MainSummary, "laba_kotor") labaKotorPct := getPLPctByID(pl.MainSummary, "laba_kotor") 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: sales.Summary.TotalOrders, TotalItems: sales.Summary.TotalItems, GrossSales: formatCurrency(totalOmset), Discount: formatCurrency(sales.Summary.TotalDiscount), Tax: formatCurrency(sales.Summary.TotalTax), NetSales: formatCurrency(sales.Summary.NetSales), COGS: formatCurrency(hpp), GrossProfit: formatCurrency(labaKotor), GrossMarginPercent: fmt.Sprintf("%.2f", labaKotorPct), }, } items := make([]reportItem, 0, len(products.Data)) for _, p := range products.Data { items = append(items, reportItem{ Name: p.ProductName, Quantity: p.QuantitySold, GrossSales: formatCurrency(p.Revenue), Discount: formatCurrency(0), NetSales: formatCurrency(p.Revenue), COGS: formatCurrency(p.StandardHppTotal), GrossProfit: formatCurrency(p.Revenue - p.StandardHppTotal), }) } 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 } func getPLNominalByID(rows []models.ProfitLossSummaryRow, id string) float64 { for _, row := range rows { if row.ID == id { return row.TodayNominal } } return 0 } func getPLPctByID(rows []models.ProfitLossSummaryRow, id string) float64 { for _, row := range rows { if row.ID == id { return row.TodayPct } } return 0 } // profitLossReportData holds data for the profit/loss PDF template type profitLossReportData struct { OrganizationName string MonthName string ReportDate string ReportDateUpper string TotalPenjualan string TotalBiaya string LabaRugi string LabaRugiClass string LabaRugiValueClass string LabaRugiMtd string LabaRugiMtdClass string LabaRugiMtdValueClass string MainSummary []profitLossSummaryRowView PurchasingItems []profitLossPurchasingItem PurchasingTotal string GeneratedBy string PrintTime string } type profitLossSummaryRowView struct { Number string Label string TodayNominal string TodayPct string MtdNominal string MtdPct string RowClass string SubItems []profitLossSummaryRowView } type profitLossPurchasingItem struct { Name string Amount string } func (s *ReportServiceImpl) GenerateProfitLossPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error) { orgID, err := uuid.Parse(organizationID) if err != nil { return "", "", fmt.Errorf("invalid organization id: %w", err) } var outID *uuid.UUID if outletID != "" { parsed, err := uuid.Parse(outletID) if err != nil { return "", "", fmt.Errorf("invalid outlet id: %w", err) } outID = &parsed } org, err := s.organizationRepo.GetByID(ctx, orgID) if err != nil { return "", "", fmt.Errorf("organization not found: %w", err) } var tzName string if outID != nil { outlet, err := s.outletRepo.GetByID(ctx, *outID) if err == nil && outlet.Timezone != nil && *outlet.Timezone != "" { tzName = *outlet.Timezone } } if tzName == "" { tzName = "Asia/Jakarta" } 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) } dayStart := day dayEnd := day.Add(24*time.Hour - time.Nanosecond) // MTD: from 1st of month to end of the report day mtdStart := time.Date(day.Year(), day.Month(), 1, 0, 0, 0, 0, loc) mtdEnd := dayEnd // Get profit/loss analytics for the day plReq := &models.ProfitLossAnalyticsRequest{ OrganizationID: orgID, OutletID: outID, DateFrom: dayStart, DateTo: mtdEnd, GroupBy: "day", } pl, err := s.analyticsService.GetProfitLossAnalytics(ctx, plReq) if err != nil { return "", "", fmt.Errorf("get profit/loss analytics: %w", err) } // Get purchasing analytics for the day (Rincian Biaya / Catatan) purchReq := &models.PurchasingAnalyticsRequest{ OrganizationID: orgID, OutletID: outID, DateFrom: dayStart, DateTo: dayEnd, GroupBy: "day", } purch, err := s.analyticsService.GetPurchasingAnalytics(ctx, purchReq) if err != nil { return "", "", fmt.Errorf("get purchasing analytics: %w", err) } // Build summary values totalOmset := getPLNominalByID(pl.MainSummary, "total_omset") hpp := getPLNominalByID(pl.MainSummary, "hpp") _ = mtdStart // used above // Total biaya = HPP + operational expenses for the day totalBiayaToday := hpp + pl.OperationalExpensesTotal // Laba/Rugi today labaRugiToday := totalOmset - totalBiayaToday // MTD values mtdOmset := getMtdNominalByID(pl.MainSummary, "total_omset") mtdCost := getMtdNominalByID(pl.MainSummary, "hpp") mtdOps := getMtdNominalByID(pl.MainSummary, "biaya_ops") mtdGaji := getMtdNominalByID(pl.MainSummary, "biaya_gaji") labaRugiMtd := mtdOmset - mtdCost - mtdOps - mtdGaji // Month name in Indonesian monthNames := []string{"", "Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli", "Agustus", "September", "Oktober", "November", "Desember"} monthName := fmt.Sprintf("%s %d", monthNames[day.Month()], day.Year()) reportDateStr := fmt.Sprintf("%d %s %d", day.Day(), monthNames[day.Month()], day.Year()) reportDateUpper := fmt.Sprintf("%d %s %d", day.Day(), strings.ToUpper(monthNames[day.Month()]), day.Year()) // Build main summary rows mainSummaryRows := buildProfitLossSummaryRows(pl.MainSummary) // Build purchasing items from ingredient data purchItems := make([]profitLossPurchasingItem, 0) var purchTotal float64 for _, item := range purch.IngredientData { purchItems = append(purchItems, profitLossPurchasingItem{ Name: item.IngredientName, Amount: formatCurrency(item.TotalCost), }) purchTotal += item.TotalCost } // Determine highlight classes labaRugiClass := "" labaRugiValueClass := "" if labaRugiToday < 0 { labaRugiClass = "highlight-red" labaRugiValueClass = "negative" } else { labaRugiClass = "highlight-green" labaRugiValueClass = "positive" } labaRugiMtdClass := "" labaRugiMtdValueClass := "" if labaRugiMtd < 0 { labaRugiMtdClass = "highlight-red" labaRugiMtdValueClass = "negative" } else { labaRugiMtdClass = "highlight-green" labaRugiMtdValueClass = "positive" } data := profitLossReportData{ OrganizationName: org.Name, MonthName: monthName, ReportDate: reportDateStr, ReportDateUpper: reportDateUpper, TotalPenjualan: formatCurrency(totalOmset), TotalBiaya: formatCurrency(totalBiayaToday), LabaRugi: formatCurrencySigned(labaRugiToday), LabaRugiClass: labaRugiClass, LabaRugiValueClass: labaRugiValueClass, LabaRugiMtd: formatCurrencySigned(labaRugiMtd), LabaRugiMtdClass: labaRugiMtdClass, LabaRugiMtdValueClass: labaRugiMtdValueClass, MainSummary: mainSummaryRows, PurchasingItems: purchItems, PurchasingTotal: formatCurrency(purchTotal), GeneratedBy: generatedBy, PrintTime: time.Now().In(loc).Format("02/01/2006 15:04:05"), } templatePath := filepath.Join("templates", "profit_loss_report.html") pdfBytes, err := renderTemplateToPDF(templatePath, data) if err != nil { return "", "", fmt.Errorf("render pdf: %w", err) } safeOrg := orgID.String() safeOutlet := "all" if outID != nil { safeOutlet = outID.String() } fileName := fmt.Sprintf("laporan-laba-rugi-%s-%s.pdf", 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 } func getMtdNominalByID(rows []models.ProfitLossSummaryRow, id string) float64 { for _, row := range rows { if row.ID == id { return row.MtdNominal } } return 0 } func buildProfitLossSummaryRows(rows []models.ProfitLossSummaryRow) []profitLossSummaryRowView { result := make([]profitLossSummaryRowView, 0, len(rows)) for i, row := range rows { rowClass := "" if row.IsBold { rowClass = "highlight-green-row" } // Highlight laba kotor row if row.ID == "laba_kotor" { rowClass = "highlight-row" } number := "" if row.ID != "" { number = fmt.Sprintf("%d", i+1) } subItems := make([]profitLossSummaryRowView, 0) for _, sub := range row.SubItems { subItems = append(subItems, profitLossSummaryRowView{ Label: sub.Label, TodayNominal: formatCurrencyOrDash(sub.TodayNominal), TodayPct: formatPct(sub.TodayPct), MtdNominal: formatCurrencyOrDash(sub.MtdNominal), MtdPct: formatPct(sub.MtdPct), RowClass: "", }) } result = append(result, profitLossSummaryRowView{ Number: number, Label: row.Label, TodayNominal: formatCurrencyOrDash(row.TodayNominal), TodayPct: formatPct(row.TodayPct), MtdNominal: formatCurrencyOrDash(row.MtdNominal), MtdPct: formatPct(row.MtdPct), RowClass: rowClass, SubItems: subItems, }) } return result } func formatCurrencyOrDash(amount float64) string { if amount == 0 { return "-" } if amount < 0 { return formatCurrencySigned(amount) } return formatCurrency(amount) } func formatCurrencySigned(amount float64) string { if amount < 0 { return "(Rp " + addThousandsSep(fmt.Sprintf("%.0f", -amount)) + ")" } return "Rp " + addThousandsSep(fmt.Sprintf("%.0f", amount)) } func formatPct(pct float64) string { if pct == 0 { return "0%" } return fmt.Sprintf("%.0f%%", pct) }