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, 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, 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] }