From 024d9ee637792e0606e99b076944c77b2d056165 Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 26 May 2026 14:59:56 +0700 Subject: [PATCH] Update profit-loss --- internal/app/app.go | 2 +- internal/contract/analytics_contract.go | 73 ++---- internal/entities/analytics.go | 58 ++--- internal/models/analytics.go | 73 ++---- internal/processor/analytics_processor.go | 168 +++++++++----- .../processor/analytics_processor_test.go | 21 +- internal/repository/analytics_repository.go | 217 ++++++++---------- internal/service/analytics_service.go | 16 +- internal/service/report_service.go | 52 ++++- internal/transformer/analytics_transformer.go | 95 +++----- 10 files changed, 354 insertions(+), 421 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 36742c7..82f4442 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -359,7 +359,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo), fileProcessor: processor.NewFileProcessorImpl(repos.fileRepo, fileClient), customerProcessor: processor.NewCustomerProcessor(repos.customerRepo), - analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo), + analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo, repos.expenseRepo), tableProcessor: processor.NewTableProcessor(repos.tableRepo, repos.orderRepo), unitProcessor: processor.NewUnitProcessor(repos.unitRepo), ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo), diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index 5389240..d9d6556 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -236,68 +236,33 @@ type DashboardOverview struct { RefundedOrders int64 `json:"refunded_orders"` } -// ProfitLossAnalyticsRequest represents the request for profit and loss analytics type ProfitLossAnalyticsRequest struct { OrganizationID uuid.UUID 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"` + Date string `form:"date" validate:"required"` } -// ProfitLossAnalyticsResponse represents the response for profit and loss analytics type ProfitLossAnalyticsResponse struct { - OrganizationID uuid.UUID `json:"organization_id"` - 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"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + Date time.Time `json:"date"` + MainSummary []ProfitLossSummaryRow `json:"main_summary"` + OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"` + OperationalExpensesTotal float64 `json:"operational_expenses_total"` } -// ProfitLossSummary represents the summary of profit and loss analytics -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 ProfitLossSummaryRow struct { + ID string `json:"id"` + Label string `json:"label"` + IsBold bool `json:"is_bold"` + TodayNominal float64 `json:"today_nominal"` + TodayPct float64 `json:"today_pct"` + MtdNominal float64 `json:"mtd_nominal"` + MtdPct float64 `json:"mtd_pct"` + SubItems []ProfitLossSummaryRow `json:"sub_items,omitempty"` } -// ProfitLossData represents individual profit and loss data point by time period -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"` -} - -// 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"` +type OperationalExpenseItem struct { + Item string `json:"item"` + Nominal float64 `json:"nominal"` } diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go index 2b69ae7..f06aff9 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -113,54 +113,22 @@ type DashboardOverview struct { RefundedOrders int64 `json:"refunded_orders"` } -// ProfitLossAnalytics represents profit and loss analytics data type ProfitLossAnalytics struct { - Summary ProfitLossSummary `json:"summary"` - Data []ProfitLossData `json:"data"` - ProductData []ProductProfitData `json:"product_data"` + TodayRevenue float64 + TodayCost float64 + MtdRevenue float64 + MtdCost float64 + TodayExpenseByCategory []ExpenseCategoryTotal + MtdExpenseByCategory []ExpenseCategoryTotal + OperationalExpenseItems []OperationalExpenseItem } -// ProfitLossSummary represents profit and loss summary data -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 ExpenseCategoryTotal struct { + CategoryName string + Amount float64 } -// ProfitLossData represents profit and loss data by time period -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"` -} - -// 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"` +type OperationalExpenseItem struct { + Description string + Amount float64 } diff --git a/internal/models/analytics.go b/internal/models/analytics.go index 2821518..2a76965 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -246,68 +246,33 @@ type DashboardOverview struct { RefundedOrders int64 `json:"refunded_orders"` } -// ProfitLossAnalyticsRequest represents the request for profit and loss analytics type ProfitLossAnalyticsRequest struct { OrganizationID uuid.UUID `validate:"required"` OutletID *uuid.UUID `validate:"omitempty"` - DateFrom time.Time `validate:"required"` - DateTo time.Time `validate:"required"` - GroupBy string `validate:"omitempty,oneof=day hour week month"` + Date time.Time `validate:"required"` } -// ProfitLossAnalyticsResponse represents the response for profit and loss analytics type ProfitLossAnalyticsResponse struct { - OrganizationID uuid.UUID `json:"organization_id"` - 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"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + Date time.Time `json:"date"` + MainSummary []ProfitLossSummaryRow `json:"main_summary"` + OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"` + OperationalExpensesTotal float64 `json:"operational_expenses_total"` } -// ProfitLossSummary represents the summary of profit and loss analytics -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 ProfitLossSummaryRow struct { + ID string `json:"id"` + Label string `json:"label"` + IsBold bool `json:"is_bold"` + TodayNominal float64 `json:"today_nominal"` + TodayPct float64 `json:"today_pct"` + MtdNominal float64 `json:"mtd_nominal"` + MtdPct float64 `json:"mtd_pct"` + SubItems []ProfitLossSummaryRow `json:"sub_items,omitempty"` } -// ProfitLossData represents individual profit and loss data point by time period -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"` -} - -// 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"` +type OperationalExpenseItem struct { + Item string `json:"item"` + Nominal float64 `json:"nominal"` } diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 344b801..c9ae596 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -3,8 +3,10 @@ package processor import ( "context" "fmt" + "strings" "time" + "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" ) @@ -21,11 +23,13 @@ type AnalyticsProcessor interface { type AnalyticsProcessorImpl struct { analyticsRepo repository.AnalyticsRepository + expenseRepo ExpenseRepository } -func NewAnalyticsProcessorImpl(analyticsRepo repository.AnalyticsRepository) *AnalyticsProcessorImpl { +func NewAnalyticsProcessorImpl(analyticsRepo repository.AnalyticsRepository, expenseRepo ExpenseRepository) *AnalyticsProcessorImpl { return &AnalyticsProcessorImpl{ 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) { - if req.DateFrom.After(req.DateTo) { - return nil, fmt.Errorf("date_from cannot be after date_to") + if req.Date.IsZero() { + return nil, fmt.Errorf("date is required") } - // Get analytics data from repository - result, err := p.analyticsRepo.GetProfitLossAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy) + result, err := p.analyticsRepo.GetProfitLossAnalytics(ctx, req.OrganizationID, req.OutletID, req.Date) if err != nil { return nil, fmt.Errorf("failed to get profit/loss analytics: %w", err) } - // Transform entities to models - 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, + todayPromosi := getExpenseAmountByCategory(result.TodayExpenseByCategory, "promosi") + todayLainLain := getExpenseAmountByCategory(result.TodayExpenseByCategory, "lain") + todayTotalOps := todayPromosi + todayLainLain + todayGaji := getExpenseAmountByCategory(result.TodayExpenseByCategory, "gaji") + + mtdPromosi := getExpenseAmountByCategory(result.MtdExpenseByCategory, "promosi") + mtdLainLain := getExpenseAmountByCategory(result.MtdExpenseByCategory, "lain") + mtdTotalOps := mtdPromosi + mtdLainLain + mtdGaji := getExpenseAmountByCategory(result.MtdExpenseByCategory, "gaji") + + todayGrossProfit := result.TodayRevenue - result.TodayCost + mtdGrossProfit := result.MtdRevenue - result.MtdCost + + todayProfitBeforeGaji := todayGrossProfit - todayTotalOps + mtdProfitBeforeGaji := mtdGrossProfit - mtdTotalOps + + todayNetProfit := todayProfitBeforeGaji - todayGaji + mtdNetProfit := mtdProfitBeforeGaji - mtdGaji + + todayPct := func(nominal float64) float64 { + if result.TodayRevenue == 0 { + return 0 } + return (nominal / result.TodayRevenue) * 100 + } + mtdPct := func(nominal float64) float64 { + if result.MtdRevenue == 0 { + return 0 + } + return (nominal / result.MtdRevenue) * 100 } - 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, + mainSummary := []models.ProfitLossSummaryRow{ + { + ID: "total_omset", Label: "TOTAL OMSET", + TodayNominal: result.TodayRevenue, TodayPct: todayPct(result.TodayRevenue), + MtdNominal: result.MtdRevenue, MtdPct: mtdPct(result.MtdRevenue), + }, + { + ID: "hpp", Label: "HPP", + TodayNominal: result.TodayCost, TodayPct: todayPct(result.TodayCost), + MtdNominal: result.MtdCost, MtdPct: mtdPct(result.MtdCost), + }, + { + ID: "laba_kotor", Label: "Laba Kotor (1-2)", + TodayNominal: todayGrossProfit, TodayPct: todayPct(todayGrossProfit), + MtdNominal: mtdGrossProfit, MtdPct: mtdPct(mtdGrossProfit), + }, + { + ID: "biaya_ops", Label: "BIAYA OPS", + TodayNominal: todayTotalOps, TodayPct: todayPct(todayTotalOps), + MtdNominal: mtdTotalOps, MtdPct: mtdPct(mtdTotalOps), + SubItems: []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{ - 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, + OrganizationID: req.OrganizationID, + OutletID: req.OutletID, + Date: req.Date, + MainSummary: mainSummary, + OperationalExpenses: opsItems, + OperationalExpensesTotal: opsTotal, }, 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 +} diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index cad6e01..6fb57c5 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -40,10 +40,27 @@ func (analyticsRepositoryStub) GetDashboardOverview(context.Context, uuid.UUID, 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 } +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) { outletID := uuid.New() outletName := "Main Outlet" @@ -55,7 +72,7 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T) TotalPurchases: 125, }, }, - }) + }, expenseRepositoryStub{}) result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{ OrganizationID: uuid.New(), diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index 9c2a0cf..b28863d 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, groupBy string) (*entities.ProfitLossAnalytics, error) + GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, date time.Time) (*entities.ProfitLossAnalytics, error) } type AnalyticsRepositoryImpl struct { @@ -432,152 +432,119 @@ 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, groupBy string) (*entities.ProfitLossAnalytics, error) { - // Summary query - var summary entities.ProfitLossSummary +func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, date time.Time) (*entities.ProfitLossAnalytics, error) { + mtdStart := time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location()) + 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). - 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") - - err := summaryQuery.Scan(&summary).Error - if err != nil { - return nil, err + type revenueCostResult struct { + Revenue float64 + Cost float64 } - // Time series data query - 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: // day - timeFormat = "DATE_TRUNC('day', o.created_at)" - } - - var data []entities.ProfitLossData - - dataQuery := r.db.WithContext(ctx). + var todayRC revenueCostResult + todayQuery := 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 + 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 <= ?", dateFrom, dateTo). - Group(timeFormat). - Order(timeFormat) + Where("o.created_at >= ? AND o.created_at <= ?", todayStart, todayEnd) + todayQuery = r.resolveOutletID(todayQuery, outletID, "o.outlet_id") + 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 { return nil, err } - // Product profit data query - var productData []entities.ProductProfitData + mtdExpenseByCategory, err := r.getExpenseByCategory(ctx, organizationID, outletID, mtdStart, todayEnd) + if err != nil { + return nil, err + } - 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") - - err = productQuery.Scan(&productData).Error + opsItems, err := r.getOperationalExpenseItems(ctx, organizationID, outletID, mtdStart, todayEnd) if err != nil { return nil, err } return &entities.ProfitLossAnalytics{ - Summary: summary, - Data: data, - ProductData: productData, + TodayRevenue: todayRC.Revenue, + TodayCost: todayRC.Cost, + MtdRevenue: mtdRC.Revenue, + MtdCost: mtdRC.Cost, + TodayExpenseByCategory: todayExpenseByCategory, + MtdExpenseByCategory: mtdExpenseByCategory, + OperationalExpenseItems: opsItems, }, 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 +} diff --git a/internal/service/analytics_service.go b/internal/service/analytics_service.go index 7643c3e..c0483a7 100644 --- a/internal/service/analytics_service.go +++ b/internal/service/analytics_service.go @@ -306,20 +306,8 @@ func (s *AnalyticsServiceImpl) validateProfitLossAnalyticsRequest(req *models.Pr return fmt.Errorf("organization_id is required") } - if req.DateFrom.IsZero() { - return fmt.Errorf("date_from 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") + if req.Date.IsZero() { + return fmt.Errorf("date is required") } return nil diff --git a/internal/service/report_service.go b/internal/service/report_service.go index 55aeede..915eb28 100644 --- a/internal/service/report_service.go +++ b/internal/service/report_service.go @@ -113,7 +113,8 @@ func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, org end := day.Add(24*time.Hour - time.Nanosecond) 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) if err != nil { @@ -123,6 +124,15 @@ func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, org if err != nil { 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{ OrganizationName: org.Name, @@ -133,28 +143,28 @@ func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, org GeneratedBy: generatedBy, PrintTime: time.Now().Format("02/01/2006 15:04:05"), Summary: reportSummary{ - TotalTransactions: pl.Summary.TotalOrders, + TotalTransactions: sales.Summary.TotalOrders, TotalItems: sales.Summary.TotalItems, - GrossSales: formatCurrency(pl.Summary.TotalRevenue), - Discount: formatCurrency(pl.Summary.TotalDiscount), - Tax: formatCurrency(pl.Summary.TotalTax), + GrossSales: formatCurrency(totalOmset), + Discount: formatCurrency(sales.Summary.TotalDiscount), + Tax: formatCurrency(sales.Summary.TotalTax), NetSales: formatCurrency(sales.Summary.NetSales), - COGS: formatCurrency(pl.Summary.TotalCost), - GrossProfit: formatCurrency(pl.Summary.GrossProfit), - GrossMarginPercent: fmt.Sprintf("%.2f", pl.Summary.GrossProfitMargin), + COGS: formatCurrency(hpp), + GrossProfit: formatCurrency(labaKotor), + GrossMarginPercent: fmt.Sprintf("%.2f", labaKotorPct), }, } - items := make([]reportItem, 0, len(pl.ProductData)) - for _, p := range pl.ProductData { + items := make([]reportItem, 0, len(products.Data)) + for _, p := range products.Data { items = append(items, reportItem{ Name: p.ProductName, Quantity: p.QuantitySold, GrossSales: formatCurrency(p.Revenue), Discount: formatCurrency(0), NetSales: formatCurrency(p.Revenue), - COGS: formatCurrency(p.Cost), - GrossProfit: formatCurrency(p.GrossProfit), + COGS: formatCurrency(p.StandardHppTotal), + GrossProfit: formatCurrency(p.Revenue - p.StandardHppTotal), }) } data.Items = items @@ -190,3 +200,21 @@ func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, org 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 +} diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index 52105e2..c5436cc 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -427,93 +427,68 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse) } } -// ProfitLossAnalyticsContractToModel transforms contract request to model func ProfitLossAnalyticsContractToModel(req *contract.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsRequest, error) { if req == nil { return nil, fmt.Errorf("request cannot be nil") } - // Parse date range using utility function - dateFrom, dateTo, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo) + dateTime, err := util.ParseDateToJakartaTime(req.Date) if err != nil { return nil, fmt.Errorf("invalid date format: %w", err) } - if dateFrom == nil || dateTo == nil { - return nil, fmt.Errorf("both date_from and date_to are required") + if dateTime == nil { + return nil, fmt.Errorf("date is required") } return &models.ProfitLossAnalyticsRequest{ OrganizationID: req.OrganizationID, OutletID: parseOutletID(req.OutletID), - DateFrom: *dateFrom, - DateTo: *dateTo, - GroupBy: req.GroupBy, + Date: *dateTime, }, nil } -// ProfitLossAnalyticsModelToContract transforms model response to contract func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse) *contract.ProfitLossAnalyticsResponse { if resp == nil { return nil } - // Transform profit/loss data - 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, - } + mainSummary := make([]contract.ProfitLossSummaryRow, len(resp.MainSummary)) + for i, row := range resp.MainSummary { + mainSummary[i] = profitLossSummaryRowModelToContract(row) } - // Transform product profit data - 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{ + Item: item.Item, + Nominal: item.Nominal, } } return &contract.ProfitLossAnalyticsResponse{ - 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, + OrganizationID: resp.OrganizationID, + OutletID: resp.OutletID, + Date: resp.Date, + MainSummary: mainSummary, + OperationalExpenses: opsItems, + OperationalExpensesTotal: resp.OperationalExpensesTotal, + } +} + +func profitLossSummaryRowModelToContract(row models.ProfitLossSummaryRow) contract.ProfitLossSummaryRow { + subItems := make([]contract.ProfitLossSummaryRow, len(row.SubItems)) + for i, sub := range row.SubItems { + subItems[i] = profitLossSummaryRowModelToContract(sub) + } + return contract.ProfitLossSummaryRow{ + ID: row.ID, + Label: row.Label, + IsBold: row.IsBold, + TodayNominal: row.TodayNominal, + TodayPct: row.TodayPct, + MtdNominal: row.MtdNominal, + MtdPct: row.MtdPct, + SubItems: subItems, } }