feature/expense #13

Merged
aefril merged 10 commits from feature/expense into main 2026-06-01 17:20:29 +00:00
10 changed files with 541 additions and 25 deletions
Showing only changes of commit 47fa21d739 - Show all commits

View File

@ -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"`

View File

@ -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

View File

@ -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"`

View File

@ -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
@ -517,6 +555,22 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
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,

View File

@ -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)
}

View File

@ -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,

View File

@ -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
}

View File

@ -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)
}

View File

@ -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{
@ -476,6 +511,22 @@ func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse
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,

View File

@ -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)
}