Update profit-loss

This commit is contained in:
ryan 2026-05-26 14:59:56 +07:00
parent b8be29e110
commit 024d9ee637
10 changed files with 354 additions and 421 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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