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>
1241 lines
44 KiB
Go
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]
|
|
}
|