Files
apskel-pos-backend/internal/processor/analytics_processor.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

1241 lines
44 KiB
Go

package processor
import (
"context"
"fmt"
"strings"
"time"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
"github.com/google/uuid"
)
type AnalyticsProcessor interface {
GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error)
GetSalesAnalytics(ctx context.Context, req *models.SalesAnalyticsRequest) (*models.SalesAnalyticsResponse, error)
GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error)
GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error)
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error)
GetProductAnalyticsPerParentCategory(ctx context.Context, req *models.ProductAnalyticsPerParentCategoryRequest) (*models.ProductAnalyticsPerParentCategoryResponse, error)
GetParentCategoryAnalyticsDetail(ctx context.Context, req *models.ParentCategoryAnalyticsDetailRequest) (*models.ParentCategoryAnalyticsDetailResponse, error)
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error)
GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error)
GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error)
GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error)
}
type AnalyticsProcessorImpl struct {
analyticsRepo repository.AnalyticsRepository
expenseRepo ExpenseRepository
}
func NewAnalyticsProcessorImpl(analyticsRepo repository.AnalyticsRepository, expenseRepo ExpenseRepository) *AnalyticsProcessorImpl {
return &AnalyticsProcessorImpl{
analyticsRepo: analyticsRepo,
expenseRepo: expenseRepo,
}
}
// resolveOutletName fetches the outlet name from the database if outletID is provided
func (p *AnalyticsProcessorImpl) resolveOutletName(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) *string {
if outletID == nil {
return nil
}
name, err := p.analyticsRepo.GetOutletName(ctx, organizationID, *outletID)
if err != nil || name == "" {
return nil
}
return &name
}
func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) {
if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to")
}
analyticsData, err := p.analyticsRepo.GetPaymentMethodAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo)
if err != nil {
return nil, fmt.Errorf("failed to get payment method analytics: %w", err)
}
var totalAmount float64
var totalOrders int64
var totalPayments int64
for _, data := range analyticsData {
totalAmount += data.TotalAmount
totalOrders += data.OrderCount
totalPayments += data.PaymentCount
}
var averageOrderValue float64
if totalOrders > 0 {
averageOrderValue = totalAmount / float64(totalOrders)
}
// Calculate percentages
var resultData []models.PaymentMethodAnalyticsData
for _, data := range analyticsData {
var percentage float64
if totalAmount > 0 {
percentage = (data.TotalAmount / totalAmount) * 100
}
resultData = append(resultData, models.PaymentMethodAnalyticsData{
PaymentMethodID: data.PaymentMethodID,
PaymentMethodName: data.PaymentMethodName,
PaymentMethodType: data.PaymentMethodType,
TotalAmount: data.TotalAmount,
OrderCount: data.OrderCount,
PaymentCount: data.PaymentCount,
Percentage: percentage,
})
}
summary := models.PaymentMethodSummary{
TotalAmount: totalAmount,
TotalOrders: totalOrders,
TotalPayments: totalPayments,
AverageOrderValue: averageOrderValue,
}
return &models.PaymentMethodAnalyticsResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom,
DateTo: req.DateTo,
GroupBy: req.GroupBy,
Summary: summary,
Data: resultData,
}, nil
}
func (p *AnalyticsProcessorImpl) GetSalesAnalytics(ctx context.Context, req *models.SalesAnalyticsRequest) (*models.SalesAnalyticsResponse, error) {
// Validate date range
if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to")
}
// Validate groupBy
if req.GroupBy == "" {
req.GroupBy = "day"
}
// Get analytics data from repository
analyticsData, err := p.analyticsRepo.GetSalesAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy)
if err != nil {
return nil, fmt.Errorf("failed to get sales analytics: %w", err)
}
// Calculate summary
var totalSales float64
var totalOrders int64
var totalItems int64
var totalTax float64
var totalDiscount float64
var netSales float64
for _, data := range analyticsData {
totalSales += data.Sales
totalOrders += data.Orders
totalItems += data.Items
totalTax += data.Tax
totalDiscount += data.Discount
netSales += data.NetSales
}
var averageOrderValue float64
if totalOrders > 0 {
averageOrderValue = totalSales / float64(totalOrders)
}
// Transform data
var resultData []models.SalesAnalyticsData
for _, data := range analyticsData {
resultData = append(resultData, models.SalesAnalyticsData{
Date: data.Date,
Sales: data.Sales,
Orders: data.Orders,
Items: data.Items,
Tax: data.Tax,
Discount: data.Discount,
NetSales: data.NetSales,
})
}
summary := models.SalesSummary{
TotalSales: totalSales,
TotalOrders: totalOrders,
TotalItems: totalItems,
AverageOrderValue: averageOrderValue,
TotalTax: totalTax,
TotalDiscount: totalDiscount,
NetSales: netSales,
}
return &models.SalesAnalyticsResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom,
DateTo: req.DateTo,
GroupBy: req.GroupBy,
Summary: summary,
Data: resultData,
}, nil
}
func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error) {
if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to")
}
if req.GroupBy == "" {
req.GroupBy = "day"
}
teamFilter, err := models.ParsePurchaseTeamFilter(req.Team)
if err != nil {
return nil, err
}
result, err := p.analyticsRepo.GetPurchasingAnalytics(ctx, req.OrganizationID, req.OutletID, teamFilter, req.DateFrom, req.DateTo, req.GroupBy)
if err != nil {
return nil, fmt.Errorf("failed to get purchasing analytics: %w", err)
}
data := make([]models.PurchasingAnalyticsData, len(result.Data))
for i, item := range result.Data {
data[i] = models.PurchasingAnalyticsData{
Date: item.Date,
Purchases: item.Purchases,
RawMaterialPurchases: item.RawMaterialPurchases,
ExpensePurchases: item.ExpensePurchases,
PurchaseOrders: item.PurchaseOrders,
RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders,
ExpenseCount: item.ExpenseCount,
Quantity: item.Quantity,
Ingredients: item.Ingredients,
Vendors: item.Vendors,
}
}
ingredientData := make([]models.PurchasingIngredientData, len(result.IngredientData))
for i, item := range result.IngredientData {
ingredientData[i] = models.PurchasingIngredientData{
IngredientID: item.IngredientID,
IngredientName: item.IngredientName,
Quantity: item.Quantity,
TotalCost: item.TotalCost,
AverageUnitCost: item.AverageUnitCost,
PurchaseOrderCount: item.PurchaseOrderCount,
}
}
vendorData := make([]models.PurchasingVendorData, len(result.VendorData))
for i, item := range result.VendorData {
vendorData[i] = models.PurchasingVendorData{
VendorID: item.VendorID,
VendorName: item.VendorName,
TotalCost: item.TotalCost,
PurchaseOrderCount: item.PurchaseOrderCount,
IngredientCount: item.IngredientCount,
Quantity: item.Quantity,
}
}
teamData := make([]models.PurchasingTeamData, len(result.TeamData))
for i, item := range result.TeamData {
teamData[i] = models.PurchasingTeamData{
Scope: item.Scope,
CategoryID: item.CategoryID,
Name: item.Name,
TotalPurchases: item.TotalPurchases,
RawMaterialPurchases: item.RawMaterialPurchases,
ExpensePurchases: item.ExpensePurchases,
PurchaseOrderCount: item.PurchaseOrderCount,
Quantity: item.Quantity,
Percentage: item.Percentage,
}
}
return &models.PurchasingAnalyticsResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: result.OutletName,
Team: req.Team,
DateFrom: req.DateFrom,
DateTo: req.DateTo,
GroupBy: req.GroupBy,
Summary: models.PurchasingSummary{
TotalPurchases: result.Summary.TotalPurchases,
RawMaterialPurchases: result.Summary.RawMaterialPurchases,
ExpensePurchases: result.Summary.ExpensePurchases,
TotalPurchaseOrders: result.Summary.TotalPurchaseOrders,
RawMaterialPurchaseOrders: result.Summary.RawMaterialPurchaseOrders,
ExpenseCount: result.Summary.ExpenseCount,
TotalQuantity: result.Summary.TotalQuantity,
AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue,
TotalIngredients: result.Summary.TotalIngredients,
TotalVendors: result.Summary.TotalVendors,
TotalTeams: result.Summary.TotalTeams,
},
Data: data,
IngredientData: ingredientData,
VendorData: vendorData,
TeamData: teamData,
}, nil
}
func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error) {
// Validate date range
if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to")
}
// Set default limit
if req.Limit <= 0 {
req.Limit = 1000
}
// Get analytics data from repository
analyticsData, err := p.analyticsRepo.GetProductAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.Limit)
if err != nil {
return nil, fmt.Errorf("failed to get product analytics: %w", err)
}
// Transform data
var resultData []models.ProductAnalyticsData
for _, data := range analyticsData {
resultData = append(resultData, models.ProductAnalyticsData{
ProductID: data.ProductID,
ProductName: data.ProductName,
ProductSku: data.ProductSku,
ProductPrice: data.ProductPrice,
CategoryID: data.CategoryID,
CategoryName: data.CategoryName,
CategoryOrder: data.CategoryOrder,
QuantitySold: data.QuantitySold,
WeightSold: data.WeightSold,
Revenue: data.Revenue,
AveragePrice: data.AveragePrice,
OrderCount: data.OrderCount,
StandardHppPerUnit: data.StandardHppPerUnit,
StandardHppTotal: data.StandardHppTotal,
FifoHppPerUnit: data.FifoHppPerUnit,
FifoHppTotal: data.FifoHppTotal,
MovingAverageHppPerUnit: data.MovingAverageHppPerUnit,
MovingAverageHppTotal: data.MovingAverageHppTotal,
})
}
return &models.ProductAnalyticsResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom,
DateTo: req.DateTo,
Data: resultData,
}, nil
}
func (p *AnalyticsProcessorImpl) GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error) {
// Validate date range
if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to")
}
// Get analytics data from repository
analyticsData, err := p.analyticsRepo.GetProductAnalyticsPerCategory(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo)
if err != nil {
return nil, fmt.Errorf("failed to get product analytics per category: %w", err)
}
// Transform data
var resultData []models.ProductAnalyticsPerCategoryData
for _, data := range analyticsData {
resultData = append(resultData, models.ProductAnalyticsPerCategoryData{
CategoryID: data.CategoryID,
CategoryName: data.CategoryName,
TotalRevenue: data.TotalRevenue,
TotalQuantity: data.TotalQuantity,
ProductCount: data.ProductCount,
OrderCount: data.OrderCount,
TotalStandardHpp: data.TotalStandardHpp,
TotalFifoHpp: data.TotalFifoHpp,
TotalMovingAverageHpp: data.TotalMovingAverageHpp,
})
}
return &models.ProductAnalyticsPerCategoryResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom,
DateTo: req.DateTo,
Data: resultData,
}, nil
}
func (p *AnalyticsProcessorImpl) GetProductAnalyticsPerParentCategory(ctx context.Context, req *models.ProductAnalyticsPerParentCategoryRequest) (*models.ProductAnalyticsPerParentCategoryResponse, error) {
// Validate date range
if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to")
}
// Get analytics data from repository
analyticsData, err := p.analyticsRepo.GetProductAnalyticsPerParentCategory(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo)
if err != nil {
return nil, fmt.Errorf("failed to get product analytics per parent category: %w", err)
}
// Transform data
var resultData []models.ProductAnalyticsPerParentCategoryData
for _, data := range analyticsData {
resultData = append(resultData, models.ProductAnalyticsPerParentCategoryData{
ParentCategoryID: data.ParentCategoryID,
ParentCategoryName: data.ParentCategoryName,
TotalRevenue: data.TotalRevenue,
TotalQuantity: data.TotalQuantity,
CategoryCount: data.CategoryCount,
ProductCount: data.ProductCount,
OrderCount: data.OrderCount,
TotalStandardHpp: data.TotalStandardHpp,
TotalFifoHpp: data.TotalFifoHpp,
TotalMovingAverageHpp: data.TotalMovingAverageHpp,
})
}
budget, err := p.buildBudgetCutOff(ctx, req.OrganizationID, req.OutletID, nil, req.DateFrom, req.DateTo)
if err != nil {
return nil, err
}
return &models.ProductAnalyticsPerParentCategoryResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom,
DateTo: req.DateTo,
Data: resultData,
Budget: budget,
}, nil
}
func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Context, req *models.ParentCategoryAnalyticsDetailRequest) (*models.ParentCategoryAnalyticsDetailResponse, error) {
// Validate date range
if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to")
}
detail, err := p.analyticsRepo.GetParentCategoryAnalyticsDetail(ctx, req.OrganizationID, req.OutletID, req.ParentCategoryID, req.DateFrom, req.DateTo)
if err != nil {
return nil, fmt.Errorf("failed to get parent category analytics detail: %w", err)
}
// Bucket the product rows by the category they belong to
productsByCategory := make(map[uuid.UUID][]models.ParentCategoryAnalyticsProductData)
for _, product := range detail.Products {
productsByCategory[product.CategoryID] = append(productsByCategory[product.CategoryID], models.ParentCategoryAnalyticsProductData{
ProductID: product.ProductID,
ProductName: product.ProductName,
ProductSku: product.ProductSku,
ProductPrice: product.ProductPrice,
QuantitySold: product.QuantitySold,
WeightSold: product.WeightSold,
Revenue: product.Revenue,
AveragePrice: product.AveragePrice,
OrderCount: product.OrderCount,
StandardHppPerUnit: product.StandardHppPerUnit,
StandardHppTotal: product.StandardHppTotal,
FifoHppPerUnit: product.FifoHppPerUnit,
FifoHppTotal: product.FifoHppTotal,
MovingAverageHppPerUnit: product.MovingAverageHppPerUnit,
MovingAverageHppTotal: product.MovingAverageHppTotal,
})
}
categories := make([]models.ParentCategoryAnalyticsDetailData, 0, len(detail.Categories))
for _, category := range detail.Categories {
products := productsByCategory[category.CategoryID]
if products == nil {
products = []models.ParentCategoryAnalyticsProductData{}
}
categories = append(categories, models.ParentCategoryAnalyticsDetailData{
CategoryID: category.CategoryID,
CategoryName: category.CategoryName,
TotalRevenue: category.TotalRevenue,
TotalQuantity: category.TotalQuantity,
ProductCount: category.ProductCount,
OrderCount: category.OrderCount,
TotalStandardHpp: category.TotalStandardHpp,
TotalFifoHpp: category.TotalFifoHpp,
TotalMovingAverageHpp: category.TotalMovingAverageHpp,
Products: products,
})
}
summary := models.ParentCategoryAnalyticsDetailSummary{}
if detail.Summary != nil {
summary = models.ParentCategoryAnalyticsDetailSummary{
TotalRevenue: detail.Summary.TotalRevenue,
TotalQuantity: detail.Summary.TotalQuantity,
CategoryCount: detail.Summary.CategoryCount,
ProductCount: detail.Summary.ProductCount,
OrderCount: detail.Summary.OrderCount,
TotalStandardHpp: detail.Summary.TotalStandardHpp,
TotalFifoHpp: detail.Summary.TotalFifoHpp,
TotalMovingAverageHpp: detail.Summary.TotalMovingAverageHpp,
}
}
budget, err := p.buildBudgetCutOff(ctx, req.OrganizationID, req.OutletID, &req.ParentCategoryID, req.DateFrom, req.DateTo)
if err != nil {
return nil, err
}
return &models.ParentCategoryAnalyticsDetailResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom,
DateTo: req.DateTo,
ParentCategoryID: detail.ParentCategoryID,
ParentCategoryName: detail.ParentCategoryName,
Summary: summary,
Categories: categories,
Budget: budget,
}, nil
}
// startOfWeek returns the Monday 00:00 of the week containing t, in t's own location.
func startOfWeek(t time.Time) time.Time {
daysSinceMonday := (int(t.Weekday()) + 6) % 7
year, month, day := t.Date()
return time.Date(year, month, day-daysSinceMonday, 0, 0, 0, 0, t.Location())
}
// endOfWeek returns the Sunday 23:59:59.999999999 of the week containing t.
func endOfWeek(t time.Time) time.Time {
return startOfWeek(t).AddDate(0, 0, 7).Add(-time.Nanosecond)
}
// newBudgetPeriod splits a period's revenue into the spending limits.
func newBudgetPeriod(start, end time.Time, revenue float64, orderCount int64) models.BudgetPeriod {
return models.BudgetPeriod{
PeriodStart: start,
PeriodEnd: end,
Revenue: revenue,
OrderCount: orderCount,
LimitPurchase: revenue * constants.BudgetLimitPurchasePercent / 100,
LimitOwner: revenue * constants.BudgetLimitOwnerPercent / 100,
LimitTeam: revenue * constants.BudgetLimitTeamPercent / 100,
}
}
// buildBudgetCutOff produces the weekly cut-off breakdown for the given scope. Weeks
// are always whole Monday-to-Sunday blocks, so the covered range is widened to the
// week boundaries around the requested dates. A nil parentCategoryID covers every
// category.
func (p *AnalyticsProcessorImpl) buildBudgetCutOff(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, parentCategoryID *uuid.UUID, dateFrom, dateTo time.Time) (models.BudgetCutOff, error) {
cutOffFrom := startOfWeek(dateFrom)
cutOffTo := endOfWeek(dateTo)
budget := models.BudgetCutOff{
Percentages: models.BudgetPercentages{
Purchase: constants.BudgetLimitPurchasePercent,
Owner: constants.BudgetLimitOwnerPercent,
Team: constants.BudgetLimitTeamPercent,
},
CutOffFrom: cutOffFrom,
CutOffTo: cutOffTo,
Weekly: []models.BudgetPeriod{},
Monthly: []models.BudgetMonthPeriod{},
}
rows, err := p.analyticsRepo.GetBudgetCutOffWeekly(ctx, organizationID, outletID, parentCategoryID, cutOffFrom, cutOffTo)
if err != nil {
return budget, fmt.Errorf("failed to get budget cut off: %w", err)
}
// Key the rows by their Monday so weeks without any sales can still be emitted
rowsByWeek := make(map[string]*entities.BudgetCutOffWeek, len(rows))
for _, row := range rows {
rowsByWeek[row.WeekStart.In(cutOffFrom.Location()).Format("2006-01-02")] = row
}
var (
totalRevenue float64
totalOrders int64
monthOrder []string
monthAccumulator = map[string]*models.BudgetMonthPeriod{}
)
for week := cutOffFrom; !week.After(cutOffTo); week = week.AddDate(0, 0, 7) {
var revenue float64
var orderCount int64
if row, ok := rowsByWeek[week.Format("2006-01-02")]; ok {
revenue, orderCount = row.Revenue, row.OrderCount
}
period := newBudgetPeriod(week, endOfWeek(week), revenue, orderCount)
budget.Weekly = append(budget.Weekly, period)
totalRevenue += revenue
totalOrders += orderCount
// A week belongs to the month of its Monday, so every week is counted once
monthKey := week.Format("2006-01")
month, ok := monthAccumulator[monthKey]
if !ok {
month = &models.BudgetMonthPeriod{Month: monthKey}
month.PeriodStart = period.PeriodStart
monthAccumulator[monthKey] = month
monthOrder = append(monthOrder, monthKey)
}
month.WeekCount++
month.PeriodEnd = period.PeriodEnd
month.Revenue += revenue
month.OrderCount += orderCount
}
for _, monthKey := range monthOrder {
month := monthAccumulator[monthKey]
budget.Monthly = append(budget.Monthly, models.BudgetMonthPeriod{
Month: month.Month,
WeekCount: month.WeekCount,
BudgetPeriod: newBudgetPeriod(month.PeriodStart, month.PeriodEnd, month.Revenue, month.OrderCount),
})
}
budget.Total = newBudgetPeriod(cutOffFrom, cutOffTo, totalRevenue, totalOrders)
return budget, nil
}
func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) {
// Validate date range
if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to")
}
// Get dashboard overview
overview, err := p.analyticsRepo.GetDashboardOverview(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo)
if err != nil {
return nil, fmt.Errorf("failed to get dashboard overview: %w", err)
}
// Get top products (limit to 5 for dashboard)
productReq := &models.ProductAnalyticsRequest{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
DateFrom: req.DateFrom,
DateTo: req.DateTo,
Limit: 5,
}
topProducts, err := p.GetProductAnalytics(ctx, productReq)
if err != nil {
return nil, fmt.Errorf("failed to get top products: %w", err)
}
// Get payment methods
paymentReq := &models.PaymentMethodAnalyticsRequest{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
DateFrom: req.DateFrom,
DateTo: req.DateTo,
GroupBy: "day",
}
paymentMethods, err := p.GetPaymentMethodAnalytics(ctx, paymentReq)
if err != nil {
return nil, fmt.Errorf("failed to get payment methods: %w", err)
}
// Get recent sales (last 7 days)
recentDateFrom := time.Now().AddDate(0, 0, -7)
salesReq := &models.SalesAnalyticsRequest{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
DateFrom: recentDateFrom,
DateTo: req.DateTo,
GroupBy: "day",
}
recentSales, err := p.GetSalesAnalytics(ctx, salesReq)
if err != nil {
return nil, fmt.Errorf("failed to get recent sales: %w", err)
}
return &models.DashboardAnalyticsResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom,
DateTo: req.DateTo,
Overview: models.DashboardOverview{
TotalSales: overview.TotalSales,
TotalOrders: overview.TotalOrders,
AverageOrderValue: overview.AverageOrderValue,
TotalCustomers: overview.TotalCustomers,
VoidedOrders: overview.VoidedOrders,
RefundedOrders: overview.RefundedOrders,
TotalItemSold: overview.TotalItemSold,
TotalLowStock: overview.TotalLowStock,
TotalProductActive: overview.TotalProductActive,
},
TopProducts: topProducts.Data,
PaymentMethods: paymentMethods.Data,
RecentSales: recentSales.Data,
}, nil
}
func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) {
if req.DateFrom.IsZero() {
return nil, fmt.Errorf("date_from is required")
}
if req.DateTo.IsZero() {
return nil, fmt.Errorf("date_to is required")
}
if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to")
}
if req.GroupBy == "" {
req.GroupBy = "day"
}
result, err := p.analyticsRepo.GetProfitLossAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy)
if err != nil {
return nil, fmt.Errorf("failed to get profit/loss analytics: %w", err)
}
data := make([]models.ProfitLossData, len(result.Data))
for i, item := range result.Data {
data[i] = models.ProfitLossData{
Date: item.Date,
Revenue: item.Revenue,
Cost: item.Cost,
GrossProfit: item.GrossProfit,
GrossProfitMargin: item.GrossProfitMargin,
Tax: item.Tax,
Discount: item.Discount,
NetProfit: item.NetProfit,
NetProfitMargin: item.NetProfitMargin,
Orders: item.Orders,
}
}
productData := make([]models.ProductProfitData, len(result.ProductData))
for i, item := range result.ProductData {
productData[i] = models.ProductProfitData{
ProductID: item.ProductID,
ProductName: item.ProductName,
CategoryID: item.CategoryID,
CategoryName: item.CategoryName,
QuantitySold: item.QuantitySold,
Revenue: item.Revenue,
Cost: item.Cost,
GrossProfit: item.GrossProfit,
GrossProfitMargin: item.GrossProfitMargin,
AveragePrice: item.AveragePrice,
AverageCost: item.AverageCost,
ProfitPerUnit: item.ProfitPerUnit,
}
}
type categoryAmount struct {
Name string
TodayAmt float64
MtdAmt float64
}
categoryMap := make(map[string]*categoryAmount)
var categoryOrder []string
for _, cat := range result.TodayExpenseByCategory {
name := cat.CategoryName
if _, exists := categoryMap[name]; !exists {
categoryMap[name] = &categoryAmount{Name: name}
categoryOrder = append(categoryOrder, name)
}
categoryMap[name].TodayAmt = cat.Amount
}
for _, cat := range result.MtdExpenseByCategory {
name := cat.CategoryName
if _, exists := categoryMap[name]; !exists {
categoryMap[name] = &categoryAmount{Name: name}
categoryOrder = append(categoryOrder, name)
}
categoryMap[name].MtdAmt = cat.Amount
}
var todayTotalOps float64
var mtdTotalOps float64
var todayGaji float64
var mtdGaji float64
for _, cat := range categoryMap {
if isSalaryExpenseCategory(cat.Name) {
todayGaji += cat.TodayAmt
mtdGaji += cat.MtdAmt
continue
}
todayTotalOps += cat.TodayAmt
mtdTotalOps += cat.MtdAmt
}
todayGrossProfit := result.TodayRevenue - result.TodayCost
mtdGrossProfit := result.MtdRevenue - result.MtdCost
todayProfitBeforeGaji := todayGrossProfit - todayTotalOps
mtdProfitBeforeGaji := mtdGrossProfit - mtdTotalOps
todayNetProfit := todayProfitBeforeGaji - todayGaji
mtdNetProfit := mtdProfitBeforeGaji - mtdGaji
todayPct := func(nominal float64) float64 {
if result.TodayRevenue == 0 {
return 0
}
return (nominal / result.TodayRevenue) * 100
}
mtdPct := func(nominal float64) float64 {
if result.MtdRevenue == 0 {
return 0
}
return (nominal / result.MtdRevenue) * 100
}
opsSubItems := make([]models.ProfitLossSummaryRow, 0, len(categoryOrder)+1)
opsCategoryCount := 0
for _, name := range categoryOrder {
cat := categoryMap[name]
if isSalaryExpenseCategory(cat.Name) {
continue
}
opsCategoryCount++
opsSubItems = append(opsSubItems, models.ProfitLossSummaryRow{
ID: fmt.Sprintf("by_%s", slugify(name)),
Label: fmt.Sprintf("%d. %s", opsCategoryCount, cat.Name),
TodayNominal: cat.TodayAmt,
TodayPct: todayPct(cat.TodayAmt),
MtdNominal: cat.MtdAmt,
MtdPct: mtdPct(cat.MtdAmt),
})
}
opsSubItems = append(opsSubItems, models.ProfitLossSummaryRow{
ID: "total_biaya_ops",
Label: fmt.Sprintf("Total Biaya OPS (%d kategori)", opsCategoryCount),
IsBold: true,
TodayNominal: todayTotalOps,
TodayPct: todayPct(todayTotalOps),
MtdNominal: mtdTotalOps,
MtdPct: mtdPct(mtdTotalOps),
})
mainSummary := []models.ProfitLossSummaryRow{
{
ID: "total_omset", Label: "TOTAL OMSET",
TodayNominal: result.TodayRevenue, TodayPct: todayPct(result.TodayRevenue),
MtdNominal: result.MtdRevenue, MtdPct: mtdPct(result.MtdRevenue),
},
{
ID: "hpp", Label: "HPP",
TodayNominal: result.TodayCost, TodayPct: todayPct(result.TodayCost),
MtdNominal: result.MtdCost, MtdPct: mtdPct(result.MtdCost),
},
{
ID: "laba_kotor", Label: "Laba Kotor (1-2)",
TodayNominal: todayGrossProfit, TodayPct: todayPct(todayGrossProfit),
MtdNominal: mtdGrossProfit, MtdPct: mtdPct(mtdGrossProfit),
},
{
ID: "biaya_ops", Label: "BIAYA OPS",
TodayNominal: todayTotalOps, TodayPct: todayPct(todayTotalOps),
MtdNominal: mtdTotalOps, MtdPct: mtdPct(mtdTotalOps),
SubItems: opsSubItems,
},
{
ID: "laba_rugi_sblm_gaji", Label: "Laba/Rugi sblm Gaji (3-4)",
TodayNominal: todayProfitBeforeGaji, TodayPct: todayPct(todayProfitBeforeGaji),
MtdNominal: mtdProfitBeforeGaji, MtdPct: mtdPct(mtdProfitBeforeGaji),
},
{
ID: "biaya_gaji", Label: "BIAYA GAJI",
TodayNominal: todayGaji, TodayPct: todayPct(todayGaji),
MtdNominal: mtdGaji, MtdPct: mtdPct(mtdGaji),
},
{
ID: "laba_rugi", Label: "Laba/Rugi (5-6)", IsBold: true,
TodayNominal: todayNetProfit, TodayPct: todayPct(todayNetProfit),
MtdNominal: mtdNetProfit, MtdPct: mtdPct(mtdNetProfit),
},
}
opsItems := make([]models.OperationalExpenseItem, len(result.OperationalExpenseItems))
var opsTotal float64
for i, item := range result.OperationalExpenseItems {
opsItems[i] = models.OperationalExpenseItem{
Item: item.Item,
Nominal: item.Amount,
}
opsTotal += item.Amount
}
purchasingItems := make([]models.ProfitLossPurchasingItem, len(result.PurchasingItems))
for i, item := range result.PurchasingItems {
purchasingItems[i] = models.ProfitLossPurchasingItem{
Date: item.Date,
Item: item.Item,
Quantity: item.Quantity,
Nominal: item.Amount,
}
}
return &models.ProfitLossAnalyticsResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom,
DateTo: req.DateTo,
GroupBy: req.GroupBy,
Summary: models.ProfitLossSummary{
TotalRevenue: result.Summary.TotalRevenue,
TotalCost: result.Summary.TotalCost,
GrossProfit: result.Summary.GrossProfit,
GrossProfitMargin: result.Summary.GrossProfitMargin,
TotalTax: result.Summary.TotalTax,
TotalDiscount: result.Summary.TotalDiscount,
NetProfit: result.Summary.NetProfit,
NetProfitMargin: result.Summary.NetProfitMargin,
TotalOrders: result.Summary.TotalOrders,
AverageProfit: result.Summary.AverageProfit,
ProfitabilityRatio: result.Summary.ProfitabilityRatio,
},
Data: data,
ProductData: productData,
MainSummary: mainSummary,
Purchasing: models.ProfitLossPurchasing{
TodayTotal: result.TodayPurchasing,
MtdTotal: result.MtdPurchasing,
TodayRawMaterial: result.TodayPurchasingRawMaterial,
MtdRawMaterial: result.MtdPurchasingRawMaterial,
TodayExpense: result.TodayPurchasingExpense,
MtdExpense: result.MtdPurchasingExpense,
Items: purchasingItems,
},
OperationalExpenses: opsItems,
OperationalExpensesTotal: opsTotal,
}, nil
}
func isSalaryExpenseCategory(name string) bool {
name = strings.ToLower(name)
return strings.Contains(name, "gaji") || strings.Contains(name, "salary")
}
func slugify(s string) string {
result := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'a' && c <= 'z':
result = append(result, c)
case c >= 'A' && c <= 'Z':
result = append(result, c+32)
case c >= '0' && c <= '9':
result = append(result, c)
default:
if len(result) == 0 || result[len(result)-1] != '_' {
result = append(result, '_')
}
}
}
return string(result)
}
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to")
}
return p.buildExclusiveSummaryPeriod(ctx, req)
}
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) {
monthStart := time.Date(req.Month.Year(), req.Month.Month(), 1, 0, 0, 0, 0, req.Month.Location())
monthEnd := monthStart.AddDate(0, 1, 0).Add(-time.Nanosecond)
fullPeriod, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
DateFrom: monthStart,
DateTo: monthEnd,
})
if err != nil {
return nil, err
}
periods := make([]models.ExclusiveSummaryMonthlyPeriod, 0)
for _, bucket := range buildExclusiveSummaryMonthlyBuckets(monthStart) {
period, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
DateFrom: bucket.DateFrom,
DateTo: bucket.DateTo,
})
if err != nil {
return nil, err
}
periods = append(periods, models.ExclusiveSummaryMonthlyPeriod{
Label: bucket.Label,
DateFrom: bucket.DateFrom,
DateTo: bucket.DateTo,
Sales: period.Summary.Sales,
HPP: period.Summary.HPP,
GrossProfit: period.Summary.GrossProfit,
GrossMargin: percentage(period.Summary.GrossProfit, period.Summary.Sales),
})
}
bankBalances, err := p.analyticsRepo.GetExclusiveSummaryBankBalances(ctx, req.OrganizationID, req.OutletID)
if err != nil {
return nil, fmt.Errorf("failed to get exclusive summary bank balances: %w", err)
}
bankBalance := make([]models.ExclusiveSummaryBankBalance, len(bankBalances))
for i, item := range bankBalances {
bankBalance[i] = models.ExclusiveSummaryBankBalance{
Bank: item.Bank,
OpeningBalance: item.OpeningBalance,
IncomingMutation: item.IncomingMutation,
OutgoingMutation: item.OutgoingMutation,
ClosingBalance: item.ClosingBalance,
Notes: item.Notes,
}
}
return &models.ExclusiveSummaryMonthlyResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
Month: monthStart.Format("2006-01"),
Summary: models.ExclusiveSummaryMonthlySummary{
TotalSales: fullPeriod.Summary.Sales,
HPP: fullPeriod.Summary.HPP,
GrossProfit: fullPeriod.Summary.GrossProfit,
OperationalExpensesTotal: fullPeriod.Summary.OperationalExpensesTotal,
TotalCost: fullPeriod.Summary.TotalCost,
NetProfit: fullPeriod.Summary.NetProfit,
NetProfitMargin: percentage(fullPeriod.Summary.NetProfit, fullPeriod.Summary.Sales),
},
Periods: periods,
BankBalance: bankBalance,
}, nil
}
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
mtdStart := time.Date(req.DateTo.Year(), req.DateTo.Month(), 1, 0, 0, 0, 0, req.DateTo.Location())
return p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
DateFrom: mtdStart,
DateTo: req.DateTo,
ExcludeGajiStaffFromReimburse: req.ExcludeGajiStaffFromReimburse,
})
}
func (p *AnalyticsProcessorImpl) buildExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
result, err := p.analyticsRepo.GetExclusiveSummaryAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo)
if err != nil {
return nil, fmt.Errorf("failed to get exclusive summary analytics: %w", err)
}
hppBreakdown, hppTotal := exclusiveSummaryCategoryBreakdown(result.HPPBreakdown)
operationalBreakdown, operationalTotal := exclusiveSummaryCategoryBreakdown(result.OperationalExpenseBreakdown)
salaryDW, salaryStaff, salaryOther := exclusiveSummarySalaryBreakdown(result.DailyTransactions)
salaryTotal := salaryDW + salaryStaff + salaryOther
otherOperationalExpenses := operationalTotal - salaryTotal
if otherOperationalExpenses < 0 {
otherOperationalExpenses = 0
}
grossProfit := result.SalesTotal - hppTotal
totalCost := hppTotal + operationalTotal
netProfit := result.SalesTotal - totalCost
excludedSalaryStaff := 0.0
if req.ExcludeGajiStaffFromReimburse {
excludedSalaryStaff = salaryStaff
}
dailySummary := make([]models.ExclusiveSummaryDailySummary, len(result.DailySummary))
for i, item := range result.DailySummary {
dailySummary[i] = models.ExclusiveSummaryDailySummary{
Date: item.Date,
TransactionCount: item.TransactionCount,
TotalCost: item.TotalCost,
}
}
dailyTransactions := make([]models.ExclusiveSummaryDailyTransaction, len(result.DailyTransactions))
for i, item := range result.DailyTransactions {
dailyTransactions[i] = models.ExclusiveSummaryDailyTransaction{
Date: item.Date,
CategoryCode: item.CategoryCode,
CategoryName: item.CategoryName,
Description: item.Description,
Amount: item.Amount,
Source: item.Source,
}
}
return &models.ExclusiveSummaryPeriodResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
Period: models.ExclusiveSummaryPeriodRange{
DateFrom: req.DateFrom,
DateTo: req.DateTo,
},
Summary: models.ExclusiveSummaryPeriodSummary{
Sales: result.SalesTotal,
HPP: hppTotal,
GrossProfit: grossProfit,
SalaryTotal: salaryTotal,
SalaryDW: salaryDW,
SalaryStaff: salaryStaff,
SalaryOther: salaryOther,
OtherOperationalExpenses: otherOperationalExpenses,
OperationalExpensesTotal: operationalTotal,
TotalCost: totalCost,
NetProfit: netProfit,
},
Reimburse: models.ExclusiveSummaryReimburse{
TotalCost: totalCost,
ExcludedSalaryStaff: excludedSalaryStaff,
TotalReimburse: totalCost - excludedSalaryStaff,
},
HPPBreakdown: hppBreakdown,
OperationalExpenseBreakdown: operationalBreakdown,
DailySummary: dailySummary,
DailyTransactions: dailyTransactions,
}, nil
}
func exclusiveSummaryCategoryBreakdown(items []entities.ExclusiveSummaryCategoryTotal) ([]models.ExclusiveSummaryCategoryBreakdown, float64) {
var total float64
for _, item := range items {
total += item.Amount
}
breakdown := make([]models.ExclusiveSummaryCategoryBreakdown, len(items))
for i, item := range items {
breakdown[i] = models.ExclusiveSummaryCategoryBreakdown{
CategoryCode: item.CategoryCode,
CategoryName: item.CategoryName,
Amount: item.Amount,
Percentage: percentage(item.Amount, total),
}
}
return breakdown, total
}
func exclusiveSummarySalaryBreakdown(transactions []entities.ExclusiveSummaryDailyTransaction) (float64, float64, float64) {
var salaryDW float64
var salaryStaff float64
var salaryOther float64
for _, transaction := range transactions {
if !isExclusiveSummarySalary(transaction.CategoryCode, transaction.CategoryName, transaction.Description) {
continue
}
classification := strings.ToLower(transaction.CategoryCode + " " + transaction.CategoryName + " " + transaction.Description)
switch {
case strings.Contains(classification, "staff") || strings.Contains(classification, "kary") || strings.Contains(classification, "karyawan"):
salaryStaff += transaction.Amount
case strings.Contains(classification, "dw"):
salaryDW += transaction.Amount
default:
salaryOther += transaction.Amount
}
}
return salaryDW, salaryStaff, salaryOther
}
func isExclusiveSummarySalary(parts ...string) bool {
text := strings.ToLower(strings.Join(parts, " "))
return strings.Contains(text, "gaji") || strings.Contains(text, "salary")
}
func percentage(numerator, denominator float64) float64 {
if denominator == 0 {
return 0
}
return (numerator / denominator) * 100
}
type exclusiveSummaryMonthlyBucket struct {
Label string
DateFrom time.Time
DateTo time.Time
}
func buildExclusiveSummaryMonthlyBuckets(monthStart time.Time) []exclusiveSummaryMonthlyBucket {
monthEnd := monthStart.AddDate(0, 1, 0).Add(-time.Nanosecond)
buckets := make([]exclusiveSummaryMonthlyBucket, 0, 6)
currentStart := monthStart
for !currentStart.After(monthEnd) {
currentEnd := currentStart
for currentEnd.Weekday() != time.Sunday && currentEnd.Day() < monthEnd.Day() {
currentEnd = currentEnd.AddDate(0, 0, 1)
}
bucketEnd := time.Date(currentEnd.Year(), currentEnd.Month(), currentEnd.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), currentEnd.Location())
if bucketEnd.After(monthEnd) {
bucketEnd = monthEnd
}
buckets = append(buckets, exclusiveSummaryMonthlyBucket{
Label: fmt.Sprintf("%d - %d %s", currentStart.Day(), bucketEnd.Day(), indonesianMonthName(currentStart.Month())),
DateFrom: currentStart,
DateTo: bucketEnd,
})
currentStart = time.Date(bucketEnd.Year(), bucketEnd.Month(), bucketEnd.Day(), 0, 0, 0, 0, bucketEnd.Location()).AddDate(0, 0, 1)
}
return buckets
}
func indonesianMonthName(month time.Month) string {
names := map[time.Month]string{
time.January: "Januari",
time.February: "Februari",
time.March: "Maret",
time.April: "April",
time.May: "Mei",
time.June: "Juni",
time.July: "Juli",
time.August: "Agustus",
time.September: "September",
time.October: "Oktober",
time.November: "November",
time.December: "Desember",
}
return names[month]
}