From 2138b44c5307b3ee5895c3491aefafcf819e65e5 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 18 Jun 2026 19:51:43 +0700 Subject: [PATCH] Update profit-loss --- internal/repository/analytics_repository.go | 247 ++++++++++++++++++++ 1 file changed, 247 insertions(+) diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index 922330a..a2384a9 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "sort" "time" "apskel-pos-be/internal/entities" @@ -43,6 +44,14 @@ func purchaseOrderItemTotalAmountSQL() string { return "CASE WHEN pc.type = '" + string(entities.PurchaseCategoryTypeRawMaterial) + "' THEN COALESCE(poi.quantity, 0) * poi.amount ELSE poi.amount END" } +func purchaseOrderRawMaterialAmountSQL() string { + return "CASE WHEN pc.type = '" + string(entities.PurchaseCategoryTypeRawMaterial) + "' THEN " + purchaseOrderItemTotalAmountSQL() + " ELSE 0 END" +} + +func purchaseOrderExpenseAmountSQL() string { + return "CASE WHEN pc.type = '" + string(entities.PurchaseCategoryTypeExpense) + "' THEN " + purchaseOrderItemTotalAmountSQL() + " ELSE 0 END" +} + func (r *AnalyticsRepositoryImpl) GetPaymentMethodAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.PaymentMethodAnalytics, error) { var results []*entities.PaymentMethodAnalytics @@ -158,7 +167,11 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Table("purchase_orders po"). Select(` COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total_purchases, + COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material_purchases, + COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense_purchases, COUNT(DISTINCT po.id) as total_purchase_orders, + COUNT(DISTINCT CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeRawMaterial)+`' THEN po.id END) as raw_material_purchase_orders, + COUNT(CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeExpense)+`' THEN poi.id END) as expense_count, COALESCE(SUM(poi.quantity), 0) as total_quantity, CASE WHEN COUNT(DISTINCT po.id) > 0 @@ -199,7 +212,11 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Select(` `+dateFormat+` as date, COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as purchases, + COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material_purchases, + COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense_purchases, COUNT(DISTINCT po.id) as purchase_orders, + COUNT(DISTINCT CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeRawMaterial)+`' THEN po.id END) as raw_material_purchase_orders, + COUNT(CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeExpense)+`' THEN poi.id END) as expense_count, COALESCE(SUM(poi.quantity), 0) as quantity, COUNT(DISTINCT i.id) as ingredients, COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) as vendors @@ -210,6 +227,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Joins("LEFT JOIN units u ON poi.unit_id = u.id"). Where("po.organization_id = ?", organizationID). Where("po.status != ?", "cancelled"). + Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Group(dateFormat). Order(dateFormat) @@ -494,6 +512,11 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or if err := summaryQuery.Scan(&summary).Error; err != nil { return nil, err } + periodHPP, err := r.getPurchaseOrderRawMaterialTotal(ctx, organizationID, outletID, dateFrom, dateTo) + if err != nil { + return nil, err + } + applyProfitLossSummaryCost(&summary, periodHPP) var timeFormat string switch groupBy { @@ -541,6 +564,11 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or if err := dataQuery.Scan(&data).Error; err != nil { return nil, err } + poCostData, err := r.getPurchaseOrderRawMaterialCostByPeriod(ctx, organizationID, outletID, dateFrom, dateTo, groupBy) + if err != nil { + return nil, err + } + data = mergeProfitLossDataWithPurchaseOrderCost(data, poCostData) var productData []entities.ProductProfitData productQuery := r.db.WithContext(ctx). @@ -601,6 +629,11 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or if err := todayQuery.Scan(&todayRC).Error; err != nil { return nil, err } + todayHPP, err := r.getPurchaseOrderRawMaterialTotal(ctx, organizationID, outletID, todayStart, todayEnd) + if err != nil { + return nil, err + } + todayRC.Cost = todayHPP var mtdRC revenueCostResult mtdQuery := r.db.WithContext(ctx). @@ -618,21 +651,41 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or if err := mtdQuery.Scan(&mtdRC).Error; err != nil { return nil, err } + mtdHPP, err := r.getPurchaseOrderRawMaterialTotal(ctx, organizationID, outletID, mtdStart, todayEnd) + if err != nil { + return nil, err + } + mtdRC.Cost = mtdHPP todayExpenseByCategory, err := r.getExpenseByCategory(ctx, organizationID, outletID, todayStart, todayEnd) if err != nil { return nil, err } + todayPOExpenseByCategory, err := r.getPurchaseOrderExpenseByCategory(ctx, organizationID, outletID, todayStart, todayEnd) + if err != nil { + return nil, err + } + todayExpenseByCategory = mergeExpenseCategoryTotals(todayExpenseByCategory, todayPOExpenseByCategory) mtdExpenseByCategory, err := r.getExpenseByCategory(ctx, organizationID, outletID, mtdStart, todayEnd) if err != nil { return nil, err } + mtdPOExpenseByCategory, err := r.getPurchaseOrderExpenseByCategory(ctx, organizationID, outletID, mtdStart, todayEnd) + if err != nil { + return nil, err + } + mtdExpenseByCategory = mergeExpenseCategoryTotals(mtdExpenseByCategory, mtdPOExpenseByCategory) opsItems, err := r.getOperationalExpenseItems(ctx, organizationID, outletID, mtdStart, todayEnd) if err != nil { return nil, err } + poOpsItems, err := r.getPurchaseOrderExpenseItems(ctx, organizationID, outletID, mtdStart, todayEnd) + if err != nil { + return nil, err + } + opsItems = mergeOperationalExpenseItems(opsItems, poOpsItems) return &entities.ProfitLossAnalytics{ Summary: summary, @@ -648,6 +701,200 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or }, nil } +func (r *AnalyticsRepositoryImpl) getPurchaseOrderRawMaterialTotal(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (float64, error) { + type totalResult struct { + Total float64 + } + var result totalResult + + query := r.db.WithContext(ctx). + Table("purchase_order_items poi"). + Select(`COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total`). + Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id"). + Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id"). + Where("po.organization_id = ?", organizationID). + Where("po.status = ?", "received"). + Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). + Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo) + query = r.applyPurchaseOrderItemOutletFilter(query, outletID) + + if err := query.Scan(&result).Error; err != nil { + return 0, err + } + return result.Total, nil +} + +func (r *AnalyticsRepositoryImpl) getPurchaseOrderRawMaterialCostByPeriod(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) ([]entities.ProfitLossData, error) { + var dateFormat string + switch groupBy { + case "hour": + dateFormat = "DATE_TRUNC('hour', po.transaction_date::timestamp)" + case "week": + dateFormat = "DATE_TRUNC('week', po.transaction_date::timestamp)" + case "month": + dateFormat = "DATE_TRUNC('month', po.transaction_date::timestamp)" + default: + dateFormat = "DATE_TRUNC('day', po.transaction_date::timestamp)" + } + + var results []entities.ProfitLossData + query := r.db.WithContext(ctx). + Table("purchase_order_items poi"). + Select(` + `+dateFormat+` as date, + COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as cost + `). + Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id"). + Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id"). + Where("po.organization_id = ?", organizationID). + Where("po.status = ?", "received"). + Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). + Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). + Group(dateFormat). + Order(dateFormat) + query = r.applyPurchaseOrderItemOutletFilter(query, outletID) + + if err := query.Scan(&results).Error; err != nil { + return nil, err + } + return results, nil +} + +func applyProfitLossSummaryCost(summary *entities.ProfitLossSummary, cost float64) { + summary.TotalCost = cost + summary.GrossProfit = summary.TotalRevenue - cost + summary.GrossProfitMargin = ratio(summary.GrossProfit, summary.TotalRevenue) + summary.NetProfit = summary.TotalRevenue - cost - summary.TotalDiscount + summary.NetProfitMargin = ratio(summary.NetProfit, summary.TotalRevenue) + if summary.TotalOrders > 0 { + summary.AverageProfit = summary.NetProfit / float64(summary.TotalOrders) + } else { + summary.AverageProfit = 0 + } + summary.ProfitabilityRatio = ratio(summary.GrossProfit, cost) +} + +func mergeProfitLossDataWithPurchaseOrderCost(data, costs []entities.ProfitLossData) []entities.ProfitLossData { + indexByDate := make(map[time.Time]int, len(data)) + for i, item := range data { + data[i].Cost = 0 + indexByDate[item.Date] = i + } + + for _, cost := range costs { + if i, ok := indexByDate[cost.Date]; ok { + data[i].Cost = cost.Cost + continue + } + + indexByDate[cost.Date] = len(data) + data = append(data, entities.ProfitLossData{Date: cost.Date, Cost: cost.Cost}) + } + + for i := range data { + data[i].GrossProfit = data[i].Revenue - data[i].Cost + data[i].GrossProfitMargin = ratio(data[i].GrossProfit, data[i].Revenue) + data[i].NetProfit = data[i].Revenue - data[i].Cost - data[i].Discount + data[i].NetProfitMargin = ratio(data[i].NetProfit, data[i].Revenue) + } + + sort.Slice(data, func(i, j int) bool { + return data[i].Date.Before(data[j].Date) + }) + return data +} + +func ratio(numerator, denominator float64) float64 { + if denominator == 0 { + return 0 + } + return (numerator / denominator) * 100 +} + +func (r *AnalyticsRepositoryImpl) getPurchaseOrderExpenseByCategory(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("purchase_order_items poi"). + Select(` + pc.name as category_name, + COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as amount + `). + Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id"). + Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id"). + Where("po.organization_id = ?", organizationID). + Where("po.status = ?", "received"). + Where("pc.type = ?", entities.PurchaseCategoryTypeExpense). + Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). + Group("pc.id, pc.name, pc.sort_order"). + Order("pc.sort_order ASC, pc.name ASC") + query = r.applyPurchaseOrderItemOutletFilter(query, outletID) + + if err := query.Scan(&results).Error; err != nil { + return nil, err + } + return results, nil +} + +func (r *AnalyticsRepositoryImpl) getPurchaseOrderExpenseItems(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("purchase_order_items poi"). + Select(` + COALESCE(NULLIF(poi.description, ''), pc.name) as item, + COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as amount + `). + Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id"). + Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id"). + Where("po.organization_id = ?", organizationID). + Where("po.status = ?", "received"). + Where("pc.type = ?", entities.PurchaseCategoryTypeExpense). + Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). + Group("COALESCE(NULLIF(poi.description, ''), pc.name)"). + Order("amount DESC") + query = r.applyPurchaseOrderItemOutletFilter(query, outletID) + + if err := query.Scan(&results).Error; err != nil { + return nil, err + } + return results, nil +} + +func mergeExpenseCategoryTotals(base, extra []entities.ExpenseCategoryTotal) []entities.ExpenseCategoryTotal { + indexByName := make(map[string]int, len(base)) + for i, item := range base { + indexByName[item.CategoryName] = i + } + + for _, item := range extra { + if i, ok := indexByName[item.CategoryName]; ok { + base[i].Amount += item.Amount + continue + } + indexByName[item.CategoryName] = len(base) + base = append(base, item) + } + return base +} + +func mergeOperationalExpenseItems(base, extra []entities.OperationalExpenseItem) []entities.OperationalExpenseItem { + indexByName := make(map[string]int, len(base)) + for i, item := range base { + indexByName[item.Item] = i + } + + for _, item := range extra { + if i, ok := indexByName[item.Item]; ok { + base[i].Amount += item.Amount + continue + } + indexByName[item.Item] = len(base) + base = append(base, item) + } + return base +} + func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExpenseCategoryTotal, error) { var results []entities.ExpenseCategoryTotal