From 47fa21d7391f83372a17db9495ae098a743eec65 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 1 Jun 2026 13:13:40 +0700 Subject: [PATCH] reinstate profit loss overview --- internal/contract/analytics_contract.go | 47 ++++++ internal/entities/analytics.go | 45 ++++++ internal/models/analytics.go | 47 ++++++ internal/processor/analytics_processor.go | 64 ++++++++- .../processor/analytics_processor_test.go | 81 ++++++++++- internal/repository/analytics_repository.go | 134 +++++++++++++++++- internal/service/analytics_service.go | 24 ++-- internal/service/analytics_service_test.go | 24 ++++ internal/transformer/analytics_transformer.go | 59 +++++++- .../transformer/analytics_transformer_test.go | 41 ++++++ 10 files changed, 541 insertions(+), 25 deletions(-) diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index 09211d1..89c3327 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -241,6 +241,7 @@ type ProfitLossAnalyticsRequest struct { OutletID *string `form:"outlet_id,omitempty"` DateFrom string `form:"date_from" validate:"required"` DateTo string `form:"date_to" validate:"required"` + GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"` } type ProfitLossAnalyticsResponse struct { @@ -248,11 +249,57 @@ type ProfitLossAnalyticsResponse struct { OutletID *uuid.UUID `json:"outlet_id,omitempty"` DateFrom time.Time `json:"date_from"` DateTo time.Time `json:"date_to"` + GroupBy string `json:"group_by"` + Summary ProfitLossSummary `json:"summary"` + Data []ProfitLossData `json:"data"` + ProductData []ProductProfitData `json:"product_data"` MainSummary []ProfitLossSummaryRow `json:"main_summary"` OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"` OperationalExpensesTotal float64 `json:"operational_expenses_total"` } +type ProfitLossSummary struct { + TotalRevenue float64 `json:"total_revenue"` + 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"` +} + +type ProfitLossData struct { + Date time.Time `json:"date"` + 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"` +} + +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"` +} + type ProfitLossSummaryRow struct { ID string `json:"id"` Label string `json:"label"` diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go index 5f0a894..8cea304 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -114,6 +114,9 @@ type DashboardOverview struct { } type ProfitLossAnalytics struct { + Summary ProfitLossSummary + Data []ProfitLossData + ProductData []ProductProfitData TodayRevenue float64 TodayCost float64 MtdRevenue float64 @@ -123,6 +126,48 @@ type ProfitLossAnalytics struct { OperationalExpenseItems []OperationalExpenseItem } +type ProfitLossSummary struct { + TotalRevenue float64 + TotalCost float64 + GrossProfit float64 + GrossProfitMargin float64 + TotalTax float64 + TotalDiscount float64 + NetProfit float64 + NetProfitMargin float64 + TotalOrders int64 + AverageProfit float64 + ProfitabilityRatio float64 +} + +type ProfitLossData struct { + Date time.Time + Revenue float64 + Cost float64 + GrossProfit float64 + GrossProfitMargin float64 + Tax float64 + Discount float64 + NetProfit float64 + NetProfitMargin float64 + Orders int64 +} + +type ProductProfitData struct { + ProductID uuid.UUID + ProductName string + CategoryID uuid.UUID + CategoryName string + QuantitySold int64 + Revenue float64 + Cost float64 + GrossProfit float64 + GrossProfitMargin float64 + AveragePrice float64 + AverageCost float64 + ProfitPerUnit float64 +} + type ExpenseCategoryTotal struct { CategoryName string Amount float64 diff --git a/internal/models/analytics.go b/internal/models/analytics.go index dab9d22..4b94cba 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -251,6 +251,7 @@ type ProfitLossAnalyticsRequest struct { OutletID *uuid.UUID `validate:"omitempty"` DateFrom time.Time `validate:"required"` DateTo time.Time `validate:"required"` + GroupBy string `validate:"omitempty,oneof=day hour week month"` } type ProfitLossAnalyticsResponse struct { @@ -258,11 +259,57 @@ type ProfitLossAnalyticsResponse struct { OutletID *uuid.UUID `json:"outlet_id,omitempty"` DateFrom time.Time `json:"date_from"` DateTo time.Time `json:"date_to"` + GroupBy string `json:"group_by"` + Summary ProfitLossSummary `json:"summary"` + Data []ProfitLossData `json:"data"` + ProductData []ProductProfitData `json:"product_data"` MainSummary []ProfitLossSummaryRow `json:"main_summary"` OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"` OperationalExpensesTotal float64 `json:"operational_expenses_total"` } +type ProfitLossSummary struct { + TotalRevenue float64 `json:"total_revenue"` + 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"` +} + +type ProfitLossData struct { + Date time.Time `json:"date"` + 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"` +} + +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"` +} + type ProfitLossSummaryRow struct { ID string `json:"id"` Label string `json:"label"` diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 22ad669..c8e41f6 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -410,11 +410,49 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req return nil, fmt.Errorf("date_from cannot be after date_to") } - result, err := p.analyticsRepo.GetProfitLossAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo) + 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, + } + } + todayPromosi := getExpenseAmountByCategory(result.TodayExpenseByCategory, "promosi") todayLainLain := getExpenseAmountByCategory(result.TodayExpenseByCategory, "lain") todayTotalOps := todayPromosi + todayLainLain @@ -513,10 +551,26 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req } return &models.ProfitLossAnalyticsResponse{ - OrganizationID: req.OrganizationID, - OutletID: req.OutletID, - DateFrom: req.DateFrom, - DateTo: req.DateTo, + 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, diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index 7121861..998831a 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -14,6 +14,8 @@ import ( type analyticsRepositoryStub struct { purchasingResult *entities.PurchasingAnalytics + profitLossResult *entities.ProfitLossAnalytics + profitLossGroup string } func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) { @@ -40,8 +42,9 @@ func (analyticsRepositoryStub) GetDashboardOverview(context.Context, uuid.UUID, return nil, nil } -func (analyticsRepositoryStub) GetProfitLossAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) (*entities.ProfitLossAnalytics, error) { - return nil, nil +func (s analyticsRepositoryStub) GetProfitLossAnalytics(_ context.Context, _ uuid.UUID, _ *uuid.UUID, _, _ time.Time, groupBy string) (*entities.ProfitLossAnalytics, error) { + s.profitLossGroup = groupBy + return s.profitLossResult, nil } type expenseRepositoryStub struct{} @@ -88,3 +91,77 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T) require.Equal(t, outletName, *result.OutletName) require.Equal(t, float64(125), result.Summary.TotalPurchases) } + +func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) { + productID := uuid.New() + categoryID := uuid.New() + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{ + profitLossResult: &entities.ProfitLossAnalytics{ + Summary: entities.ProfitLossSummary{ + TotalRevenue: 1000, + TotalCost: 400, + GrossProfit: 600, + GrossProfitMargin: 60, + TotalTax: 50, + TotalDiscount: 25, + NetProfit: 575, + NetProfitMargin: 57.5, + TotalOrders: 10, + AverageProfit: 57.5, + ProfitabilityRatio: 150, + }, + Data: []entities.ProfitLossData{ + { + Date: now, + Revenue: 1000, + Cost: 400, + GrossProfit: 600, + GrossProfitMargin: 60, + Tax: 50, + Discount: 25, + NetProfit: 575, + NetProfitMargin: 57.5, + Orders: 10, + }, + }, + ProductData: []entities.ProductProfitData{ + { + ProductID: productID, + ProductName: "Nasi", + CategoryID: categoryID, + CategoryName: "Food", + QuantitySold: 5, + Revenue: 500, + Cost: 200, + GrossProfit: 300, + GrossProfitMargin: 60, + AveragePrice: 100, + AverageCost: 40, + ProfitPerUnit: 60, + }, + }, + TodayRevenue: 1000, + TodayCost: 400, + MtdRevenue: 2000, + MtdCost: 800, + }, + }, expenseRepositoryStub{}) + + result, err := processor.GetProfitLossAnalytics(context.Background(), &models.ProfitLossAnalyticsRequest{ + OrganizationID: uuid.New(), + DateFrom: now, + DateTo: now, + }) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "day", result.GroupBy) + require.Equal(t, float64(1000), result.Summary.TotalRevenue) + require.Len(t, result.Data, 1) + require.Equal(t, float64(575), result.Data[0].NetProfit) + require.Len(t, result.ProductData, 1) + require.Equal(t, productID, result.ProductData[0].ProductID) + require.NotEmpty(t, result.MainSummary) + require.Equal(t, "total_omset", result.MainSummary[0].ID) +} diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index 1519a1a..b250dc9 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -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) 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) - GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ProfitLossAnalytics, error) + GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ProfitLossAnalytics, error) } type AnalyticsRepositoryImpl struct { @@ -432,11 +432,138 @@ func (r *AnalyticsRepositoryImpl) GetDashboardOverview(ctx context.Context, orga return &result, nil } -func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ProfitLossAnalytics, error) { +func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ProfitLossAnalytics, error) { mtdStart := time.Date(dateTo.Year(), dateTo.Month(), 1, 0, 0, 0, 0, dateTo.Location()) todayStart := time.Date(dateTo.Year(), dateTo.Month(), dateTo.Day(), 0, 0, 0, 0, dateTo.Location()) todayEnd := todayStart.Add(24 * time.Hour).Add(-time.Nanosecond) + var summary entities.ProfitLossSummary + summaryQuery := r.db.WithContext(ctx). + Table("orders o"). + Select(` + 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") + if err := summaryQuery.Scan(&summary).Error; err != nil { + return nil, err + } + + var timeFormat string + 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: + timeFormat = "DATE_TRUNC('day', o.created_at)" + } + + var data []entities.ProfitLossData + dataQuery := r.db.WithContext(ctx). + Table("orders o"). + Select(` + `+timeFormat+` as date, + COALESCE(SUM(o.total_amount), 0) as revenue, + 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.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). + Group(timeFormat). + Order(timeFormat) + dataQuery = r.resolveOutletID(dataQuery, outletID, "o.outlet_id") + if err := dataQuery.Scan(&data).Error; err != nil { + return nil, err + } + + var productData []entities.ProductProfitData + productQuery := r.db.WithContext(ctx). + 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") + if err := productQuery.Scan(&productData).Error; err != nil { + return nil, err + } + type revenueCostResult struct { Revenue float64 Cost float64 @@ -492,6 +619,9 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or } return &entities.ProfitLossAnalytics{ + Summary: summary, + Data: data, + ProductData: productData, TodayRevenue: todayRC.Revenue, TodayCost: todayRC.Cost, MtdRevenue: mtdRC.Revenue, diff --git a/internal/service/analytics_service.go b/internal/service/analytics_service.go index 8511a17..5496ad2 100644 --- a/internal/service/analytics_service.go +++ b/internal/service/analytics_service.go @@ -134,18 +134,6 @@ func (s *AnalyticsServiceImpl) validatePaymentMethodAnalyticsRequest(req *models return fmt.Errorf("date_from cannot be after date_to") } - if req.GroupBy != "" { - validGroupBy := map[string]bool{ - "day": true, - "hour": true, - "week": true, - "month": true, - } - if !validGroupBy[req.GroupBy] { - return fmt.Errorf("invalid group_by value: %s", req.GroupBy) - } - } - return nil } @@ -318,5 +306,17 @@ func (s *AnalyticsServiceImpl) validateProfitLossAnalyticsRequest(req *models.Pr return fmt.Errorf("date_from cannot be after date_to") } + if req.GroupBy != "" { + validGroupBy := map[string]bool{ + "day": true, + "hour": true, + "week": true, + "month": true, + } + if !validGroupBy[req.GroupBy] { + return fmt.Errorf("invalid group_by value: %s", req.GroupBy) + } + } + return nil } diff --git a/internal/service/analytics_service_test.go b/internal/service/analytics_service_test.go index dbd536e..c43419d 100644 --- a/internal/service/analytics_service_test.go +++ b/internal/service/analytics_service_test.go @@ -154,6 +154,16 @@ func TestAnalyticsServiceGetProfitLossAnalyticsValidation(t *testing.T) { }, wantErr: "date_from cannot be after date_to", }, + { + name: "invalid group_by", + req: &models.ProfitLossAnalyticsRequest{ + OrganizationID: uuid.New(), + DateFrom: now, + DateTo: now, + GroupBy: "quarter", + }, + wantErr: "invalid group_by value: quarter", + }, } for _, tt := range tests { @@ -166,3 +176,17 @@ func TestAnalyticsServiceGetProfitLossAnalyticsValidation(t *testing.T) { }) } } + +func TestAnalyticsServiceGetProfitLossAnalyticsAllowsEmptyGroupBy(t *testing.T) { + service := NewAnalyticsServiceImpl(analyticsProcessorStub{}) + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + + resp, err := service.GetProfitLossAnalytics(context.Background(), &models.ProfitLossAnalyticsRequest{ + OrganizationID: uuid.New(), + DateFrom: now, + DateTo: now, + }) + + require.NoError(t, err) + require.Nil(t, resp) +} diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index 2172cec..f0538c4 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -450,6 +450,7 @@ func ProfitLossAnalyticsContractToModel(req *contract.ProfitLossAnalyticsRequest OutletID: parseOutletID(req.OutletID), DateFrom: *dateFrom, DateTo: *dateTo, + GroupBy: req.GroupBy, }, nil } @@ -463,6 +464,40 @@ func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse mainSummary[i] = profitLossSummaryRowModelToContract(row) } + data := make([]contract.ProfitLossData, len(resp.Data)) + for i, item := range resp.Data { + 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, + } + } + + productData := make([]contract.ProductProfitData, len(resp.ProductData)) + for i, item := range resp.ProductData { + productData[i] = contract.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, + } + } + opsItems := make([]contract.OperationalExpenseItem, len(resp.OperationalExpenses)) for i, item := range resp.OperationalExpenses { opsItems[i] = contract.OperationalExpenseItem{ @@ -472,10 +507,26 @@ func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse } return &contract.ProfitLossAnalyticsResponse{ - OrganizationID: resp.OrganizationID, - OutletID: resp.OutletID, - DateFrom: resp.DateFrom, - DateTo: resp.DateTo, + OrganizationID: resp.OrganizationID, + OutletID: resp.OutletID, + DateFrom: resp.DateFrom, + DateTo: resp.DateTo, + GroupBy: resp.GroupBy, + Summary: contract.ProfitLossSummary{ + TotalRevenue: resp.Summary.TotalRevenue, + TotalCost: resp.Summary.TotalCost, + GrossProfit: resp.Summary.GrossProfit, + GrossProfitMargin: resp.Summary.GrossProfitMargin, + TotalTax: resp.Summary.TotalTax, + TotalDiscount: resp.Summary.TotalDiscount, + NetProfit: resp.Summary.NetProfit, + NetProfitMargin: resp.Summary.NetProfitMargin, + TotalOrders: resp.Summary.TotalOrders, + AverageProfit: resp.Summary.AverageProfit, + ProfitabilityRatio: resp.Summary.ProfitabilityRatio, + }, + Data: data, + ProductData: productData, MainSummary: mainSummary, OperationalExpenses: opsItems, OperationalExpensesTotal: resp.OperationalExpensesTotal, diff --git a/internal/transformer/analytics_transformer_test.go b/internal/transformer/analytics_transformer_test.go index efbb87e..2a7bc6c 100644 --- a/internal/transformer/analytics_transformer_test.go +++ b/internal/transformer/analytics_transformer_test.go @@ -84,12 +84,14 @@ func TestProfitLossAnalyticsContractToModelParsesDateRange(t *testing.T) { OutletID: &outletID, DateFrom: "01-05-2026", DateTo: "29-05-2026", + GroupBy: "week", }) require.NoError(t, err) require.Equal(t, orgID, result.OrganizationID) require.NotNil(t, result.OutletID) require.Equal(t, outletID, result.OutletID.String()) + require.Equal(t, "week", result.GroupBy) location, err := time.LoadLocation("Asia/Jakarta") require.NoError(t, err) @@ -100,14 +102,53 @@ func TestProfitLossAnalyticsContractToModelParsesDateRange(t *testing.T) { func TestProfitLossAnalyticsModelToContractCopiesDateRange(t *testing.T) { dateFrom := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) dateTo := time.Date(2026, 5, 29, 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC) + productID := uuid.New() + categoryID := uuid.New() result := ProfitLossAnalyticsModelToContract(&models.ProfitLossAnalyticsResponse{ OrganizationID: uuid.New(), DateFrom: dateFrom, DateTo: dateTo, + GroupBy: "month", + Summary: models.ProfitLossSummary{ + TotalRevenue: 1000, + NetProfit: 500, + }, + Data: []models.ProfitLossData{ + { + Date: dateFrom, + Revenue: 1000, + NetProfit: 500, + }, + }, + ProductData: []models.ProductProfitData{ + { + ProductID: productID, + ProductName: "Nasi", + CategoryID: categoryID, + CategoryName: "Food", + Revenue: 1000, + GrossProfit: 500, + }, + }, + MainSummary: []models.ProfitLossSummaryRow{ + { + ID: "total_omset", + Label: "TOTAL OMSET", + TodayNominal: 1000, + }, + }, }) require.NotNil(t, result) require.Equal(t, dateFrom, result.DateFrom) require.Equal(t, dateTo, result.DateTo) + require.Equal(t, "month", result.GroupBy) + require.Equal(t, float64(1000), result.Summary.TotalRevenue) + require.Len(t, result.Data, 1) + require.Equal(t, float64(500), result.Data[0].NetProfit) + require.Len(t, result.ProductData, 1) + require.Equal(t, productID, result.ProductData[0].ProductID) + require.Len(t, result.MainSummary, 1) + require.Equal(t, "total_omset", result.MainSummary[0].ID) }