apskel-pos-backend/internal/processor/analytics_processor.go
2026-06-03 13:00:50 +07:00

645 lines
21 KiB
Go

package processor
import (
"context"
"fmt"
"strings"
"time"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
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)
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error)
}
type AnalyticsProcessorImpl struct {
analyticsRepo repository.AnalyticsRepository
expenseRepo ExpenseRepository
}
func NewAnalyticsProcessorImpl(analyticsRepo repository.AnalyticsRepository, expenseRepo ExpenseRepository) *AnalyticsProcessorImpl {
return &AnalyticsProcessorImpl{
analyticsRepo: analyticsRepo,
expenseRepo: expenseRepo,
}
}
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,
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,
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"
}
result, err := p.analyticsRepo.GetPurchasingAnalytics(ctx, req.OrganizationID, req.OutletID, 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,
PurchaseOrders: item.PurchaseOrders,
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,
}
}
return &models.PurchasingAnalyticsResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: result.OutletName,
DateFrom: req.DateFrom,
DateTo: req.DateTo,
GroupBy: req.GroupBy,
Summary: models.PurchasingSummary{
TotalPurchases: result.Summary.TotalPurchases,
TotalPurchaseOrders: result.Summary.TotalPurchaseOrders,
TotalQuantity: result.Summary.TotalQuantity,
AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue,
TotalIngredients: result.Summary.TotalIngredients,
TotalVendors: result.Summary.TotalVendors,
},
Data: data,
IngredientData: ingredientData,
VendorData: vendorData,
}, 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,
CategoryID: data.CategoryID,
CategoryName: data.CategoryName,
CategoryOrder: data.CategoryOrder,
QuantitySold: data.QuantitySold,
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,
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,
DateFrom: req.DateFrom,
DateTo: req.DateTo,
Data: resultData,
}, 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,
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,
},
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
}
return &models.ProfitLossAnalyticsResponse{
OrganizationID: req.OrganizationID,
OutletID: 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,
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)
}