Products like fish are sold per weighing (4.2 ons, 5.6 ons), which the order line could not represent: quantity is INTEGER and prices are always computed as quantity * unit_price. Model one weighing as one order line. quantity stays INTEGER and keeps meaning "how many items"; the measured amount goes into a new nullable order_items.weight, and the line is priced weight * unit_price. Two weighings of the same product are two lines, never merged into one. Keeping quantity integral avoids float comparisons in void, refund and split bill, where accumulated rounding error would silently misbehave — "1.4 + 1.4 + 1.4" is not 4.2 in float64, which would leave a fully paid split-bill item marked unpaid. BillableQuantity() is now the single place that decides between weight and count; every price and cost calculation goes through it. Missing one would bill a 4.2 ons fish as a single ons — wrong money, no error. Two database constraints back the design: a weighed line always carries a positive weight, and its quantity is pinned to 1. The latter also makes void all-or-nothing for weighed lines, so the row-splitting branch can never produce a zero-weight remainder row. Also wires product.unit_id through the API, which was previously not settable at all, and corrects the misleading comment on the request's unit_price field — that value has never been used; price always comes from the database. Design notes and the audit of every price multiplication site are in docs/rfc-weight-based-products.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
527 lines
16 KiB
Go
527 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
|
|
Weight string
|
|
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,
|
|
Weight: formatWeight(p.WeightSold),
|
|
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
|
|
}
|
|
|
|
// formatWeight renders the weight sold for a product, or a dash when the product is
|
|
// not sold by weight. Trailing zeros are trimmed so 4.200 prints as "4,2".
|
|
func formatWeight(weight float64) string {
|
|
if weight <= 0 {
|
|
return "-"
|
|
}
|
|
s := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.3f", weight), "0"), ".")
|
|
return strings.Replace(s, ".", ",", 1)
|
|
}
|
|
|
|
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)
|
|
}
|