515 lines
16 KiB
Go
515 lines
16 KiB
Go
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)
|
|
}
|