933 lines
32 KiB
Go
933 lines
32 KiB
Go
package processor
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"apskel-pos-be/internal/entities"
|
|
"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)
|
|
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,
|
|
}
|
|
}
|
|
|
|
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,
|
|
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,
|
|
}
|
|
}
|
|
|
|
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,
|
|
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,
|
|
},
|
|
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,
|
|
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,
|
|
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)
|
|
}
|
|
|
|
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,
|
|
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,
|
|
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]
|
|
}
|