Files
apskel-pos-backend/internal/service/report_service.go
T
efrilmandClaude Opus 5 992bb04816 feat(order): support weight-based products
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>
2026-09-06 17:14:42 +07:00

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)
}