Update profit-loss
This commit is contained in:
parent
b8be29e110
commit
024d9ee637
@ -359,7 +359,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
|||||||
paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo),
|
paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo),
|
||||||
fileProcessor: processor.NewFileProcessorImpl(repos.fileRepo, fileClient),
|
fileProcessor: processor.NewFileProcessorImpl(repos.fileRepo, fileClient),
|
||||||
customerProcessor: processor.NewCustomerProcessor(repos.customerRepo),
|
customerProcessor: processor.NewCustomerProcessor(repos.customerRepo),
|
||||||
analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo),
|
analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo, repos.expenseRepo),
|
||||||
tableProcessor: processor.NewTableProcessor(repos.tableRepo, repos.orderRepo),
|
tableProcessor: processor.NewTableProcessor(repos.tableRepo, repos.orderRepo),
|
||||||
unitProcessor: processor.NewUnitProcessor(repos.unitRepo),
|
unitProcessor: processor.NewUnitProcessor(repos.unitRepo),
|
||||||
ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo),
|
ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo),
|
||||||
|
|||||||
@ -236,68 +236,33 @@ type DashboardOverview struct {
|
|||||||
RefundedOrders int64 `json:"refunded_orders"`
|
RefundedOrders int64 `json:"refunded_orders"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProfitLossAnalyticsRequest represents the request for profit and loss analytics
|
|
||||||
type ProfitLossAnalyticsRequest struct {
|
type ProfitLossAnalyticsRequest struct {
|
||||||
OrganizationID uuid.UUID
|
OrganizationID uuid.UUID
|
||||||
OutletID *string `form:"outlet_id,omitempty"`
|
OutletID *string `form:"outlet_id,omitempty"`
|
||||||
DateFrom string `form:"date_from" validate:"required"`
|
Date string `form:"date" validate:"required"`
|
||||||
DateTo string `form:"date_to" validate:"required"`
|
|
||||||
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProfitLossAnalyticsResponse represents the response for profit and loss analytics
|
|
||||||
type ProfitLossAnalyticsResponse struct {
|
type ProfitLossAnalyticsResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
Date time.Time `json:"date"`
|
||||||
DateTo time.Time `json:"date_to"`
|
MainSummary []ProfitLossSummaryRow `json:"main_summary"`
|
||||||
GroupBy string `json:"group_by"`
|
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
|
||||||
Summary ProfitLossSummary `json:"summary"`
|
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
||||||
Data []ProfitLossData `json:"data"`
|
|
||||||
ProductData []ProductProfitData `json:"product_data"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProfitLossSummary represents the summary of profit and loss analytics
|
type ProfitLossSummaryRow struct {
|
||||||
type ProfitLossSummary struct {
|
ID string `json:"id"`
|
||||||
TotalRevenue float64 `json:"total_revenue"`
|
Label string `json:"label"`
|
||||||
TotalCost float64 `json:"total_cost"`
|
IsBold bool `json:"is_bold"`
|
||||||
GrossProfit float64 `json:"gross_profit"`
|
TodayNominal float64 `json:"today_nominal"`
|
||||||
GrossProfitMargin float64 `json:"gross_profit_margin"`
|
TodayPct float64 `json:"today_pct"`
|
||||||
TotalTax float64 `json:"total_tax"`
|
MtdNominal float64 `json:"mtd_nominal"`
|
||||||
TotalDiscount float64 `json:"total_discount"`
|
MtdPct float64 `json:"mtd_pct"`
|
||||||
NetProfit float64 `json:"net_profit"`
|
SubItems []ProfitLossSummaryRow `json:"sub_items,omitempty"`
|
||||||
NetProfitMargin float64 `json:"net_profit_margin"`
|
|
||||||
TotalOrders int64 `json:"total_orders"`
|
|
||||||
AverageProfit float64 `json:"average_profit"`
|
|
||||||
ProfitabilityRatio float64 `json:"profitability_ratio"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProfitLossData represents individual profit and loss data point by time period
|
type OperationalExpenseItem struct {
|
||||||
type ProfitLossData struct {
|
Item string `json:"item"`
|
||||||
Date time.Time `json:"date"`
|
Nominal float64 `json:"nominal"`
|
||||||
Revenue float64 `json:"revenue"`
|
|
||||||
Cost float64 `json:"cost"`
|
|
||||||
GrossProfit float64 `json:"gross_profit"`
|
|
||||||
GrossProfitMargin float64 `json:"gross_profit_margin"`
|
|
||||||
Tax float64 `json:"tax"`
|
|
||||||
Discount float64 `json:"discount"`
|
|
||||||
NetProfit float64 `json:"net_profit"`
|
|
||||||
NetProfitMargin float64 `json:"net_profit_margin"`
|
|
||||||
Orders int64 `json:"orders"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProductProfitData represents profit data for individual products
|
|
||||||
type ProductProfitData struct {
|
|
||||||
ProductID uuid.UUID `json:"product_id"`
|
|
||||||
ProductName string `json:"product_name"`
|
|
||||||
CategoryID uuid.UUID `json:"category_id"`
|
|
||||||
CategoryName string `json:"category_name"`
|
|
||||||
QuantitySold int64 `json:"quantity_sold"`
|
|
||||||
Revenue float64 `json:"revenue"`
|
|
||||||
Cost float64 `json:"cost"`
|
|
||||||
GrossProfit float64 `json:"gross_profit"`
|
|
||||||
GrossProfitMargin float64 `json:"gross_profit_margin"`
|
|
||||||
AveragePrice float64 `json:"average_price"`
|
|
||||||
AverageCost float64 `json:"average_cost"`
|
|
||||||
ProfitPerUnit float64 `json:"profit_per_unit"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -113,54 +113,22 @@ type DashboardOverview struct {
|
|||||||
RefundedOrders int64 `json:"refunded_orders"`
|
RefundedOrders int64 `json:"refunded_orders"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProfitLossAnalytics represents profit and loss analytics data
|
|
||||||
type ProfitLossAnalytics struct {
|
type ProfitLossAnalytics struct {
|
||||||
Summary ProfitLossSummary `json:"summary"`
|
TodayRevenue float64
|
||||||
Data []ProfitLossData `json:"data"`
|
TodayCost float64
|
||||||
ProductData []ProductProfitData `json:"product_data"`
|
MtdRevenue float64
|
||||||
|
MtdCost float64
|
||||||
|
TodayExpenseByCategory []ExpenseCategoryTotal
|
||||||
|
MtdExpenseByCategory []ExpenseCategoryTotal
|
||||||
|
OperationalExpenseItems []OperationalExpenseItem
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProfitLossSummary represents profit and loss summary data
|
type ExpenseCategoryTotal struct {
|
||||||
type ProfitLossSummary struct {
|
CategoryName string
|
||||||
TotalRevenue float64 `json:"total_revenue"`
|
Amount float64
|
||||||
TotalCost float64 `json:"total_cost"`
|
|
||||||
GrossProfit float64 `json:"gross_profit"`
|
|
||||||
GrossProfitMargin float64 `json:"gross_profit_margin"`
|
|
||||||
TotalTax float64 `json:"total_tax"`
|
|
||||||
TotalDiscount float64 `json:"total_discount"`
|
|
||||||
NetProfit float64 `json:"net_profit"`
|
|
||||||
NetProfitMargin float64 `json:"net_profit_margin"`
|
|
||||||
TotalOrders int64 `json:"total_orders"`
|
|
||||||
AverageProfit float64 `json:"average_profit"`
|
|
||||||
ProfitabilityRatio float64 `json:"profitability_ratio"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProfitLossData represents profit and loss data by time period
|
type OperationalExpenseItem struct {
|
||||||
type ProfitLossData struct {
|
Description string
|
||||||
Date time.Time `json:"date"`
|
Amount float64
|
||||||
Revenue float64 `json:"revenue"`
|
|
||||||
Cost float64 `json:"cost"`
|
|
||||||
GrossProfit float64 `json:"gross_profit"`
|
|
||||||
GrossProfitMargin float64 `json:"gross_profit_margin"`
|
|
||||||
Tax float64 `json:"tax"`
|
|
||||||
Discount float64 `json:"discount"`
|
|
||||||
NetProfit float64 `json:"net_profit"`
|
|
||||||
NetProfitMargin float64 `json:"net_profit_margin"`
|
|
||||||
Orders int64 `json:"orders"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProductProfitData represents profit data for individual products
|
|
||||||
type ProductProfitData struct {
|
|
||||||
ProductID uuid.UUID `json:"product_id"`
|
|
||||||
ProductName string `json:"product_name"`
|
|
||||||
CategoryID uuid.UUID `json:"category_id"`
|
|
||||||
CategoryName string `json:"category_name"`
|
|
||||||
QuantitySold int64 `json:"quantity_sold"`
|
|
||||||
Revenue float64 `json:"revenue"`
|
|
||||||
Cost float64 `json:"cost"`
|
|
||||||
GrossProfit float64 `json:"gross_profit"`
|
|
||||||
GrossProfitMargin float64 `json:"gross_profit_margin"`
|
|
||||||
AveragePrice float64 `json:"average_price"`
|
|
||||||
AverageCost float64 `json:"average_cost"`
|
|
||||||
ProfitPerUnit float64 `json:"profit_per_unit"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -246,68 +246,33 @@ type DashboardOverview struct {
|
|||||||
RefundedOrders int64 `json:"refunded_orders"`
|
RefundedOrders int64 `json:"refunded_orders"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProfitLossAnalyticsRequest represents the request for profit and loss analytics
|
|
||||||
type ProfitLossAnalyticsRequest struct {
|
type ProfitLossAnalyticsRequest struct {
|
||||||
OrganizationID uuid.UUID `validate:"required"`
|
OrganizationID uuid.UUID `validate:"required"`
|
||||||
OutletID *uuid.UUID `validate:"omitempty"`
|
OutletID *uuid.UUID `validate:"omitempty"`
|
||||||
DateFrom time.Time `validate:"required"`
|
Date time.Time `validate:"required"`
|
||||||
DateTo time.Time `validate:"required"`
|
|
||||||
GroupBy string `validate:"omitempty,oneof=day hour week month"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProfitLossAnalyticsResponse represents the response for profit and loss analytics
|
|
||||||
type ProfitLossAnalyticsResponse struct {
|
type ProfitLossAnalyticsResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
Date time.Time `json:"date"`
|
||||||
DateTo time.Time `json:"date_to"`
|
MainSummary []ProfitLossSummaryRow `json:"main_summary"`
|
||||||
GroupBy string `json:"group_by"`
|
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
|
||||||
Summary ProfitLossSummary `json:"summary"`
|
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
||||||
Data []ProfitLossData `json:"data"`
|
|
||||||
ProductData []ProductProfitData `json:"product_data"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProfitLossSummary represents the summary of profit and loss analytics
|
type ProfitLossSummaryRow struct {
|
||||||
type ProfitLossSummary struct {
|
ID string `json:"id"`
|
||||||
TotalRevenue float64 `json:"total_revenue"`
|
Label string `json:"label"`
|
||||||
TotalCost float64 `json:"total_cost"`
|
IsBold bool `json:"is_bold"`
|
||||||
GrossProfit float64 `json:"gross_profit"`
|
TodayNominal float64 `json:"today_nominal"`
|
||||||
GrossProfitMargin float64 `json:"gross_profit_margin"`
|
TodayPct float64 `json:"today_pct"`
|
||||||
TotalTax float64 `json:"total_tax"`
|
MtdNominal float64 `json:"mtd_nominal"`
|
||||||
TotalDiscount float64 `json:"total_discount"`
|
MtdPct float64 `json:"mtd_pct"`
|
||||||
NetProfit float64 `json:"net_profit"`
|
SubItems []ProfitLossSummaryRow `json:"sub_items,omitempty"`
|
||||||
NetProfitMargin float64 `json:"net_profit_margin"`
|
|
||||||
TotalOrders int64 `json:"total_orders"`
|
|
||||||
AverageProfit float64 `json:"average_profit"`
|
|
||||||
ProfitabilityRatio float64 `json:"profitability_ratio"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProfitLossData represents individual profit and loss data point by time period
|
type OperationalExpenseItem struct {
|
||||||
type ProfitLossData struct {
|
Item string `json:"item"`
|
||||||
Date time.Time `json:"date"`
|
Nominal float64 `json:"nominal"`
|
||||||
Revenue float64 `json:"revenue"`
|
|
||||||
Cost float64 `json:"cost"`
|
|
||||||
GrossProfit float64 `json:"gross_profit"`
|
|
||||||
GrossProfitMargin float64 `json:"gross_profit_margin"`
|
|
||||||
Tax float64 `json:"tax"`
|
|
||||||
Discount float64 `json:"discount"`
|
|
||||||
NetProfit float64 `json:"net_profit"`
|
|
||||||
NetProfitMargin float64 `json:"net_profit_margin"`
|
|
||||||
Orders int64 `json:"orders"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProductProfitData represents profit data for individual products
|
|
||||||
type ProductProfitData struct {
|
|
||||||
ProductID uuid.UUID `json:"product_id"`
|
|
||||||
ProductName string `json:"product_name"`
|
|
||||||
CategoryID uuid.UUID `json:"category_id"`
|
|
||||||
CategoryName string `json:"category_name"`
|
|
||||||
QuantitySold int64 `json:"quantity_sold"`
|
|
||||||
Revenue float64 `json:"revenue"`
|
|
||||||
Cost float64 `json:"cost"`
|
|
||||||
GrossProfit float64 `json:"gross_profit"`
|
|
||||||
GrossProfitMargin float64 `json:"gross_profit_margin"`
|
|
||||||
AveragePrice float64 `json:"average_price"`
|
|
||||||
AverageCost float64 `json:"average_cost"`
|
|
||||||
ProfitPerUnit float64 `json:"profit_per_unit"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,10 @@ package processor
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/entities"
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
"apskel-pos-be/internal/repository"
|
"apskel-pos-be/internal/repository"
|
||||||
)
|
)
|
||||||
@ -21,11 +23,13 @@ type AnalyticsProcessor interface {
|
|||||||
|
|
||||||
type AnalyticsProcessorImpl struct {
|
type AnalyticsProcessorImpl struct {
|
||||||
analyticsRepo repository.AnalyticsRepository
|
analyticsRepo repository.AnalyticsRepository
|
||||||
|
expenseRepo ExpenseRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAnalyticsProcessorImpl(analyticsRepo repository.AnalyticsRepository) *AnalyticsProcessorImpl {
|
func NewAnalyticsProcessorImpl(analyticsRepo repository.AnalyticsRepository, expenseRepo ExpenseRepository) *AnalyticsProcessorImpl {
|
||||||
return &AnalyticsProcessorImpl{
|
return &AnalyticsProcessorImpl{
|
||||||
analyticsRepo: analyticsRepo,
|
analyticsRepo: analyticsRepo,
|
||||||
|
expenseRepo: expenseRepo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -394,71 +398,127 @@ func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) {
|
func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) {
|
||||||
if req.DateFrom.After(req.DateTo) {
|
if req.Date.IsZero() {
|
||||||
return nil, fmt.Errorf("date_from cannot be after date_to")
|
return nil, fmt.Errorf("date is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get analytics data from repository
|
result, err := p.analyticsRepo.GetProfitLossAnalytics(ctx, req.OrganizationID, req.OutletID, req.Date)
|
||||||
result, err := p.analyticsRepo.GetProfitLossAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get profit/loss analytics: %w", err)
|
return nil, fmt.Errorf("failed to get profit/loss analytics: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform entities to models
|
todayPromosi := getExpenseAmountByCategory(result.TodayExpenseByCategory, "promosi")
|
||||||
data := make([]models.ProfitLossData, len(result.Data))
|
todayLainLain := getExpenseAmountByCategory(result.TodayExpenseByCategory, "lain")
|
||||||
for i, item := range result.Data {
|
todayTotalOps := todayPromosi + todayLainLain
|
||||||
data[i] = models.ProfitLossData{
|
todayGaji := getExpenseAmountByCategory(result.TodayExpenseByCategory, "gaji")
|
||||||
Date: item.Date,
|
|
||||||
Revenue: item.Revenue,
|
mtdPromosi := getExpenseAmountByCategory(result.MtdExpenseByCategory, "promosi")
|
||||||
Cost: item.Cost,
|
mtdLainLain := getExpenseAmountByCategory(result.MtdExpenseByCategory, "lain")
|
||||||
GrossProfit: item.GrossProfit,
|
mtdTotalOps := mtdPromosi + mtdLainLain
|
||||||
GrossProfitMargin: item.GrossProfitMargin,
|
mtdGaji := getExpenseAmountByCategory(result.MtdExpenseByCategory, "gaji")
|
||||||
Tax: item.Tax,
|
|
||||||
Discount: item.Discount,
|
todayGrossProfit := result.TodayRevenue - result.TodayCost
|
||||||
NetProfit: item.NetProfit,
|
mtdGrossProfit := result.MtdRevenue - result.MtdCost
|
||||||
NetProfitMargin: item.NetProfitMargin,
|
|
||||||
Orders: item.Orders,
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
productData := make([]models.ProductProfitData, len(result.ProductData))
|
mainSummary := []models.ProfitLossSummaryRow{
|
||||||
for i, item := range result.ProductData {
|
{
|
||||||
productData[i] = models.ProductProfitData{
|
ID: "total_omset", Label: "TOTAL OMSET",
|
||||||
ProductID: item.ProductID,
|
TodayNominal: result.TodayRevenue, TodayPct: todayPct(result.TodayRevenue),
|
||||||
ProductName: item.ProductName,
|
MtdNominal: result.MtdRevenue, MtdPct: mtdPct(result.MtdRevenue),
|
||||||
CategoryID: item.CategoryID,
|
},
|
||||||
CategoryName: item.CategoryName,
|
{
|
||||||
QuantitySold: item.QuantitySold,
|
ID: "hpp", Label: "HPP",
|
||||||
Revenue: item.Revenue,
|
TodayNominal: result.TodayCost, TodayPct: todayPct(result.TodayCost),
|
||||||
Cost: item.Cost,
|
MtdNominal: result.MtdCost, MtdPct: mtdPct(result.MtdCost),
|
||||||
GrossProfit: item.GrossProfit,
|
},
|
||||||
GrossProfitMargin: item.GrossProfitMargin,
|
{
|
||||||
AveragePrice: item.AveragePrice,
|
ID: "laba_kotor", Label: "Laba Kotor (1-2)",
|
||||||
AverageCost: item.AverageCost,
|
TodayNominal: todayGrossProfit, TodayPct: todayPct(todayGrossProfit),
|
||||||
ProfitPerUnit: item.ProfitPerUnit,
|
MtdNominal: mtdGrossProfit, MtdPct: mtdPct(mtdGrossProfit),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "biaya_ops", Label: "BIAYA OPS",
|
||||||
|
TodayNominal: todayTotalOps, TodayPct: todayPct(todayTotalOps),
|
||||||
|
MtdNominal: mtdTotalOps, MtdPct: mtdPct(mtdTotalOps),
|
||||||
|
SubItems: []models.ProfitLossSummaryRow{
|
||||||
|
{
|
||||||
|
ID: "by_promosi", Label: "1. By Promosi",
|
||||||
|
TodayNominal: todayPromosi, TodayPct: todayPct(todayPromosi),
|
||||||
|
MtdNominal: mtdPromosi, MtdPct: mtdPct(mtdPromosi),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "by_lain_lain", Label: "2. By Lain lain",
|
||||||
|
TodayNominal: todayLainLain, TodayPct: todayPct(todayLainLain),
|
||||||
|
MtdNominal: mtdLainLain, MtdPct: mtdPct(mtdLainLain),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "total_biaya_ops", Label: "Total Biaya OPS (4.1+4.2)", IsBold: true,
|
||||||
|
TodayNominal: todayTotalOps, TodayPct: todayPct(todayTotalOps),
|
||||||
|
MtdNominal: mtdTotalOps, MtdPct: mtdPct(mtdTotalOps),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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.Description,
|
||||||
|
Nominal: item.Amount,
|
||||||
}
|
}
|
||||||
|
opsTotal += item.Amount
|
||||||
}
|
}
|
||||||
|
|
||||||
return &models.ProfitLossAnalyticsResponse{
|
return &models.ProfitLossAnalyticsResponse{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
DateFrom: req.DateFrom,
|
Date: req.Date,
|
||||||
DateTo: req.DateTo,
|
MainSummary: mainSummary,
|
||||||
GroupBy: req.GroupBy,
|
OperationalExpenses: opsItems,
|
||||||
Summary: models.ProfitLossSummary{
|
OperationalExpensesTotal: opsTotal,
|
||||||
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,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getExpenseAmountByCategory(categories []entities.ExpenseCategoryTotal, keyword string) float64 {
|
||||||
|
for _, cat := range categories {
|
||||||
|
if strings.Contains(strings.ToLower(cat.CategoryName), keyword) {
|
||||||
|
return cat.Amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|||||||
@ -40,10 +40,27 @@ func (analyticsRepositoryStub) GetDashboardOverview(context.Context, uuid.UUID,
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (analyticsRepositoryStub) GetProfitLossAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.ProfitLossAnalytics, error) {
|
func (analyticsRepositoryStub) GetProfitLossAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time) (*entities.ProfitLossAnalytics, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type expenseRepositoryStub struct{}
|
||||||
|
|
||||||
|
func (expenseRepositoryStub) Create(context.Context, *entities.Expense) error { return nil }
|
||||||
|
func (expenseRepositoryStub) GetByID(context.Context, uuid.UUID) (*entities.Expense, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (expenseRepositoryStub) GetByIDAndOrganizationID(context.Context, uuid.UUID, uuid.UUID) (*entities.Expense, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (expenseRepositoryStub) Update(context.Context, *entities.Expense) error { return nil }
|
||||||
|
func (expenseRepositoryStub) Delete(context.Context, uuid.UUID) error { return nil }
|
||||||
|
func (expenseRepositoryStub) List(context.Context, uuid.UUID, map[string]interface{}, int, int) ([]*entities.Expense, int64, error) {
|
||||||
|
return nil, 0, nil
|
||||||
|
}
|
||||||
|
func (expenseRepositoryStub) CreateItem(context.Context, *entities.ExpenseItem) error { return nil }
|
||||||
|
func (expenseRepositoryStub) DeleteItemsByExpenseID(context.Context, uuid.UUID) error { return nil }
|
||||||
|
|
||||||
func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T) {
|
func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T) {
|
||||||
outletID := uuid.New()
|
outletID := uuid.New()
|
||||||
outletName := "Main Outlet"
|
outletName := "Main Outlet"
|
||||||
@ -55,7 +72,7 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T)
|
|||||||
TotalPurchases: 125,
|
TotalPurchases: 125,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
}, expenseRepositoryStub{})
|
||||||
|
|
||||||
result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{
|
result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{
|
||||||
OrganizationID: uuid.New(),
|
OrganizationID: uuid.New(),
|
||||||
|
|||||||
@ -17,7 +17,7 @@ type AnalyticsRepository interface {
|
|||||||
GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error)
|
GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error)
|
||||||
GetProductAnalyticsPerCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerCategory, error)
|
GetProductAnalyticsPerCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerCategory, error)
|
||||||
GetDashboardOverview(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.DashboardOverview, error)
|
GetDashboardOverview(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.DashboardOverview, error)
|
||||||
GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ProfitLossAnalytics, error)
|
GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, date time.Time) (*entities.ProfitLossAnalytics, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnalyticsRepositoryImpl struct {
|
type AnalyticsRepositoryImpl struct {
|
||||||
@ -432,152 +432,119 @@ func (r *AnalyticsRepositoryImpl) GetDashboardOverview(ctx context.Context, orga
|
|||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ProfitLossAnalytics, error) {
|
func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, date time.Time) (*entities.ProfitLossAnalytics, error) {
|
||||||
// Summary query
|
mtdStart := time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location())
|
||||||
var summary entities.ProfitLossSummary
|
todayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
|
||||||
|
todayEnd := todayStart.Add(24 * time.Hour).Add(-time.Nanosecond)
|
||||||
|
|
||||||
summaryQuery := r.db.WithContext(ctx).
|
type revenueCostResult struct {
|
||||||
Table("orders o").
|
Revenue float64
|
||||||
Select(`
|
Cost float64
|
||||||
COALESCE(SUM(o.total_amount), 0) as total_revenue,
|
|
||||||
COALESCE(SUM(o.total_cost), 0) as total_cost,
|
|
||||||
COALESCE(SUM(o.total_amount - o.total_cost), 0) as gross_profit,
|
|
||||||
CASE
|
|
||||||
WHEN SUM(o.total_amount) > 0
|
|
||||||
THEN (SUM(o.total_amount - o.total_cost) / SUM(o.total_amount)) * 100
|
|
||||||
ELSE 0
|
|
||||||
END as gross_profit_margin,
|
|
||||||
COALESCE(SUM(o.tax_amount), 0) as total_tax,
|
|
||||||
COALESCE(SUM(o.discount_amount), 0) as total_discount,
|
|
||||||
COALESCE(SUM(o.total_amount - o.total_cost - o.discount_amount), 0) as net_profit,
|
|
||||||
CASE
|
|
||||||
WHEN SUM(o.total_amount) > 0
|
|
||||||
THEN (SUM(o.total_amount - o.total_cost - o.discount_amount) / SUM(o.total_amount)) * 100
|
|
||||||
ELSE 0
|
|
||||||
END as net_profit_margin,
|
|
||||||
COUNT(o.id) as total_orders,
|
|
||||||
CASE
|
|
||||||
WHEN COUNT(o.id) > 0
|
|
||||||
THEN SUM(o.total_amount - o.total_cost - o.discount_amount) / COUNT(o.id)
|
|
||||||
ELSE 0
|
|
||||||
END as average_profit,
|
|
||||||
CASE
|
|
||||||
WHEN SUM(o.total_cost) > 0
|
|
||||||
THEN (SUM(o.total_amount - o.total_cost) / SUM(o.total_cost)) * 100
|
|
||||||
ELSE 0
|
|
||||||
END as profitability_ratio
|
|
||||||
`).
|
|
||||||
Where("o.organization_id = ?", organizationID).
|
|
||||||
Where("o.status = ?", entities.OrderStatusCompleted).
|
|
||||||
Where("o.payment_status = ?", entities.PaymentStatusCompleted).
|
|
||||||
Where("o.is_void = false AND o.is_refund = false").
|
|
||||||
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo)
|
|
||||||
|
|
||||||
summaryQuery = r.resolveOutletID(summaryQuery, outletID, "o.outlet_id")
|
|
||||||
|
|
||||||
err := summaryQuery.Scan(&summary).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Time series data query
|
var todayRC revenueCostResult
|
||||||
var timeFormat string
|
todayQuery := r.db.WithContext(ctx).
|
||||||
switch groupBy {
|
|
||||||
case "hour":
|
|
||||||
timeFormat = "DATE_TRUNC('hour', o.created_at)"
|
|
||||||
case "week":
|
|
||||||
timeFormat = "DATE_TRUNC('week', o.created_at)"
|
|
||||||
case "month":
|
|
||||||
timeFormat = "DATE_TRUNC('month', o.created_at)"
|
|
||||||
default: // day
|
|
||||||
timeFormat = "DATE_TRUNC('day', o.created_at)"
|
|
||||||
}
|
|
||||||
|
|
||||||
var data []entities.ProfitLossData
|
|
||||||
|
|
||||||
dataQuery := r.db.WithContext(ctx).
|
|
||||||
Table("orders o").
|
Table("orders o").
|
||||||
Select(`
|
Select(`
|
||||||
`+timeFormat+` as date,
|
|
||||||
COALESCE(SUM(o.total_amount), 0) as revenue,
|
COALESCE(SUM(o.total_amount), 0) as revenue,
|
||||||
COALESCE(SUM(o.total_cost), 0) as cost,
|
COALESCE(SUM(o.total_cost), 0) as cost
|
||||||
COALESCE(SUM(o.total_amount - o.total_cost), 0) as gross_profit,
|
|
||||||
CASE
|
|
||||||
WHEN SUM(o.total_amount) > 0
|
|
||||||
THEN (SUM(o.total_amount - o.total_cost) / SUM(o.total_amount)) * 100
|
|
||||||
ELSE 0
|
|
||||||
END as gross_profit_margin,
|
|
||||||
COALESCE(SUM(o.tax_amount), 0) as tax,
|
|
||||||
COALESCE(SUM(o.discount_amount), 0) as discount,
|
|
||||||
COALESCE(SUM(o.total_amount - o.total_cost - o.discount_amount), 0) as net_profit,
|
|
||||||
CASE
|
|
||||||
WHEN SUM(o.total_amount) > 0
|
|
||||||
THEN (SUM(o.total_amount - o.total_cost - o.discount_amount) / SUM(o.total_amount)) * 100
|
|
||||||
ELSE 0
|
|
||||||
END as net_profit_margin,
|
|
||||||
COUNT(o.id) as orders
|
|
||||||
`).
|
`).
|
||||||
Where("o.organization_id = ?", organizationID).
|
Where("o.organization_id = ?", organizationID).
|
||||||
Where("o.status = ?", entities.OrderStatusCompleted).
|
Where("o.status = ?", entities.OrderStatusCompleted).
|
||||||
Where("o.payment_status = ?", entities.PaymentStatusCompleted).
|
Where("o.payment_status = ?", entities.PaymentStatusCompleted).
|
||||||
Where("o.is_void = false AND o.is_refund = false").
|
Where("o.is_void = false AND o.is_refund = false").
|
||||||
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo).
|
Where("o.created_at >= ? AND o.created_at <= ?", todayStart, todayEnd)
|
||||||
Group(timeFormat).
|
todayQuery = r.resolveOutletID(todayQuery, outletID, "o.outlet_id")
|
||||||
Order(timeFormat)
|
if err := todayQuery.Scan(&todayRC).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
dataQuery = r.resolveOutletID(dataQuery, outletID, "o.outlet_id")
|
var mtdRC revenueCostResult
|
||||||
|
mtdQuery := r.db.WithContext(ctx).
|
||||||
|
Table("orders o").
|
||||||
|
Select(`
|
||||||
|
COALESCE(SUM(o.total_amount), 0) as revenue,
|
||||||
|
COALESCE(SUM(o.total_cost), 0) as cost
|
||||||
|
`).
|
||||||
|
Where("o.organization_id = ?", organizationID).
|
||||||
|
Where("o.status = ?", entities.OrderStatusCompleted).
|
||||||
|
Where("o.payment_status = ?", entities.PaymentStatusCompleted).
|
||||||
|
Where("o.is_void = false AND o.is_refund = false").
|
||||||
|
Where("o.created_at >= ? AND o.created_at <= ?", mtdStart, todayEnd)
|
||||||
|
mtdQuery = r.resolveOutletID(mtdQuery, outletID, "o.outlet_id")
|
||||||
|
if err := mtdQuery.Scan(&mtdRC).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
err = dataQuery.Scan(&data).Error
|
todayExpenseByCategory, err := r.getExpenseByCategory(ctx, organizationID, outletID, todayStart, todayEnd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Product profit data query
|
mtdExpenseByCategory, err := r.getExpenseByCategory(ctx, organizationID, outletID, mtdStart, todayEnd)
|
||||||
var productData []entities.ProductProfitData
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
productQuery := r.db.WithContext(ctx).
|
opsItems, err := r.getOperationalExpenseItems(ctx, organizationID, outletID, mtdStart, todayEnd)
|
||||||
Table("order_items oi").
|
|
||||||
Select(`
|
|
||||||
p.id as product_id,
|
|
||||||
p.name as product_name,
|
|
||||||
c.id as category_id,
|
|
||||||
c.name as category_name,
|
|
||||||
SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END) as quantity_sold,
|
|
||||||
SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END) as revenue,
|
|
||||||
SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END) as cost,
|
|
||||||
SUM(CASE WHEN oi.is_fully_refunded = false THEN (oi.total_price - COALESCE(oi.refund_amount, 0)) - (oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0))) ELSE 0 END) as gross_profit,
|
|
||||||
CASE
|
|
||||||
WHEN SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END) > 0
|
|
||||||
THEN (SUM(CASE WHEN oi.is_fully_refunded = false THEN (oi.total_price - COALESCE(oi.refund_amount, 0)) - (oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0))) ELSE 0 END) / SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END)) * 100
|
|
||||||
ELSE 0
|
|
||||||
END as gross_profit_margin,
|
|
||||||
AVG(CASE WHEN oi.is_fully_refunded = false THEN oi.unit_price ELSE NULL END) as average_price,
|
|
||||||
AVG(CASE WHEN oi.is_fully_refunded = false THEN oi.unit_cost ELSE NULL END) as average_cost,
|
|
||||||
AVG(CASE WHEN oi.is_fully_refunded = false THEN oi.unit_price - oi.unit_cost ELSE NULL END) as profit_per_unit
|
|
||||||
`).
|
|
||||||
Joins("JOIN orders o ON oi.order_id = o.id").
|
|
||||||
Joins("JOIN products p ON oi.product_id = p.id").
|
|
||||||
Joins("JOIN categories c ON p.category_id = c.id").
|
|
||||||
Where("o.organization_id = ?", organizationID).
|
|
||||||
Where("o.status = ?", entities.OrderStatusCompleted).
|
|
||||||
Where("o.payment_status = ?", entities.PaymentStatusCompleted).
|
|
||||||
Where("o.is_void = false AND o.is_refund = false").
|
|
||||||
Where("oi.status != ?", entities.OrderItemStatusCancelled).
|
|
||||||
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo).
|
|
||||||
Group("p.id, p.name, c.id, c.name").
|
|
||||||
Order("p.name ASC").
|
|
||||||
Limit(1000)
|
|
||||||
|
|
||||||
productQuery = r.resolveOutletID(productQuery, outletID, "o.outlet_id")
|
|
||||||
|
|
||||||
err = productQuery.Scan(&productData).Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &entities.ProfitLossAnalytics{
|
return &entities.ProfitLossAnalytics{
|
||||||
Summary: summary,
|
TodayRevenue: todayRC.Revenue,
|
||||||
Data: data,
|
TodayCost: todayRC.Cost,
|
||||||
ProductData: productData,
|
MtdRevenue: mtdRC.Revenue,
|
||||||
|
MtdCost: mtdRC.Cost,
|
||||||
|
TodayExpenseByCategory: todayExpenseByCategory,
|
||||||
|
MtdExpenseByCategory: mtdExpenseByCategory,
|
||||||
|
OperationalExpenseItems: opsItems,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExpenseCategoryTotal, error) {
|
||||||
|
var results []entities.ExpenseCategoryTotal
|
||||||
|
|
||||||
|
query := r.db.WithContext(ctx).
|
||||||
|
Table("expense_items ei").
|
||||||
|
Select(`COALESCE(parent_coa.name, 'Lain-lain') as category_name, COALESCE(SUM(ei.amount), 0) as amount`).
|
||||||
|
Joins("JOIN expenses e ON ei.expense_id = e.id").
|
||||||
|
Joins("JOIN chart_of_accounts coa ON ei.chart_of_account_id = coa.id").
|
||||||
|
Joins("LEFT JOIN chart_of_accounts parent_coa ON coa.parent_id = parent_coa.id").
|
||||||
|
Where("e.organization_id = ?", organizationID).
|
||||||
|
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo)
|
||||||
|
|
||||||
|
if outletID != nil {
|
||||||
|
query = query.Where("e.outlet_id = ?", *outletID)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := query.
|
||||||
|
Group("parent_coa.name").
|
||||||
|
Order("parent_coa.name").
|
||||||
|
Scan(&results).Error
|
||||||
|
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AnalyticsRepositoryImpl) getOperationalExpenseItems(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.OperationalExpenseItem, error) {
|
||||||
|
var results []entities.OperationalExpenseItem
|
||||||
|
|
||||||
|
query := r.db.WithContext(ctx).
|
||||||
|
Table("expense_items ei").
|
||||||
|
Select(`COALESCE(ei.description, coa.name) as description, COALESCE(SUM(ei.amount), 0) as amount`).
|
||||||
|
Joins("JOIN expenses e ON ei.expense_id = e.id").
|
||||||
|
Joins("JOIN chart_of_accounts coa ON ei.chart_of_account_id = coa.id").
|
||||||
|
Where("e.organization_id = ?", organizationID).
|
||||||
|
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo)
|
||||||
|
|
||||||
|
if outletID != nil {
|
||||||
|
query = query.Where("e.outlet_id = ?", *outletID)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := query.
|
||||||
|
Group("COALESCE(ei.description, coa.name)").
|
||||||
|
Order("amount DESC").
|
||||||
|
Scan(&results).Error
|
||||||
|
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
|||||||
@ -306,20 +306,8 @@ func (s *AnalyticsServiceImpl) validateProfitLossAnalyticsRequest(req *models.Pr
|
|||||||
return fmt.Errorf("organization_id is required")
|
return fmt.Errorf("organization_id is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.DateFrom.IsZero() {
|
if req.Date.IsZero() {
|
||||||
return fmt.Errorf("date_from is required")
|
return fmt.Errorf("date is required")
|
||||||
}
|
|
||||||
|
|
||||||
if req.DateTo.IsZero() {
|
|
||||||
return fmt.Errorf("date_to is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.DateFrom.After(req.DateTo) {
|
|
||||||
return fmt.Errorf("date_from cannot be after date_to")
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.GroupBy != "" && req.GroupBy != "hour" && req.GroupBy != "day" && req.GroupBy != "week" && req.GroupBy != "month" {
|
|
||||||
return fmt.Errorf("invalid group_by value, must be one of: hour, day, week, month")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -113,7 +113,8 @@ func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, org
|
|||||||
end := day.Add(24*time.Hour - time.Nanosecond)
|
end := day.Add(24*time.Hour - time.Nanosecond)
|
||||||
|
|
||||||
salesReq := &models.SalesAnalyticsRequest{OrganizationID: orgID, OutletID: &outID, DateFrom: start, DateTo: end, GroupBy: "day"}
|
salesReq := &models.SalesAnalyticsRequest{OrganizationID: orgID, OutletID: &outID, DateFrom: start, DateTo: end, GroupBy: "day"}
|
||||||
plReq := &models.ProfitLossAnalyticsRequest{OrganizationID: orgID, OutletID: &outID, DateFrom: start, DateTo: end, GroupBy: "day"}
|
plReq := &models.ProfitLossAnalyticsRequest{OrganizationID: orgID, OutletID: &outID, Date: day}
|
||||||
|
productReq := &models.ProductAnalyticsRequest{OrganizationID: orgID, OutletID: &outID, DateFrom: start, DateTo: end, Limit: 1000}
|
||||||
|
|
||||||
sales, err := s.analyticsService.GetSalesAnalytics(ctx, salesReq)
|
sales, err := s.analyticsService.GetSalesAnalytics(ctx, salesReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -123,6 +124,15 @@ func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, org
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("get profit/loss analytics: %w", err)
|
return "", "", fmt.Errorf("get profit/loss analytics: %w", err)
|
||||||
}
|
}
|
||||||
|
products, err := s.analyticsService.GetProductAnalytics(ctx, productReq)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("get product analytics: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalOmset := getPLNominalByID(pl.MainSummary, "total_omset")
|
||||||
|
hpp := getPLNominalByID(pl.MainSummary, "hpp")
|
||||||
|
labaKotor := getPLNominalByID(pl.MainSummary, "laba_kotor")
|
||||||
|
labaKotorPct := getPLPctByID(pl.MainSummary, "laba_kotor")
|
||||||
|
|
||||||
data := reportTemplateData{
|
data := reportTemplateData{
|
||||||
OrganizationName: org.Name,
|
OrganizationName: org.Name,
|
||||||
@ -133,28 +143,28 @@ func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, org
|
|||||||
GeneratedBy: generatedBy,
|
GeneratedBy: generatedBy,
|
||||||
PrintTime: time.Now().Format("02/01/2006 15:04:05"),
|
PrintTime: time.Now().Format("02/01/2006 15:04:05"),
|
||||||
Summary: reportSummary{
|
Summary: reportSummary{
|
||||||
TotalTransactions: pl.Summary.TotalOrders,
|
TotalTransactions: sales.Summary.TotalOrders,
|
||||||
TotalItems: sales.Summary.TotalItems,
|
TotalItems: sales.Summary.TotalItems,
|
||||||
GrossSales: formatCurrency(pl.Summary.TotalRevenue),
|
GrossSales: formatCurrency(totalOmset),
|
||||||
Discount: formatCurrency(pl.Summary.TotalDiscount),
|
Discount: formatCurrency(sales.Summary.TotalDiscount),
|
||||||
Tax: formatCurrency(pl.Summary.TotalTax),
|
Tax: formatCurrency(sales.Summary.TotalTax),
|
||||||
NetSales: formatCurrency(sales.Summary.NetSales),
|
NetSales: formatCurrency(sales.Summary.NetSales),
|
||||||
COGS: formatCurrency(pl.Summary.TotalCost),
|
COGS: formatCurrency(hpp),
|
||||||
GrossProfit: formatCurrency(pl.Summary.GrossProfit),
|
GrossProfit: formatCurrency(labaKotor),
|
||||||
GrossMarginPercent: fmt.Sprintf("%.2f", pl.Summary.GrossProfitMargin),
|
GrossMarginPercent: fmt.Sprintf("%.2f", labaKotorPct),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
items := make([]reportItem, 0, len(pl.ProductData))
|
items := make([]reportItem, 0, len(products.Data))
|
||||||
for _, p := range pl.ProductData {
|
for _, p := range products.Data {
|
||||||
items = append(items, reportItem{
|
items = append(items, reportItem{
|
||||||
Name: p.ProductName,
|
Name: p.ProductName,
|
||||||
Quantity: p.QuantitySold,
|
Quantity: p.QuantitySold,
|
||||||
GrossSales: formatCurrency(p.Revenue),
|
GrossSales: formatCurrency(p.Revenue),
|
||||||
Discount: formatCurrency(0),
|
Discount: formatCurrency(0),
|
||||||
NetSales: formatCurrency(p.Revenue),
|
NetSales: formatCurrency(p.Revenue),
|
||||||
COGS: formatCurrency(p.Cost),
|
COGS: formatCurrency(p.StandardHppTotal),
|
||||||
GrossProfit: formatCurrency(p.GrossProfit),
|
GrossProfit: formatCurrency(p.Revenue - p.StandardHppTotal),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
data.Items = items
|
data.Items = items
|
||||||
@ -190,3 +200,21 @@ func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, org
|
|||||||
|
|
||||||
return publicURL, fileName, nil
|
return publicURL, fileName, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getPLNominalByID(rows []models.ProfitLossSummaryRow, id string) float64 {
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.ID == id {
|
||||||
|
return row.TodayNominal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPLPctByID(rows []models.ProfitLossSummaryRow, id string) float64 {
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.ID == id {
|
||||||
|
return row.TodayPct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|||||||
@ -427,93 +427,68 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProfitLossAnalyticsContractToModel transforms contract request to model
|
|
||||||
func ProfitLossAnalyticsContractToModel(req *contract.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsRequest, error) {
|
func ProfitLossAnalyticsContractToModel(req *contract.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsRequest, error) {
|
||||||
if req == nil {
|
if req == nil {
|
||||||
return nil, fmt.Errorf("request cannot be nil")
|
return nil, fmt.Errorf("request cannot be nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse date range using utility function
|
dateTime, err := util.ParseDateToJakartaTime(req.Date)
|
||||||
dateFrom, dateTo, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid date format: %w", err)
|
return nil, fmt.Errorf("invalid date format: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if dateFrom == nil || dateTo == nil {
|
if dateTime == nil {
|
||||||
return nil, fmt.Errorf("both date_from and date_to are required")
|
return nil, fmt.Errorf("date is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &models.ProfitLossAnalyticsRequest{
|
return &models.ProfitLossAnalyticsRequest{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
OutletID: parseOutletID(req.OutletID),
|
OutletID: parseOutletID(req.OutletID),
|
||||||
DateFrom: *dateFrom,
|
Date: *dateTime,
|
||||||
DateTo: *dateTo,
|
|
||||||
GroupBy: req.GroupBy,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProfitLossAnalyticsModelToContract transforms model response to contract
|
|
||||||
func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse) *contract.ProfitLossAnalyticsResponse {
|
func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse) *contract.ProfitLossAnalyticsResponse {
|
||||||
if resp == nil {
|
if resp == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform profit/loss data
|
mainSummary := make([]contract.ProfitLossSummaryRow, len(resp.MainSummary))
|
||||||
data := make([]contract.ProfitLossData, len(resp.Data))
|
for i, row := range resp.MainSummary {
|
||||||
for i, item := range resp.Data {
|
mainSummary[i] = profitLossSummaryRowModelToContract(row)
|
||||||
data[i] = contract.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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform product profit data
|
opsItems := make([]contract.OperationalExpenseItem, len(resp.OperationalExpenses))
|
||||||
productData := make([]contract.ProductProfitData, len(resp.ProductData))
|
for i, item := range resp.OperationalExpenses {
|
||||||
for i, item := range resp.ProductData {
|
opsItems[i] = contract.OperationalExpenseItem{
|
||||||
productData[i] = contract.ProductProfitData{
|
Item: item.Item,
|
||||||
ProductID: item.ProductID,
|
Nominal: item.Nominal,
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &contract.ProfitLossAnalyticsResponse{
|
return &contract.ProfitLossAnalyticsResponse{
|
||||||
OrganizationID: resp.OrganizationID,
|
OrganizationID: resp.OrganizationID,
|
||||||
OutletID: resp.OutletID,
|
OutletID: resp.OutletID,
|
||||||
DateFrom: resp.DateFrom,
|
Date: resp.Date,
|
||||||
DateTo: resp.DateTo,
|
MainSummary: mainSummary,
|
||||||
GroupBy: resp.GroupBy,
|
OperationalExpenses: opsItems,
|
||||||
Summary: contract.ProfitLossSummary{
|
OperationalExpensesTotal: resp.OperationalExpensesTotal,
|
||||||
TotalRevenue: resp.Summary.TotalRevenue,
|
}
|
||||||
TotalCost: resp.Summary.TotalCost,
|
}
|
||||||
GrossProfit: resp.Summary.GrossProfit,
|
|
||||||
GrossProfitMargin: resp.Summary.GrossProfitMargin,
|
func profitLossSummaryRowModelToContract(row models.ProfitLossSummaryRow) contract.ProfitLossSummaryRow {
|
||||||
TotalTax: resp.Summary.TotalTax,
|
subItems := make([]contract.ProfitLossSummaryRow, len(row.SubItems))
|
||||||
TotalDiscount: resp.Summary.TotalDiscount,
|
for i, sub := range row.SubItems {
|
||||||
NetProfit: resp.Summary.NetProfit,
|
subItems[i] = profitLossSummaryRowModelToContract(sub)
|
||||||
NetProfitMargin: resp.Summary.NetProfitMargin,
|
}
|
||||||
TotalOrders: resp.Summary.TotalOrders,
|
return contract.ProfitLossSummaryRow{
|
||||||
AverageProfit: resp.Summary.AverageProfit,
|
ID: row.ID,
|
||||||
ProfitabilityRatio: resp.Summary.ProfitabilityRatio,
|
Label: row.Label,
|
||||||
},
|
IsBold: row.IsBold,
|
||||||
Data: data,
|
TodayNominal: row.TodayNominal,
|
||||||
ProductData: productData,
|
TodayPct: row.TodayPct,
|
||||||
|
MtdNominal: row.MtdNominal,
|
||||||
|
MtdPct: row.MtdPct,
|
||||||
|
SubItems: subItems,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user