2025-07-18 20:10:29 +07:00
package repository
import (
"context"
2026-08-05 19:28:38 +07:00
"fmt"
2026-06-18 19:51:43 +07:00
"sort"
2025-07-18 20:10:29 +07:00
"time"
2026-08-11 22:41:10 +07:00
"apskel-pos-be/internal/constants"
2025-07-18 20:10:29 +07:00
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
"gorm.io/gorm"
)
type AnalyticsRepository interface {
GetPaymentMethodAnalytics ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , dateFrom , dateTo time . Time ) ([] * entities . PaymentMethodAnalytics , error )
GetSalesAnalytics ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , dateFrom , dateTo time . Time , groupBy string ) ([] * entities . SalesAnalytics , error )
2026-08-11 22:41:10 +07:00
GetPurchasingAnalytics ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , team * entities . PurchaseTeamFilter , dateFrom , dateTo time . Time , groupBy string ) ( * entities . PurchasingAnalytics , error )
2025-07-18 20:10:29 +07:00
GetProductAnalytics ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , dateFrom , dateTo time . Time , limit int ) ([] * entities . ProductAnalytics , error )
2025-08-14 00:45:14 +07:00
GetProductAnalyticsPerCategory ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , dateFrom , dateTo time . Time ) ([] * entities . ProductAnalyticsPerCategory , error )
2026-08-05 19:28:38 +07:00
GetProductAnalyticsPerParentCategory ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , dateFrom , dateTo time . Time ) ([] * entities . ProductAnalyticsPerParentCategory , error )
GetParentCategoryAnalyticsDetail ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , parentCategoryID uuid . UUID , dateFrom , dateTo time . Time ) ( * entities . ParentCategoryAnalyticsDetail , error )
GetBudgetCutOffWeekly ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , parentCategoryID * uuid . UUID , cutOffFrom , cutOffTo time . Time ) ([] * entities . BudgetCutOffWeek , error )
2025-07-18 20:10:29 +07:00
GetDashboardOverview ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , dateFrom , dateTo time . Time ) ( * entities . DashboardOverview , error )
2026-06-01 13:13:40 +07:00
GetProfitLossAnalytics ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , dateFrom , dateTo time . Time , groupBy string ) ( * entities . ProfitLossAnalytics , error )
2026-06-17 18:17:08 +07:00
GetExclusiveSummaryAnalytics ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , dateFrom , dateTo time . Time ) ( * entities . ExclusiveSummaryAnalytics , error )
2026-06-17 18:26:40 +07:00
GetExclusiveSummaryBankBalances ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID ) ([] entities . ExclusiveSummaryBankBalance , error )
2026-06-23 22:18:16 +07:00
GetOutletName ( ctx context . Context , organizationID uuid . UUID , outletID uuid . UUID ) ( string , error )
2025-07-18 20:10:29 +07:00
}
2026-09-06 23:06:31 +07:00
// A weight-based line is a single weighing: order_items.quantity is pinned to 1 and
// the weighed amount lives in order_items.weight, while unit_price and unit_cost are
// per unit of weight. Anything that multiplies or divides by a per-unit rate must use
// the weighed amount, not the row count, or a 4.2 ons fish is costed as a single ons.
// These mirror entities.OrderItem.BillableQuantity for aggregate SQL.
const (
// billableQty is the multiplier for a per-unit price or cost, before refunds.
billableQty = `COALESCE(oi.weight, oi.quantity)`
// billableQtyNet is billableQty after refunds. A weighed line is always refunded
// whole, so with quantity pinned to 1 the factor is the full weight or zero.
billableQtyNet = `COALESCE(oi.weight, 1) * (oi.quantity - COALESCE(oi.refund_quantity, 0))`
)
2025-07-18 20:10:29 +07:00
type AnalyticsRepositoryImpl struct {
db * gorm . DB
}
func NewAnalyticsRepositoryImpl ( db * gorm . DB ) * AnalyticsRepositoryImpl {
return & AnalyticsRepositoryImpl {
db : db ,
}
}
2026-05-12 21:47:35 +07:00
func ( r * AnalyticsRepositoryImpl ) resolveOutletID ( query * gorm . DB , outletID * uuid . UUID , column string ) * gorm . DB {
if outletID != nil {
return query . Where ( column + " = ?" , * outletID )
}
return query
}
2026-06-23 22:18:16 +07:00
func ( r * AnalyticsRepositoryImpl ) GetOutletName ( ctx context . Context , organizationID uuid . UUID , outletID uuid . UUID ) ( string , error ) {
var outlet struct {
Name string
}
result := r . db . WithContext ( ctx ).
Table ( "outlets" ).
Select ( "name" ).
Where ( "id = ? AND organization_id = ?" , outletID , organizationID ).
Limit ( 1 ).
Scan ( & outlet )
if result . Error != nil {
return "" , result . Error
}
return outlet . Name , nil
}
2026-06-18 15:44:15 +07:00
func purchaseOrderItemTotalAmountSQL () string {
return "CASE WHEN pc.type = '" + string ( entities . PurchaseCategoryTypeRawMaterial ) + "' THEN COALESCE(poi.quantity, 0) * poi.amount ELSE poi.amount END"
}
2026-06-18 19:51:43 +07:00
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"
}
2025-07-18 20:10:29 +07:00
func ( r * AnalyticsRepositoryImpl ) GetPaymentMethodAnalytics ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , dateFrom , dateTo time . Time ) ([] * entities . PaymentMethodAnalytics , error ) {
var results [] * entities . PaymentMethodAnalytics
query := r . db . WithContext ( ctx ).
Table ( "payments p" ).
Select ( `
pm.id as payment_method_id,
pm.name as payment_method_name,
pm.type as payment_method_type,
COALESCE(SUM(p.amount), 0) as total_amount,
COUNT(DISTINCT p.order_id) as order_count,
2026-09-30 15:31:44 +07:00
COUNT(p.id) as payment_count,
COALESCE(SUM(p.points_used), 0) as points_used
2025-07-18 20:10:29 +07:00
` ).
Joins ( "JOIN payment_methods pm ON p.payment_method_id = pm.id" ).
Joins ( "JOIN orders o ON p.order_id = o.id" ).
Where ( "o.organization_id = ?" , organizationID ).
2025-09-22 22:12:54 +07:00
Where ( "o.is_void = ?" , false ).
Where ( "o.is_refund = ?" , false ).
2025-07-18 20:10:29 +07:00
Where ( "p.status = ?" , entities . PaymentTransactionStatusCompleted ).
2025-09-22 22:12:54 +07:00
Where ( "o.created_at >= ? AND o.created_at <= ?" , dateFrom , dateTo )
2025-07-18 20:10:29 +07:00
2026-05-12 21:47:35 +07:00
query = r . resolveOutletID ( query , outletID , "o.outlet_id" )
2025-07-18 20:10:29 +07:00
err := query .
Group ( "pm.id, pm.name, pm.type" ).
Order ( "total_amount DESC" ).
Scan ( & results ). Error
return results , err
}
func ( r * AnalyticsRepositoryImpl ) GetSalesAnalytics ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , dateFrom , dateTo time . Time , groupBy string ) ([] * entities . SalesAnalytics , error ) {
var results [] * entities . SalesAnalytics
var dateFormat string
switch groupBy {
case "hour" :
dateFormat = "DATE_TRUNC('hour', o.created_at)"
case "week" :
dateFormat = "DATE_TRUNC('week', o.created_at)"
case "month" :
dateFormat = "DATE_TRUNC('month', o.created_at)"
default :
dateFormat = "DATE(o.created_at)"
}
2026-04-27 13:24:16 +07:00
outletFilter := ""
args := [] interface {}{ organizationID , false , false , string ( entities . PaymentStatusCompleted ), dateFrom , dateTo }
if outletID != nil {
outletFilter = "AND o.outlet_id = ?"
args = append ( args , * outletID )
}
rawQuery := `
SELECT
` + dateFormat + ` as date,
2025-07-18 20:10:29 +07:00
COALESCE(SUM(o.total_amount), 0) as sales,
COUNT(o.id) as orders,
2026-04-27 13:24:16 +07:00
COALESCE(SUM(oi_agg.total_items), 0) as items,
2025-07-18 20:10:29 +07:00
COALESCE(SUM(o.tax_amount), 0) as tax,
COALESCE(SUM(o.discount_amount), 0) as discount,
COALESCE(SUM(o.total_amount - o.tax_amount - o.discount_amount), 0) as net_sales
2026-04-27 13:24:16 +07:00
FROM orders o
LEFT JOIN (
SELECT
oi.order_id,
SUM(oi.quantity - COALESCE(oi.refund_quantity, 0)) as total_items
FROM order_items oi
WHERE oi.status != 'cancelled'
AND oi.is_fully_refunded = false
GROUP BY oi.order_id
) oi_agg ON oi_agg.order_id = o.id
WHERE o.organization_id = ?
AND o.is_void = ?
AND o.is_refund = ?
AND o.payment_status = ?
AND o.created_at >= ? AND o.created_at <= ?
` + outletFilter + `
GROUP BY date
ORDER BY date ASC
`
2025-07-18 20:10:29 +07:00
2026-04-27 13:24:16 +07:00
err := r . db . WithContext ( ctx ). Raw ( rawQuery , args ... ). Scan ( & results ). Error
2025-07-18 20:10:29 +07:00
return results , err
}
2026-08-11 22:41:10 +07:00
func ( r * AnalyticsRepositoryImpl ) GetPurchasingAnalytics ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , team * entities . PurchaseTeamFilter , dateFrom , dateTo time . Time , groupBy string ) ( * entities . PurchasingAnalytics , error ) {
2026-05-19 14:45:26 +07:00
var outletName * string
if outletID != nil {
var outlet struct {
Name string
}
result := r . db . WithContext ( ctx ).
Table ( "outlets" ).
Select ( "name" ).
Where ( "id = ? AND organization_id = ?" , * outletID , organizationID ).
Limit ( 1 ).
Scan ( & outlet )
if result . Error != nil {
return nil , result . Error
}
if result . RowsAffected > 0 {
outletName = & outlet . Name
}
}
2026-08-11 22:41:10 +07:00
return r . getPurchaseOrderPurchasingAnalytics ( ctx , organizationID , outletID , team , outletName , dateFrom , dateTo , groupBy )
2026-06-11 16:47:03 +07:00
}
2026-05-19 14:45:26 +07:00
2026-08-11 22:41:10 +07:00
func ( r * AnalyticsRepositoryImpl ) getPurchaseOrderPurchasingAnalytics ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , team * entities . PurchaseTeamFilter , outletName * string , dateFrom , dateTo time . Time , groupBy string ) ( * entities . PurchasingAnalytics , error ) {
2026-06-11 16:47:03 +07:00
var summary entities . PurchasingSummary
2026-06-11 15:57:19 +07:00
summaryQuery := r . db . WithContext ( ctx ).
2026-06-11 16:47:03 +07:00
Table ( "purchase_orders po" ).
2026-06-11 15:57:19 +07:00
Select ( `
2026-06-18 15:44:15 +07:00
COALESCE(SUM(` + purchaseOrderItemTotalAmountSQL () + `), 0) as total_purchases,
2026-06-18 19:51:43 +07:00
COALESCE(SUM(` + purchaseOrderRawMaterialAmountSQL () + `), 0) as raw_material_purchases,
COALESCE(SUM(` + purchaseOrderExpenseAmountSQL () + `), 0) as expense_purchases,
2026-06-11 16:47:03 +07:00
COUNT(DISTINCT po.id) as total_purchase_orders,
2026-06-18 19:51:43 +07:00
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,
2026-06-11 16:47:03 +07:00
COALESCE(SUM(poi.quantity), 0) as total_quantity,
2026-05-19 14:45:26 +07:00
CASE
2026-06-11 16:47:03 +07:00
WHEN COUNT(DISTINCT po.id) > 0
2026-06-18 15:44:15 +07:00
THEN COALESCE(SUM(` + purchaseOrderItemTotalAmountSQL () + `), 0) / COUNT(DISTINCT po.id)
2026-05-19 14:45:26 +07:00
ELSE 0
END as average_purchase_order_value,
2026-06-11 16:47:03 +07:00
COUNT(DISTINCT i.id) as total_ingredients,
2026-06-18 11:04:59 +07:00
COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) as total_vendors
2026-06-11 15:57:19 +07:00
` ).
2026-06-11 16:47:03 +07:00
Joins ( "LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id" ).
2026-06-18 15:44:15 +07:00
Joins ( "LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.id" ).
2026-06-11 16:47:03 +07:00
Joins ( "LEFT JOIN ingredients i ON poi.ingredient_id = i.id" ).
Joins ( "LEFT JOIN units u ON poi.unit_id = u.id" ).
Where ( "po.organization_id = ?" , organizationID ).
Where ( "po.status != ?" , "cancelled" ).
Where ( "po.transaction_date >= ? AND po.transaction_date <= ?" , dateFrom , dateTo )
summaryQuery = r . applyPurchaseOrderItemOutletFilter ( summaryQuery , outletID )
2026-08-11 22:41:10 +07:00
summaryQuery = r . applyPurchaseOrderTeamFilter ( summaryQuery , team )
2026-06-11 15:57:19 +07:00
if err := summaryQuery . Scan ( & summary ). Error ; err != nil {
2026-05-19 14:45:26 +07:00
return nil , err
}
var dateFormat string
switch groupBy {
case "hour" :
2026-06-11 16:47:03 +07:00
dateFormat = "DATE_TRUNC('hour', po.created_at)"
2026-05-19 14:45:26 +07:00
case "week" :
2026-06-11 16:47:03 +07:00
dateFormat = "DATE_TRUNC('week', po.transaction_date::timestamp)"
2026-05-19 14:45:26 +07:00
case "month" :
2026-06-11 16:47:03 +07:00
dateFormat = "DATE_TRUNC('month', po.transaction_date::timestamp)"
2026-05-19 14:45:26 +07:00
default :
2026-06-11 16:47:03 +07:00
dateFormat = "DATE_TRUNC('day', po.transaction_date::timestamp)"
2026-05-19 14:45:26 +07:00
}
var data [] entities . PurchasingAnalyticsData
2026-06-11 15:57:19 +07:00
dataQuery := r . db . WithContext ( ctx ).
2026-06-11 16:47:03 +07:00
Table ( "purchase_orders po" ).
2026-06-11 15:57:19 +07:00
Select ( `
` + dateFormat + ` as date,
2026-06-18 15:44:15 +07:00
COALESCE(SUM(` + purchaseOrderItemTotalAmountSQL () + `), 0) as purchases,
2026-06-18 19:51:43 +07:00
COALESCE(SUM(` + purchaseOrderRawMaterialAmountSQL () + `), 0) as raw_material_purchases,
COALESCE(SUM(` + purchaseOrderExpenseAmountSQL () + `), 0) as expense_purchases,
2026-06-11 16:47:03 +07:00
COUNT(DISTINCT po.id) as purchase_orders,
2026-06-18 19:51:43 +07:00
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,
2026-06-11 16:47:03 +07:00
COALESCE(SUM(poi.quantity), 0) as quantity,
COUNT(DISTINCT i.id) as ingredients,
2026-06-18 11:04:59 +07:00
COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) as vendors
2026-06-11 15:57:19 +07:00
` ).
2026-06-11 16:47:03 +07:00
Joins ( "LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id" ).
2026-06-18 15:44:15 +07:00
Joins ( "LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.id" ).
2026-06-11 16:47:03 +07:00
Joins ( "LEFT JOIN ingredients i ON poi.ingredient_id = i.id" ).
Joins ( "LEFT JOIN units u ON poi.unit_id = u.id" ).
Where ( "po.organization_id = ?" , organizationID ).
Where ( "po.status != ?" , "cancelled" ).
2026-06-18 19:51:43 +07:00
Where ( "pc.type = ?" , entities . PurchaseCategoryTypeRawMaterial ).
2026-06-11 16:47:03 +07:00
Where ( "po.transaction_date >= ? AND po.transaction_date <= ?" , dateFrom , dateTo ).
2026-06-11 15:57:19 +07:00
Group ( dateFormat ).
Order ( dateFormat )
2026-06-11 16:47:03 +07:00
dataQuery = r . applyPurchaseOrderItemOutletFilter ( dataQuery , outletID )
2026-08-11 22:41:10 +07:00
dataQuery = r . applyPurchaseOrderTeamFilter ( dataQuery , team )
2026-06-11 15:57:19 +07:00
if err := dataQuery . Scan ( & data ). Error ; err != nil {
2026-05-19 14:45:26 +07:00
return nil , err
}
var ingredientData [] entities . PurchasingIngredientData
ingredientQuery := r . db . WithContext ( ctx ).
2026-06-11 16:47:03 +07:00
Table ( "purchase_order_items poi" ).
2026-05-19 14:45:26 +07:00
Select ( `
i.id as ingredient_id,
i.name as ingredient_name,
2026-06-11 16:47:03 +07:00
COALESCE(SUM(poi.quantity), 0) as quantity,
2026-06-18 15:44:15 +07:00
COALESCE(SUM(` + purchaseOrderItemTotalAmountSQL () + `), 0) as total_cost,
2026-05-19 14:45:26 +07:00
CASE
2026-06-11 16:47:03 +07:00
WHEN SUM(poi.quantity) > 0
2026-06-18 15:44:15 +07:00
THEN COALESCE(SUM(` + purchaseOrderItemTotalAmountSQL () + `), 0) / SUM(poi.quantity)
2026-05-19 14:45:26 +07:00
ELSE 0
END as average_unit_cost,
2026-06-11 16:47:03 +07:00
COUNT(DISTINCT po.id) as purchase_order_count
2026-05-19 14:45:26 +07:00
` ).
2026-06-11 16:47:03 +07:00
Joins ( "JOIN purchase_orders po ON poi.purchase_order_id = po.id" ).
2026-06-18 15:44:15 +07:00
Joins ( "JOIN purchase_categories pc ON poi.purchase_category_id = pc.id" ).
2026-06-11 16:47:03 +07:00
Joins ( "JOIN ingredients i ON poi.ingredient_id = i.id" ).
Joins ( "LEFT JOIN units u ON poi.unit_id = u.id" ).
Where ( "po.organization_id = ?" , organizationID ).
Where ( "po.status != ?" , "cancelled" ).
Where ( "po.transaction_date >= ? AND po.transaction_date <= ?" , dateFrom , dateTo ).
2026-05-19 14:45:26 +07:00
Group ( "i.id, i.name" ).
Order ( "total_cost DESC" )
2026-06-11 16:47:03 +07:00
ingredientQuery = r . applyPurchaseOrderItemOutletFilter ( ingredientQuery , outletID )
2026-08-11 22:41:10 +07:00
ingredientQuery = r . applyPurchaseOrderTeamFilter ( ingredientQuery , team )
2026-05-19 14:45:26 +07:00
if err := ingredientQuery . Scan ( & ingredientData ). Error ; err != nil {
return nil , err
}
var vendorData [] entities . PurchasingVendorData
vendorQuery := r . db . WithContext ( ctx ).
2026-06-11 16:47:03 +07:00
Table ( "purchase_orders po" ).
2026-05-19 14:45:26 +07:00
Select ( `
v.id as vendor_id,
2026-06-18 11:04:59 +07:00
COALESCE(v.name, 'No Vendor') as vendor_name,
2026-06-18 15:44:15 +07:00
COALESCE(SUM(` + purchaseOrderItemTotalAmountSQL () + `), 0) as total_cost,
2026-06-11 16:47:03 +07:00
COUNT(DISTINCT po.id) as purchase_order_count,
COUNT(DISTINCT i.id) as ingredient_count,
COALESCE(SUM(poi.quantity), 0) as quantity
2026-05-19 14:45:26 +07:00
` ).
2026-06-18 11:04:59 +07:00
Joins ( "LEFT JOIN vendors v ON po.vendor_id = v.id" ).
2026-06-11 16:47:03 +07:00
Joins ( "LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id" ).
2026-06-18 15:44:15 +07:00
Joins ( "LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.id" ).
2026-06-11 16:47:03 +07:00
Joins ( "LEFT JOIN ingredients i ON poi.ingredient_id = i.id" ).
Joins ( "LEFT JOIN units u ON poi.unit_id = u.id" ).
Where ( "po.organization_id = ?" , organizationID ).
Where ( "po.status != ?" , "cancelled" ).
Where ( "po.transaction_date >= ? AND po.transaction_date <= ?" , dateFrom , dateTo ).
2026-06-18 11:04:59 +07:00
Group ( "v.id, COALESCE(v.name, 'No Vendor')" ).
2026-05-19 14:45:26 +07:00
Order ( "total_cost DESC" )
2026-06-11 16:47:03 +07:00
vendorQuery = r . applyPurchaseOrderItemOutletFilter ( vendorQuery , outletID )
2026-08-11 22:41:10 +07:00
vendorQuery = r . applyPurchaseOrderTeamFilter ( vendorQuery , team )
2026-05-19 14:45:26 +07:00
if err := vendorQuery . Scan ( & vendorData ). Error ; err != nil {
return nil , err
}
2026-08-11 22:41:10 +07:00
teamData , err := r . getPurchaseOrderTeamBreakdown ( ctx , organizationID , outletID , team , dateFrom , dateTo , summary . TotalPurchases )
if err != nil {
return nil , err
}
summary . TotalTeams = int64 ( len ( teamData ))
2026-05-19 14:45:26 +07:00
return & entities . PurchasingAnalytics {
OutletName : outletName ,
Summary : summary ,
Data : data ,
IngredientData : ingredientData ,
VendorData : vendorData ,
2026-08-11 22:41:10 +07:00
TeamData : teamData ,
2026-05-19 14:45:26 +07:00
}, nil
}
2026-08-11 22:41:10 +07:00
// getPurchaseOrderTeamBreakdown splits the purchases over the teams they were
// charged to. Purchases with no team are kept as their own row rather than
// dropped, so the rows still add up to the summary total.
func ( r * AnalyticsRepositoryImpl ) getPurchaseOrderTeamBreakdown ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , team * entities . PurchaseTeamFilter , dateFrom , dateTo time . Time , totalPurchases float64 ) ([] entities . PurchasingTeamData , error ) {
var rows [] struct {
Scope * string
CategoryID * uuid . UUID
CategoryName * string
TotalPurchases float64
RawMaterialPurchases float64
ExpensePurchases float64
PurchaseOrderCount int64
Quantity float64
}
query := r . db . WithContext ( ctx ).
Table ( "purchase_orders po" ).
Select ( `
po.team_scope as scope,
po.team_category_id as category_id,
c.name as category_name,
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 purchase_order_count,
COALESCE(SUM(poi.quantity), 0) as quantity
` ).
Joins ( "LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id" ).
Joins ( "LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.id" ).
Joins ( "LEFT JOIN categories c ON po.team_category_id = c.id" ).
Where ( "po.organization_id = ?" , organizationID ).
Where ( "po.status != ?" , "cancelled" ).
Where ( "po.transaction_date >= ? AND po.transaction_date <= ?" , dateFrom , dateTo ).
Group ( "po.team_scope, po.team_category_id, c.name" ).
Order ( "total_purchases DESC" )
query = r . applyPurchaseOrderItemOutletFilter ( query , outletID )
query = r . applyPurchaseOrderTeamFilter ( query , team )
if err := query . Scan ( & rows ). Error ; err != nil {
return nil , err
}
teamData := make ([] entities . PurchasingTeamData , len ( rows ))
for i , row := range rows {
entry := entities . PurchasingTeamData {
CategoryID : row . CategoryID ,
TotalPurchases : row . TotalPurchases ,
RawMaterialPurchases : row . RawMaterialPurchases ,
ExpensePurchases : row . ExpensePurchases ,
PurchaseOrderCount : row . PurchaseOrderCount ,
Quantity : row . Quantity ,
}
switch {
case row . Scope == nil :
entry . Scope = constants . PurchaseTeamNone
entry . Name = constants . PurchaseTeamNoneName
case * row . Scope == constants . PurchaseTeamScopeCentral :
entry . Scope = constants . PurchaseTeamScopeCentral
entry . Name = constants . PurchaseTeamCentralName
default :
entry . Scope = * row . Scope
if row . CategoryName != nil {
entry . Name = * row . CategoryName
}
}
if totalPurchases != 0 {
entry . Percentage = row . TotalPurchases / totalPurchases * 100
}
teamData [ i ] = entry
}
return teamData , nil
}
2026-06-11 16:47:03 +07:00
func ( r * AnalyticsRepositoryImpl ) applyPurchaseOrderItemOutletFilter ( query * gorm . DB , outletID * uuid . UUID ) * gorm . DB {
if outletID == nil {
return query
}
2026-06-18 15:27:20 +07:00
return query . Where ( "po.outlet_id = ?" , * outletID )
2026-06-11 16:47:03 +07:00
}
2026-08-11 22:41:10 +07:00
// applyPurchaseOrderTeamFilter narrows a purchase order query to the team the
// report asked for. A nil filter, or a category team without a category, leaves
// the query spanning every team.
func ( r * AnalyticsRepositoryImpl ) applyPurchaseOrderTeamFilter ( query * gorm . DB , team * entities . PurchaseTeamFilter ) * gorm . DB {
if team == nil {
return query
}
switch team . Scope {
case constants . PurchaseTeamNone :
return query . Where ( "po.team_scope IS NULL" )
case constants . PurchaseTeamScopeCentral :
return query . Where ( "po.team_scope = ?" , constants . PurchaseTeamScopeCentral )
case constants . PurchaseTeamScopeCategory :
if team . CategoryID != nil {
return query . Where ( "po.team_scope = ? AND po.team_category_id = ?" , constants . PurchaseTeamScopeCategory , * team . CategoryID )
}
}
return query
}
2025-07-18 20:10:29 +07:00
func ( r * AnalyticsRepositoryImpl ) GetProductAnalytics ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , dateFrom , dateTo time . Time , limit int ) ([] * entities . ProductAnalytics , error ) {
var results [] * entities . ProductAnalytics
2025-11-25 13:23:59 +07:00
query := r . db . WithContext ( ctx ).
Table ( "order_items oi" ).
2025-07-18 20:10:29 +07:00
Select ( `
p.id as product_id,
2025-11-25 13:23:59 +07:00
p.name as product_name,
2026-06-22 13:33:25 +07:00
p.sku as product_sku,
COALESCE(
NULLIF(pop.price, 0),
(SELECT price FROM product_outlet_prices WHERE product_id = p.id ORDER BY updated_at DESC LIMIT 1),
NULLIF(p.price, 0),
0
) as product_price,
2025-11-25 13:23:59 +07:00
c.id as category_id,
c.name as category_name,
c.order as category_order,
COALESCE(SUM(oi.quantity), 0) as quantity_sold,
2026-09-06 17:14:42 +07:00
COALESCE(SUM(oi.weight), 0) as weight_sold,
2025-11-25 13:23:59 +07:00
COALESCE(SUM(oi.total_price), 0) as revenue,
2025-07-18 20:10:29 +07:00
CASE
2026-09-30 15:31:44 +07:00
WHEN SUM(` + billableQty + `) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(` + billableQty + `)
2025-07-18 20:10:29 +07:00
ELSE 0
END as average_price,
2026-04-24 17:56:38 +07:00
COUNT(DISTINCT oi.order_id) as order_count,
COALESCE((
SELECT SUM(pr.quantity * (1 + COALESCE(pr.waste_percentage, 0)/100.0) * i.cost)
FROM product_recipes pr
JOIN ingredients i ON pr.ingredient_id = i.id
WHERE pr.product_id = p.id
), p.cost, 0) as standard_hpp_per_unit,
COALESCE((
SELECT SUM(pr.quantity * (1 + COALESCE(pr.waste_percentage, 0)/100.0) * i.cost)
FROM product_recipes pr
JOIN ingredients i ON pr.ingredient_id = i.id
WHERE pr.product_id = p.id
2026-09-30 15:31:44 +07:00
), p.cost, 0) * COALESCE(SUM(` + billableQty + `), 0) as standard_hpp_total,
2026-04-24 17:56:38 +07:00
CASE
2026-09-30 15:31:44 +07:00
WHEN SUM(` + billableQty + `) > 0 THEN COALESCE(SUM(oi.total_cost), 0) / SUM(` + billableQty + `)
2026-04-24 17:56:38 +07:00
ELSE 0
END as fifo_hpp_per_unit,
COALESCE(SUM(oi.total_cost), 0) as fifo_hpp_total,
COALESCE(mahpp.hpp_per_unit, p.cost, 0) as moving_average_hpp_per_unit,
2026-09-30 15:31:44 +07:00
COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(` + billableQty + `), 0) as moving_average_hpp_total
2025-07-18 20:10:29 +07:00
` ).
Joins ( "JOIN products p ON oi.product_id = p.id" ).
2025-11-25 13:23:59 +07:00
Joins ( "JOIN categories c ON p.category_id = c.id" ).
2025-07-18 20:10:29 +07:00
Joins ( "JOIN orders o ON oi.order_id = o.id" ).
2026-06-22 13:33:25 +07:00
Joins ( "LEFT JOIN product_outlet_prices pop ON pop.product_id = p.id AND pop.outlet_id = o.outlet_id" ).
2026-04-24 17:56:38 +07:00
Joins ( "LEFT JOIN (?) mahpp ON mahpp.product_id = p.id" ,
r . db . Table ( "product_recipes pr2" ).
Select ( "pr2.product_id, SUM(pr2.quantity * (1 + COALESCE(pr2.waste_percentage, 0)/100.0) * COALESCE(ma.moving_avg_cost, ing.cost)) as hpp_per_unit" ).
Joins ( "JOIN ingredients ing ON pr2.ingredient_id = ing.id" ).
Joins ( "LEFT JOIN (?) ma ON ma.ingredient_id = pr2.ingredient_id" ,
r . db . Table ( "inventory_movements im" ).
Select ( "im.item_id as ingredient_id, CASE WHEN SUM(im.quantity) > 0 THEN SUM(im.total_cost) / SUM(im.quantity) ELSE 0 END as moving_avg_cost" ).
Where ( "im.movement_type = ?" , "purchase" ).
Where ( "im.item_type = ?" , "INGREDIENT" ).
Where ( "im.organization_id = ?" , organizationID ).
Where ( "im.created_at <= ?" , dateTo ).
Group ( "im.item_id" ),
).
Group ( "pr2.product_id" ),
).
2025-07-18 20:10:29 +07:00
Where ( "o.organization_id = ?" , organizationID ).
Where ( "o.is_void = ?" , false ).
2025-11-25 21:44:20 +07:00
Where ( "o.is_refund = ?" , false ).
Where ( "o.payment_status = ?" , entities . PaymentStatusCompleted ).
Where ( "oi.status != ?" , entities . OrderItemStatusCancelled ).
2025-07-18 20:10:29 +07:00
Where ( "o.created_at >= ? AND o.created_at <= ?" , dateFrom , dateTo )
2026-05-12 21:47:35 +07:00
query = r . resolveOutletID ( query , outletID , "o.outlet_id" )
2025-07-18 20:10:29 +07:00
err := query .
2026-06-22 13:33:25 +07:00
Group ( "p.id, p.name, p.sku, p.price, p.cost, pop.price, c.id, c.name, c.order, mahpp.hpp_per_unit" ).
2025-11-25 13:23:59 +07:00
Order ( "revenue DESC" ).
2025-07-18 20:10:29 +07:00
Limit ( limit ).
Scan ( & results ). Error
return results , err
}
2025-08-14 00:45:14 +07:00
func ( r * AnalyticsRepositoryImpl ) GetProductAnalyticsPerCategory ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , dateFrom , dateTo time . Time ) ([] * entities . ProductAnalyticsPerCategory , error ) {
var results [] * entities . ProductAnalyticsPerCategory
query := r . db . WithContext ( ctx ).
Table ( "order_items oi" ).
Select ( `
c.id as category_id,
c.name as category_name,
2025-09-22 22:12:54 +07:00
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as total_revenue,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity,
2025-08-14 00:45:14 +07:00
COUNT(DISTINCT p.id) as product_count,
2026-04-24 17:56:38 +07:00
COUNT(DISTINCT oi.order_id) as order_count,
2026-09-30 15:31:44 +07:00
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp,
2026-04-24 17:56:38 +07:00
COALESCE(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), 0) as total_fifo_hpp,
2026-09-30 15:31:44 +07:00
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp
2025-08-14 00:45:14 +07:00
` ).
Joins ( "JOIN products p ON oi.product_id = p.id" ).
Joins ( "JOIN categories c ON p.category_id = c.id" ).
Joins ( "JOIN orders o ON oi.order_id = o.id" ).
2026-04-24 17:56:38 +07:00
Joins ( "LEFT JOIN (SELECT pr.product_id, SUM(pr.quantity * (1 + COALESCE(pr.waste_percentage, 0)/100.0) * i.cost) as hpp_per_unit FROM product_recipes pr JOIN ingredients i ON pr.ingredient_id = i.id GROUP BY pr.product_id) shpp ON shpp.product_id = p.id" ).
Joins ( "LEFT JOIN (?) mahpp ON mahpp.product_id = p.id" ,
r . db . Table ( "product_recipes pr2" ).
Select ( "pr2.product_id, SUM(pr2.quantity * (1 + COALESCE(pr2.waste_percentage, 0)/100.0) * COALESCE(ma.moving_avg_cost, ing.cost)) as hpp_per_unit" ).
Joins ( "JOIN ingredients ing ON pr2.ingredient_id = ing.id" ).
Joins ( "LEFT JOIN (?) ma ON ma.ingredient_id = pr2.ingredient_id" ,
r . db . Table ( "inventory_movements im" ).
Select ( "im.item_id as ingredient_id, CASE WHEN SUM(im.quantity) > 0 THEN SUM(im.total_cost) / SUM(im.quantity) ELSE 0 END as moving_avg_cost" ).
Where ( "im.movement_type = ?" , "purchase" ).
Where ( "im.item_type = ?" , "INGREDIENT" ).
Where ( "im.organization_id = ?" , organizationID ).
Where ( "im.created_at <= ?" , dateTo ).
Group ( "im.item_id" ),
).
Group ( "pr2.product_id" ),
).
2025-08-14 00:45:14 +07:00
Where ( "o.organization_id = ?" , organizationID ).
Where ( "o.is_void = ?" , false ).
2025-09-22 22:12:54 +07:00
Where ( "o.is_refund = ?" , false ).
Where ( "o.payment_status = ?" , entities . PaymentStatusCompleted ).
Where ( "oi.status != ?" , entities . OrderItemStatusCancelled ).
2025-08-14 00:45:14 +07:00
Where ( "o.created_at >= ? AND o.created_at <= ?" , dateFrom , dateTo )
2026-05-12 21:47:35 +07:00
query = r . resolveOutletID ( query , outletID , "o.outlet_id" )
2025-08-14 00:45:14 +07:00
err := query .
Group ( "c.id, c.name" ).
2025-09-25 20:31:57 +07:00
Order ( "c.name ASC" ).
2025-08-14 00:45:14 +07:00
Scan ( & results ). Error
return results , err
}
2026-08-05 19:28:38 +07:00
func ( r * AnalyticsRepositoryImpl ) GetProductAnalyticsPerParentCategory ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , dateFrom , dateTo time . Time ) ([] * entities . ProductAnalyticsPerParentCategory , error ) {
var results [] * entities . ProductAnalyticsPerParentCategory
query := r . db . WithContext ( ctx ).
Table ( "order_items oi" ).
Select ( `
pc.id as parent_category_id,
pc.name as parent_category_name,
2026-10-02 23:30:03 +07:00
COALESCE(pc.owner_fee_percent, ?) as owner_fee_percent,
2026-08-05 19:28:38 +07:00
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as total_revenue,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity,
COUNT(DISTINCT c.id) as category_count,
COUNT(DISTINCT p.id) as product_count,
COUNT(DISTINCT oi.order_id) as order_count,
2026-09-30 15:31:44 +07:00
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp,
2026-08-05 19:28:38 +07:00
COALESCE(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), 0) as total_fifo_hpp,
2026-09-30 15:31:44 +07:00
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp
2026-10-02 23:30:03 +07:00
` , constants . BudgetLimitOwnerPercent ).
2026-08-05 19:28:38 +07:00
Joins ( "JOIN products p ON oi.product_id = p.id" ).
Joins ( "JOIN categories c ON p.category_id = c.id" ).
// Categories without a parent roll up to themselves, so top-level categories still appear
Joins ( "JOIN categories pc ON pc.id = COALESCE(c.parent_id, c.id)" ).
Joins ( "JOIN orders o ON oi.order_id = o.id" ).
Joins ( "LEFT JOIN (SELECT pr.product_id, SUM(pr.quantity * (1 + COALESCE(pr.waste_percentage, 0)/100.0) * i.cost) as hpp_per_unit FROM product_recipes pr JOIN ingredients i ON pr.ingredient_id = i.id GROUP BY pr.product_id) shpp ON shpp.product_id = p.id" ).
Joins ( "LEFT JOIN (?) mahpp ON mahpp.product_id = p.id" ,
r . db . Table ( "product_recipes pr2" ).
Select ( "pr2.product_id, SUM(pr2.quantity * (1 + COALESCE(pr2.waste_percentage, 0)/100.0) * COALESCE(ma.moving_avg_cost, ing.cost)) as hpp_per_unit" ).
Joins ( "JOIN ingredients ing ON pr2.ingredient_id = ing.id" ).
Joins ( "LEFT JOIN (?) ma ON ma.ingredient_id = pr2.ingredient_id" ,
r . db . Table ( "inventory_movements im" ).
Select ( "im.item_id as ingredient_id, CASE WHEN SUM(im.quantity) > 0 THEN SUM(im.total_cost) / SUM(im.quantity) ELSE 0 END as moving_avg_cost" ).
Where ( "im.movement_type = ?" , "purchase" ).
Where ( "im.item_type = ?" , "INGREDIENT" ).
Where ( "im.organization_id = ?" , organizationID ).
Where ( "im.created_at <= ?" , dateTo ).
Group ( "im.item_id" ),
).
Group ( "pr2.product_id" ),
).
Where ( "o.organization_id = ?" , organizationID ).
Where ( "o.is_void = ?" , false ).
Where ( "o.is_refund = ?" , false ).
Where ( "o.payment_status = ?" , entities . PaymentStatusCompleted ).
Where ( "oi.status != ?" , entities . OrderItemStatusCancelled ).
Where ( "o.created_at >= ? AND o.created_at <= ?" , dateFrom , dateTo )
query = r . resolveOutletID ( query , outletID , "o.outlet_id" )
err := query .
2026-10-02 23:30:03 +07:00
Group ( "pc.id, pc.name, pc.owner_fee_percent" ).
2026-08-05 19:28:38 +07:00
Order ( "pc.name ASC" ).
Scan ( & results ). Error
2026-10-02 23:30:03 +07:00
if err != nil || len ( results ) == 0 {
return results , err
}
2026-08-05 19:28:38 +07:00
2026-10-02 23:30:03 +07:00
// Best-selling product of each parent category. DISTINCT ON keeps the first row per
// parent category, which the ordering makes the one with the highest revenue.
var topProducts [] * entities . ParentCategoryTopProduct
topQuery := r . db . WithContext ( ctx ).
Table ( "order_items oi" ).
Select ( `
DISTINCT ON (pc.id)
pc.id as parent_category_id,
p.id as product_id,
p.name as product_name,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as quantity_sold,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as revenue
` ).
Joins ( "JOIN products p ON oi.product_id = p.id" ).
Joins ( "JOIN categories c ON p.category_id = c.id" ).
Joins ( "JOIN categories pc ON pc.id = COALESCE(c.parent_id, c.id)" ).
Joins ( "JOIN orders o ON oi.order_id = o.id" ).
Where ( "o.organization_id = ?" , organizationID ).
Where ( "o.is_void = ?" , false ).
Where ( "o.is_refund = ?" , false ).
Where ( "o.payment_status = ?" , entities . PaymentStatusCompleted ).
Where ( "oi.status != ?" , entities . OrderItemStatusCancelled ).
Where ( "o.created_at >= ? AND o.created_at <= ?" , dateFrom , dateTo )
topQuery = r . resolveOutletID ( topQuery , outletID , "o.outlet_id" )
err = topQuery .
Group ( "pc.id, p.id, p.name" ).
Order ( "pc.id, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) DESC, p.name ASC" ).
Scan ( & topProducts ). Error
if err != nil {
return nil , err
}
topByParent := make ( map [ uuid . UUID ] * entities . ParentCategoryTopProduct , len ( topProducts ))
for _ , product := range topProducts {
topByParent [ product . ParentCategoryID ] = product
}
for _ , result := range results {
result . TopProduct = topByParent [ result . ParentCategoryID ]
}
return results , nil
2026-08-05 19:28:38 +07:00
}
// movingAverageHppSubquery builds the per-product moving-average HPP lookup shared by
// the parent category detail queries.
func ( r * AnalyticsRepositoryImpl ) movingAverageHppSubquery ( organizationID uuid . UUID , dateTo time . Time ) * gorm . DB {
return r . db . Table ( "product_recipes pr2" ).
Select ( "pr2.product_id, SUM(pr2.quantity * (1 + COALESCE(pr2.waste_percentage, 0)/100.0) * COALESCE(ma.moving_avg_cost, ing.cost)) as hpp_per_unit" ).
Joins ( "JOIN ingredients ing ON pr2.ingredient_id = ing.id" ).
Joins ( "LEFT JOIN (?) ma ON ma.ingredient_id = pr2.ingredient_id" ,
r . db . Table ( "inventory_movements im" ).
Select ( "im.item_id as ingredient_id, CASE WHEN SUM(im.quantity) > 0 THEN SUM(im.total_cost) / SUM(im.quantity) ELSE 0 END as moving_avg_cost" ).
Where ( "im.movement_type = ?" , "purchase" ).
Where ( "im.item_type = ?" , "INGREDIENT" ).
Where ( "im.organization_id = ?" , organizationID ).
Where ( "im.created_at <= ?" , dateTo ).
Group ( "im.item_id" ),
).
Group ( "pr2.product_id" )
}
// parentCategoryScopedQuery builds the common order_items -> product -> category join
// restricted to a single parent category group. Categories without a parent belong to
// their own group, so a leaf category resolves to itself.
func ( r * AnalyticsRepositoryImpl ) parentCategoryScopedQuery ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , parentCategoryID uuid . UUID , dateFrom , dateTo time . Time ) * gorm . DB {
query := r . db . WithContext ( ctx ).
Table ( "order_items oi" ).
Joins ( "JOIN products p ON oi.product_id = p.id" ).
Joins ( "JOIN categories c ON p.category_id = c.id" ).
Joins ( "JOIN orders o ON oi.order_id = o.id" ).
Joins ( "LEFT JOIN (SELECT pr.product_id, SUM(pr.quantity * (1 + COALESCE(pr.waste_percentage, 0)/100.0) * i.cost) as hpp_per_unit FROM product_recipes pr JOIN ingredients i ON pr.ingredient_id = i.id GROUP BY pr.product_id) shpp ON shpp.product_id = p.id" ).
Joins ( "LEFT JOIN (?) mahpp ON mahpp.product_id = p.id" , r . movingAverageHppSubquery ( organizationID , dateTo )).
Where ( "COALESCE(c.parent_id, c.id) = ?" , parentCategoryID ).
Where ( "o.organization_id = ?" , organizationID ).
Where ( "o.is_void = ?" , false ).
Where ( "o.is_refund = ?" , false ).
Where ( "o.payment_status = ?" , entities . PaymentStatusCompleted ).
Where ( "oi.status != ?" , entities . OrderItemStatusCancelled ).
Where ( "o.created_at >= ? AND o.created_at <= ?" , dateFrom , dateTo )
return r . resolveOutletID ( query , outletID , "o.outlet_id" )
}
func ( r * AnalyticsRepositoryImpl ) GetParentCategoryAnalyticsDetail ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , parentCategoryID uuid . UUID , dateFrom , dateTo time . Time ) ( * entities . ParentCategoryAnalyticsDetail , error ) {
// Resolve the category first so the endpoint still identifies the category when it
// has no sales in the requested range, and rejects ids from another organization.
var parent struct {
2026-10-02 23:30:03 +07:00
ID uuid . UUID
Name string
OwnerFeePercent float64
2026-08-05 19:28:38 +07:00
}
if err := r . db . WithContext ( ctx ).
Table ( "categories" ).
2026-10-02 23:30:03 +07:00
Select ( "id, name, COALESCE(owner_fee_percent, ?) as owner_fee_percent" , constants . BudgetLimitOwnerPercent ).
2026-08-05 19:28:38 +07:00
Where ( "id = ? AND organization_id = ?" , parentCategoryID , organizationID ).
Scan ( & parent ). Error ; err != nil {
return nil , err
}
if parent . ID == uuid . Nil {
return nil , fmt . Errorf ( "category not found" )
}
detail := & entities . ParentCategoryAnalyticsDetail {
ParentCategoryID : parent . ID ,
ParentCategoryName : parent . Name ,
Categories : [] * entities . ProductAnalyticsPerCategory {},
Products : [] * entities . ProductAnalytics {},
}
// Totals for the whole parent group. Kept as its own aggregate because order_count
// is a COUNT(DISTINCT order) and cannot be recovered by summing the category rows.
summary := & entities . ProductAnalyticsPerParentCategory {}
err := r . parentCategoryScopedQuery ( ctx , organizationID , outletID , parentCategoryID , dateFrom , dateTo ).
Select ( `
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as total_revenue,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity,
COUNT(DISTINCT c.id) as category_count,
COUNT(DISTINCT p.id) as product_count,
COUNT(DISTINCT oi.order_id) as order_count,
2026-09-06 23:06:31 +07:00
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp,
2026-08-05 19:28:38 +07:00
COALESCE(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), 0) as total_fifo_hpp,
2026-09-06 23:06:31 +07:00
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp
2026-08-05 19:28:38 +07:00
` ).
Scan ( summary ). Error
if err != nil {
return nil , err
}
summary . ParentCategoryID = parent . ID
summary . ParentCategoryName = parent . Name
2026-10-02 23:30:03 +07:00
summary . OwnerFeePercent = parent . OwnerFeePercent
2026-08-05 19:28:38 +07:00
detail . Summary = summary
// Sub-category rows.
err = r . parentCategoryScopedQuery ( ctx , organizationID , outletID , parentCategoryID , dateFrom , dateTo ).
Select ( `
c.id as category_id,
c.name as category_name,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as total_revenue,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity,
COUNT(DISTINCT p.id) as product_count,
COUNT(DISTINCT oi.order_id) as order_count,
2026-09-06 23:06:31 +07:00
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp,
2026-08-05 19:28:38 +07:00
COALESCE(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), 0) as total_fifo_hpp,
2026-09-06 23:06:31 +07:00
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp
2026-08-05 19:28:38 +07:00
` ).
Group ( "c.id, c.name, c.order" ).
Order ( "c.order ASC, c.name ASC" ).
Scan ( & detail . Categories ). Error
if err != nil {
return nil , err
}
// Product rows. Uses the same refund-aware arithmetic as the rows above so the
// products of a category add up to that category's totals.
err = r . parentCategoryScopedQuery ( ctx , organizationID , outletID , parentCategoryID , dateFrom , dateTo ).
Joins ( "LEFT JOIN product_outlet_prices pop ON pop.product_id = p.id AND pop.outlet_id = o.outlet_id" ).
Select ( `
p.id as product_id,
p.name as product_name,
p.sku as product_sku,
COALESCE(
NULLIF(pop.price, 0),
(SELECT price FROM product_outlet_prices WHERE product_id = p.id ORDER BY updated_at DESC LIMIT 1),
NULLIF(p.price, 0),
0
) as product_price,
c.id as category_id,
c.name as category_name,
c.order as category_order,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as quantity_sold,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as revenue,
COALESCE(
SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END)
2026-09-06 23:06:31 +07:00
/ NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN ` + billableQtyNet + ` ELSE 0 END), 0),
2026-08-05 19:28:38 +07:00
0) as average_price,
COUNT(DISTINCT oi.order_id) as order_count,
COALESCE(shpp.hpp_per_unit, p.cost, 0) as standard_hpp_per_unit,
2026-09-06 23:06:31 +07:00
COALESCE(shpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN ` + billableQtyNet + ` ELSE 0 END), 0) as standard_hpp_total,
2026-08-05 19:28:38 +07:00
COALESCE(
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)
2026-09-06 23:06:31 +07:00
/ NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN ` + billableQtyNet + ` ELSE 0 END), 0),
2026-08-05 19:28:38 +07:00
0) as fifo_hpp_per_unit,
COALESCE(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), 0) as fifo_hpp_total,
COALESCE(mahpp.hpp_per_unit, p.cost, 0) as moving_average_hpp_per_unit,
2026-09-06 23:06:31 +07:00
COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN ` + billableQtyNet + ` ELSE 0 END), 0) as moving_average_hpp_total
2026-08-05 19:28:38 +07:00
` ).
Group ( "p.id, p.name, p.sku, p.price, p.cost, pop.price, c.id, c.name, c.order, shpp.hpp_per_unit, mahpp.hpp_per_unit" ).
Order ( "revenue DESC" ).
Scan ( & detail . Products ). Error
if err != nil {
return nil , err
}
return detail , nil
}
// GetBudgetCutOffWeekly buckets revenue and cost of goods sold into Monday-to-Sunday
// weeks. DATE_TRUNC('week') is ISO, so the buckets start on Monday, and the connection
// runs with TimeZone=Asia/Jakarta so the boundaries land on local midnight.
// A nil parentCategoryID covers every category in scope.
func ( r * AnalyticsRepositoryImpl ) GetBudgetCutOffWeekly ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , parentCategoryID * uuid . UUID , cutOffFrom , cutOffTo time . Time ) ([] * entities . BudgetCutOffWeek , error ) {
var results [] * entities . BudgetCutOffWeek
query := r . db . WithContext ( ctx ).
Table ( "order_items oi" ).
Select ( `
DATE_TRUNC('week', o.created_at) as week_start,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as revenue,
2026-10-02 23:30:03 +07:00
COALESCE(SUM((CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END) * COALESCE(pc.owner_fee_percent, ?) / 100.0), 0) as sdl,
2026-08-05 19:28:38 +07:00
COUNT(DISTINCT oi.order_id) as order_count
2026-10-02 23:30:03 +07:00
` , constants . BudgetLimitOwnerPercent ).
2026-08-05 19:28:38 +07:00
// products and categories are joined to keep the scope identical to the report
// the block is attached to, even when no parent category filter is applied
Joins ( "JOIN products p ON oi.product_id = p.id" ).
Joins ( "JOIN categories c ON p.category_id = c.id" ).
2026-10-02 23:30:03 +07:00
// The owner fee lives on the parent category; a category without a parent is its own group
Joins ( "JOIN categories pc ON pc.id = COALESCE(c.parent_id, c.id)" ).
2026-08-05 19:28:38 +07:00
Joins ( "JOIN orders o ON oi.order_id = o.id" ).
Where ( "o.organization_id = ?" , organizationID ).
Where ( "o.is_void = ?" , false ).
Where ( "o.is_refund = ?" , false ).
Where ( "o.payment_status = ?" , entities . PaymentStatusCompleted ).
Where ( "oi.status != ?" , entities . OrderItemStatusCancelled ).
Where ( "o.created_at >= ? AND o.created_at <= ?" , cutOffFrom , cutOffTo )
if parentCategoryID != nil {
query = query . Where ( "COALESCE(c.parent_id, c.id) = ?" , * parentCategoryID )
}
query = r . resolveOutletID ( query , outletID , "o.outlet_id" )
err := query .
Group ( "DATE_TRUNC('week', o.created_at)" ).
Order ( "week_start ASC" ).
Scan ( & results ). Error
return results , err
}
2025-07-18 20:10:29 +07:00
func ( r * AnalyticsRepositoryImpl ) GetDashboardOverview ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , dateFrom , dateTo time . Time ) ( * entities . DashboardOverview , error ) {
var result entities . DashboardOverview
query := r . db . WithContext ( ctx ).
Table ( "orders o" ).
Select ( `
2025-10-15 18:55:35 +07:00
COALESCE(SUM(CASE WHEN o.is_void = false AND o.is_refund = false AND o.payment_status = 'completed' THEN o.total_amount ELSE 0 END), 0) as total_sales,
2025-07-18 20:10:29 +07:00
COUNT(CASE WHEN o.is_void = false THEN o.id END) as total_orders,
CASE
WHEN COUNT(CASE WHEN o.is_void = false THEN o.id END) > 0
THEN COALESCE(SUM(CASE WHEN o.is_void = false THEN o.total_amount ELSE 0 END), 0) / COUNT(CASE WHEN o.is_void = false THEN o.id END)
ELSE 0
END as average_order_value,
COUNT(DISTINCT o.customer_id) as total_customers,
COUNT(CASE WHEN o.is_void = true THEN o.id END) as voided_orders,
COUNT(CASE WHEN o.is_refund = true THEN o.id END) as refunded_orders
` ).
Where ( "o.organization_id = ?" , organizationID ).
Where ( "o.created_at >= ? AND o.created_at <= ?" , dateFrom , dateTo )
2026-05-12 21:47:35 +07:00
query = r . resolveOutletID ( query , outletID , "o.outlet_id" )
2025-07-18 20:10:29 +07:00
err := query . Scan ( & result ). Error
if err != nil {
return nil , err
}
2026-06-23 22:18:16 +07:00
// Total item sold (sum of order_items quantity for completed orders in date range)
var totalItemSold int64
itemQuery := r . db . WithContext ( ctx ).
Table ( "order_items oi" ).
Select ( "COALESCE(SUM(oi.quantity), 0)" ).
Joins ( "JOIN orders o ON o.id = oi.order_id" ).
Where ( "o.organization_id = ?" , organizationID ).
Where ( "o.is_void = false AND o.is_refund = false AND o.payment_status = 'completed'" ).
Where ( "o.created_at >= ? AND o.created_at <= ?" , dateFrom , dateTo )
itemQuery = r . resolveOutletID ( itemQuery , outletID , "o.outlet_id" )
itemQuery . Scan ( & totalItemSold )
result . TotalItemSold = totalItemSold
// Total low stock (inventory where quantity <= reorder_level)
var totalLowStock int64
lowStockQuery := r . db . WithContext ( ctx ).
Table ( "inventory i" ).
Select ( "COUNT(i.id)" ).
Joins ( "JOIN products p ON p.id = i.product_id" ).
Where ( "p.organization_id = ?" , organizationID ).
Where ( "i.quantity <= i.reorder_level" )
lowStockQuery = r . resolveOutletID ( lowStockQuery , outletID , "i.outlet_id" )
lowStockQuery . Scan ( & totalLowStock )
result . TotalLowStock = totalLowStock
// Total active products
var totalProductActive int64
productQuery := r . db . WithContext ( ctx ).
Table ( "products p" ).
Select ( "COUNT(p.id)" ).
Where ( "p.organization_id = ?" , organizationID ).
Where ( "p.is_active = true" )
productQuery . Scan ( & totalProductActive )
result . TotalProductActive = totalProductActive
2025-07-18 20:10:29 +07:00
return & result , nil
}
2026-06-01 13:13:40 +07:00
func ( r * AnalyticsRepositoryImpl ) GetProfitLossAnalytics ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , dateFrom , dateTo time . Time , groupBy string ) ( * entities . ProfitLossAnalytics , error ) {
2026-05-29 18:24:14 +07:00
mtdStart := time . Date ( dateTo . Year (), dateTo . Month (), 1 , 0 , 0 , 0 , 0 , dateTo . Location ())
todayStart := time . Date ( dateTo . Year (), dateTo . Month (), dateTo . Day (), 0 , 0 , 0 , 0 , dateTo . Location ())
2026-05-26 14:59:56 +07:00
todayEnd := todayStart . Add ( 24 * time . Hour ). Add ( - time . Nanosecond )
2025-07-18 20:10:29 +07:00
2026-06-01 13:13:40 +07:00
var summary entities . ProfitLossSummary
summaryQuery := r . db . WithContext ( ctx ).
Table ( "orders o" ).
Select ( `
COALESCE(SUM(o.total_amount), 0) as total_revenue,
COALESCE(SUM(o.total_cost), 0) as total_cost,
COALESCE(SUM(o.total_amount - o.total_cost), 0) as gross_profit,
CASE
WHEN SUM(o.total_amount) > 0
THEN (SUM(o.total_amount - o.total_cost) / SUM(o.total_amount)) * 100
ELSE 0
END as gross_profit_margin,
COALESCE(SUM(o.tax_amount), 0) as total_tax,
COALESCE(SUM(o.discount_amount), 0) as total_discount,
COALESCE(SUM(o.total_amount - o.total_cost - o.discount_amount), 0) as net_profit,
CASE
WHEN SUM(o.total_amount) > 0
THEN (SUM(o.total_amount - o.total_cost - o.discount_amount) / SUM(o.total_amount)) * 100
ELSE 0
END as net_profit_margin,
COUNT(o.id) as total_orders,
CASE
WHEN COUNT(o.id) > 0
THEN SUM(o.total_amount - o.total_cost - o.discount_amount) / COUNT(o.id)
ELSE 0
END as average_profit,
CASE
WHEN SUM(o.total_cost) > 0
THEN (SUM(o.total_amount - o.total_cost) / SUM(o.total_cost)) * 100
ELSE 0
END as profitability_ratio
` ).
Where ( "o.organization_id = ?" , organizationID ).
Where ( "o.status = ?" , entities . OrderStatusCompleted ).
Where ( "o.payment_status = ?" , entities . PaymentStatusCompleted ).
Where ( "o.is_void = false AND o.is_refund = false" ).
Where ( "o.created_at >= ? AND o.created_at <= ?" , dateFrom , dateTo )
summaryQuery = r . resolveOutletID ( summaryQuery , outletID , "o.outlet_id" )
if err := summaryQuery . Scan ( & summary ). Error ; err != nil {
return nil , err
}
2026-06-18 19:51:43 +07:00
periodHPP , err := r . getPurchaseOrderRawMaterialTotal ( ctx , organizationID , outletID , dateFrom , dateTo )
if err != nil {
return nil , err
}
applyProfitLossSummaryCost ( & summary , periodHPP )
2026-06-01 13:13:40 +07:00
var timeFormat string
switch groupBy {
case "hour" :
timeFormat = "DATE_TRUNC('hour', o.created_at)"
case "week" :
timeFormat = "DATE_TRUNC('week', o.created_at)"
case "month" :
timeFormat = "DATE_TRUNC('month', o.created_at)"
default :
timeFormat = "DATE_TRUNC('day', o.created_at)"
}
var data [] entities . ProfitLossData
dataQuery := r . db . WithContext ( ctx ).
Table ( "orders o" ).
Select ( `
` + timeFormat + ` as date,
COALESCE(SUM(o.total_amount), 0) as revenue,
COALESCE(SUM(o.total_cost), 0) as cost,
COALESCE(SUM(o.total_amount - o.total_cost), 0) as gross_profit,
CASE
WHEN SUM(o.total_amount) > 0
THEN (SUM(o.total_amount - o.total_cost) / SUM(o.total_amount)) * 100
ELSE 0
END as gross_profit_margin,
COALESCE(SUM(o.tax_amount), 0) as tax,
COALESCE(SUM(o.discount_amount), 0) as discount,
COALESCE(SUM(o.total_amount - o.total_cost - o.discount_amount), 0) as net_profit,
CASE
WHEN SUM(o.total_amount) > 0
THEN (SUM(o.total_amount - o.total_cost - o.discount_amount) / SUM(o.total_amount)) * 100
ELSE 0
END as net_profit_margin,
COUNT(o.id) as orders
` ).
Where ( "o.organization_id = ?" , organizationID ).
Where ( "o.status = ?" , entities . OrderStatusCompleted ).
Where ( "o.payment_status = ?" , entities . PaymentStatusCompleted ).
Where ( "o.is_void = false AND o.is_refund = false" ).
Where ( "o.created_at >= ? AND o.created_at <= ?" , dateFrom , dateTo ).
Group ( timeFormat ).
Order ( timeFormat )
dataQuery = r . resolveOutletID ( dataQuery , outletID , "o.outlet_id" )
if err := dataQuery . Scan ( & data ). Error ; err != nil {
return nil , err
}
2026-06-18 19:51:43 +07:00
poCostData , err := r . getPurchaseOrderRawMaterialCostByPeriod ( ctx , organizationID , outletID , dateFrom , dateTo , groupBy )
if err != nil {
return nil , err
}
data = mergeProfitLossDataWithPurchaseOrderCost ( data , poCostData )
2026-06-01 13:13:40 +07:00
var productData [] entities . ProductProfitData
productQuery := r . db . WithContext ( ctx ).
Table ( "order_items oi" ).
Select ( `
p.id as product_id,
p.name as product_name,
c.id as category_id,
c.name as category_name,
SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END) as quantity_sold,
SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END) as revenue,
SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END) as cost,
SUM(CASE WHEN oi.is_fully_refunded = false THEN (oi.total_price - COALESCE(oi.refund_amount, 0)) - (oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0))) ELSE 0 END) as gross_profit,
CASE
WHEN SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END) > 0
THEN (SUM(CASE WHEN oi.is_fully_refunded = false THEN (oi.total_price - COALESCE(oi.refund_amount, 0)) - (oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0))) ELSE 0 END) / SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END)) * 100
ELSE 0
END as gross_profit_margin,
AVG(CASE WHEN oi.is_fully_refunded = false THEN oi.unit_price ELSE NULL END) as average_price,
AVG(CASE WHEN oi.is_fully_refunded = false THEN oi.unit_cost ELSE NULL END) as average_cost,
AVG(CASE WHEN oi.is_fully_refunded = false THEN oi.unit_price - oi.unit_cost ELSE NULL END) as profit_per_unit
` ).
Joins ( "JOIN orders o ON oi.order_id = o.id" ).
Joins ( "JOIN products p ON oi.product_id = p.id" ).
Joins ( "JOIN categories c ON p.category_id = c.id" ).
Where ( "o.organization_id = ?" , organizationID ).
Where ( "o.status = ?" , entities . OrderStatusCompleted ).
Where ( "o.payment_status = ?" , entities . PaymentStatusCompleted ).
Where ( "o.is_void = false AND o.is_refund = false" ).
Where ( "oi.status != ?" , entities . OrderItemStatusCancelled ).
Where ( "o.created_at >= ? AND o.created_at <= ?" , dateFrom , dateTo ).
Group ( "p.id, p.name, c.id, c.name" ).
Order ( "p.name ASC" ).
Limit ( 1000 )
productQuery = r . resolveOutletID ( productQuery , outletID , "o.outlet_id" )
if err := productQuery . Scan ( & productData ). Error ; err != nil {
return nil , err
}
2026-05-26 14:59:56 +07:00
type revenueCostResult struct {
Revenue float64
Cost float64
2025-07-18 20:10:29 +07:00
}
2026-05-26 14:59:56 +07:00
var todayRC revenueCostResult
todayQuery := r . db . WithContext ( ctx ).
2025-07-18 20:10:29 +07:00
Table ( "orders o" ).
Select ( `
COALESCE(SUM(o.total_amount), 0) as revenue,
2026-05-26 14:59:56 +07:00
COALESCE(SUM(o.total_cost), 0) as cost
2025-07-18 20:10:29 +07:00
` ).
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" ).
2026-05-26 14:59:56 +07:00
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
}
2026-06-18 19:51:43 +07:00
todayHPP , err := r . getPurchaseOrderRawMaterialTotal ( ctx , organizationID , outletID , todayStart , todayEnd )
if err != nil {
return nil , err
}
todayRC . Cost = todayHPP
2025-07-18 20:10:29 +07:00
2026-05-26 14:59:56 +07:00
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
}
2026-06-18 19:51:43 +07:00
mtdHPP , err := r . getPurchaseOrderRawMaterialTotal ( ctx , organizationID , outletID , mtdStart , todayEnd )
if err != nil {
return nil , err
}
mtdRC . Cost = mtdHPP
2025-07-18 20:10:29 +07:00
2026-05-26 14:59:56 +07:00
todayExpenseByCategory , err := r . getExpenseByCategory ( ctx , organizationID , outletID , todayStart , todayEnd )
2025-07-18 20:10:29 +07:00
if err != nil {
return nil , err
}
2026-06-18 19:51:43 +07:00
todayPOExpenseByCategory , err := r . getPurchaseOrderExpenseByCategory ( ctx , organizationID , outletID , todayStart , todayEnd )
if err != nil {
return nil , err
}
todayExpenseByCategory = mergeExpenseCategoryTotals ( todayExpenseByCategory , todayPOExpenseByCategory )
2025-07-18 20:10:29 +07:00
2026-05-26 14:59:56 +07:00
mtdExpenseByCategory , err := r . getExpenseByCategory ( ctx , organizationID , outletID , mtdStart , todayEnd )
if err != nil {
return nil , err
}
2026-06-18 19:51:43 +07:00
mtdPOExpenseByCategory , err := r . getPurchaseOrderExpenseByCategory ( ctx , organizationID , outletID , mtdStart , todayEnd )
if err != nil {
return nil , err
}
mtdExpenseByCategory = mergeExpenseCategoryTotals ( mtdExpenseByCategory , mtdPOExpenseByCategory )
2025-07-18 20:10:29 +07:00
2026-05-26 14:59:56 +07:00
opsItems , err := r . getOperationalExpenseItems ( ctx , organizationID , outletID , mtdStart , todayEnd )
2025-07-18 20:10:29 +07:00
if err != nil {
return nil , err
}
2026-06-18 19:51:43 +07:00
poOpsItems , err := r . getPurchaseOrderExpenseItems ( ctx , organizationID , outletID , mtdStart , todayEnd )
if err != nil {
return nil , err
}
opsItems = mergeOperationalExpenseItems ( opsItems , poOpsItems )
2025-07-18 20:10:29 +07:00
2026-06-24 00:11:04 +07:00
todayPurchasing , err := r . getPurchaseOrderTotals ( ctx , organizationID , todayStart , todayEnd )
if err != nil {
return nil , err
}
mtdPurchasing , err := r . getPurchaseOrderTotals ( ctx , organizationID , mtdStart , todayEnd )
if err != nil {
return nil , err
}
purchasingItems , err := r . getPurchasingItemDetails ( ctx , organizationID , dateFrom , dateTo )
if err != nil {
return nil , err
}
2025-07-18 20:10:29 +07:00
return & entities . ProfitLossAnalytics {
2026-06-24 00:11:04 +07:00
Summary : summary ,
Data : data ,
ProductData : productData ,
TodayRevenue : todayRC . Revenue ,
TodayCost : todayRC . Cost ,
MtdRevenue : mtdRC . Revenue ,
MtdCost : mtdRC . Cost ,
TodayPurchasing : todayPurchasing . Total ,
MtdPurchasing : mtdPurchasing . Total ,
TodayPurchasingRawMaterial : todayPurchasing . RawMaterial ,
MtdPurchasingRawMaterial : mtdPurchasing . RawMaterial ,
TodayPurchasingExpense : todayPurchasing . Expense ,
MtdPurchasingExpense : mtdPurchasing . Expense ,
PurchasingItems : purchasingItems ,
TodayExpenseByCategory : todayExpenseByCategory ,
MtdExpenseByCategory : mtdExpenseByCategory ,
OperationalExpenseItems : opsItems ,
2025-07-18 20:10:29 +07:00
}, nil
}
2026-05-26 14:59:56 +07:00
2026-06-18 19:51:43 +07:00
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
}
2026-06-24 00:11:04 +07:00
type purchasingTotals struct {
2026-09-06 17:14:42 +07:00
Total float64
RawMaterial float64
Expense float64
2026-06-24 00:11:04 +07:00
}
func ( r * AnalyticsRepositoryImpl ) getPurchaseOrderTotals ( ctx context . Context , organizationID uuid . UUID , dateFrom , dateTo time . Time ) ( purchasingTotals , error ) {
type result struct {
Total float64
RawMaterial float64
Expense float64
}
var res result
query := r . db . WithContext ( ctx ).
Table ( "purchase_order_items poi" ).
Select ( `
COALESCE(SUM(` + purchaseOrderItemTotalAmountSQL () + `), 0) as total,
COALESCE(SUM(` + purchaseOrderRawMaterialAmountSQL () + `), 0) as raw_material,
COALESCE(SUM(` + purchaseOrderExpenseAmountSQL () + `), 0) as expense
` ).
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 ( "po.transaction_date >= ? AND po.transaction_date <= ?" , dateFrom , dateTo )
if err := query . Scan ( & res ). Error ; err != nil {
return purchasingTotals {}, err
}
return purchasingTotals {
Total : res . Total ,
RawMaterial : res . RawMaterial ,
Expense : res . Expense ,
}, nil
}
func ( r * AnalyticsRepositoryImpl ) getPurchasingItemDetails ( ctx context . Context , organizationID uuid . UUID , dateFrom , dateTo time . Time ) ([] entities . PurchasingItemDetail , error ) {
var results [] entities . PurchasingItemDetail
query := r . db . WithContext ( ctx ).
Table ( "purchase_order_items poi" ).
Select ( `
po.transaction_date as date,
COALESCE(NULLIF(poi.description, ''), i.name, pc.name) as item,
COALESCE(poi.quantity, 0) as quantity,
CASE WHEN pc.type = '` + string ( entities . PurchaseCategoryTypeRawMaterial ) + `' THEN COALESCE(poi.quantity, 0) * poi.amount ELSE poi.amount END as amount
` ).
Joins ( "JOIN purchase_orders po ON poi.purchase_order_id = po.id" ).
Joins ( "LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.id" ).
Joins ( "LEFT JOIN ingredients i ON poi.ingredient_id = i.id" ).
Where ( "po.organization_id = ?" , organizationID ).
Where ( "po.status = ?" , "received" ).
Where ( "po.transaction_date >= ? AND po.transaction_date <= ?" , dateFrom , dateTo ).
Order ( "po.transaction_date DESC, poi.created_at DESC" )
if err := query . Scan ( & results ). Error ; err != nil {
return nil , err
}
return results , nil
}
2026-06-18 19:51:43 +07:00
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
}
2026-05-26 14:59:56 +07:00
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" ).
2026-06-11 16:47:03 +07:00
Select ( `COALESCE(parent_coa.name, coa.name, 'Lain-lain') as category_name, COALESCE(SUM(ei.amount), 0) as amount` ).
2026-05-26 14:59:56 +07:00
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 ).
2026-05-29 15:44:59 +07:00
Where ( "e.status = ?" , "approved" ).
2026-05-26 14:59:56 +07:00
Where ( "e.transaction_date >= ? AND e.transaction_date <= ?" , dateFrom , dateTo )
if outletID != nil {
query = query . Where ( "e.outlet_id = ?" , * outletID )
}
err := query .
2026-06-11 16:47:03 +07:00
Group ( "COALESCE(parent_coa.name, coa.name, 'Lain-lain')" ).
Order ( "COALESCE(parent_coa.name, coa.name, 'Lain-lain')" ).
2026-05-26 14:59:56 +07:00
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" ).
2026-05-29 13:25:38 +07:00
Select ( `COALESCE(NULLIF(ei.item, ''), ei.description, coa.name) as item, COALESCE(SUM(ei.amount), 0) as amount` ).
2026-05-26 14:59:56 +07:00
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 ).
2026-05-29 15:44:59 +07:00
Where ( "e.status = ?" , "approved" ).
2026-05-26 14:59:56 +07:00
Where ( "e.transaction_date >= ? AND e.transaction_date <= ?" , dateFrom , dateTo )
if outletID != nil {
query = query . Where ( "e.outlet_id = ?" , * outletID )
}
err := query .
2026-05-29 13:25:38 +07:00
Group ( "COALESCE(NULLIF(ei.item, ''), ei.description, coa.name)" ).
2026-05-26 14:59:56 +07:00
Order ( "amount DESC" ).
Scan ( & results ). Error
return results , err
}
2026-06-17 18:17:08 +07:00
func ( r * AnalyticsRepositoryImpl ) GetExclusiveSummaryAnalytics ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , dateFrom , dateTo time . Time ) ( * entities . ExclusiveSummaryAnalytics , error ) {
type salesResult struct {
SalesTotal float64
SalesCount int64
}
var sales salesResult
salesQuery := r . db . WithContext ( ctx ).
Table ( "orders o" ).
Select ( `
COALESCE(SUM(o.total_amount), 0) as sales_total,
COUNT(o.id) as sales_count
` ).
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 )
salesQuery = r . resolveOutletID ( salesQuery , outletID , "o.outlet_id" )
if err := salesQuery . Scan ( & sales ). Error ; err != nil {
return nil , err
}
hppBreakdown , err := r . getExclusiveSummaryHPPBreakdown ( ctx , organizationID , outletID , dateFrom , dateTo )
if err != nil {
return nil , err
}
2026-06-18 15:27:20 +07:00
operationalExpenseBreakdown , err := r . getExclusiveSummaryOperationalExpenseBreakdown ( ctx , organizationID , outletID , dateFrom , dateTo )
2026-06-17 18:17:08 +07:00
if err != nil {
return nil , err
}
dailySummary , err := r . getExclusiveSummaryDailySummary ( ctx , organizationID , outletID , dateFrom , dateTo )
if err != nil {
return nil , err
}
dailyTransactions , err := r . getExclusiveSummaryDailyTransactions ( ctx , organizationID , outletID , dateFrom , dateTo )
if err != nil {
return nil , err
}
return & entities . ExclusiveSummaryAnalytics {
SalesTotal : sales . SalesTotal ,
SalesCount : sales . SalesCount ,
HPPBreakdown : hppBreakdown ,
OperationalExpenseBreakdown : operationalExpenseBreakdown ,
DailySummary : dailySummary ,
DailyTransactions : dailyTransactions ,
}, nil
}
func ( r * AnalyticsRepositoryImpl ) getExclusiveSummaryHPPBreakdown ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , dateFrom , dateTo time . Time ) ([] entities . ExclusiveSummaryCategoryTotal , error ) {
var results [] entities . ExclusiveSummaryCategoryTotal
query := r . db . WithContext ( ctx ).
Table ( "purchase_order_items poi" ).
Select ( `
pc.code as category_code,
pc.name as category_name,
2026-06-18 15:44:15 +07:00
COALESCE(SUM(` + purchaseOrderItemTotalAmountSQL () + `), 0) as amount
2026-06-17 18:17:08 +07:00
` ).
Joins ( "JOIN purchase_orders po ON poi.purchase_order_id = po.id" ).
Joins ( "JOIN purchase_categories pc ON poi.purchase_category_id = pc.id" ).
Joins ( "LEFT JOIN ingredients i ON poi.ingredient_id = i.id" ).
Joins ( "LEFT JOIN units u ON poi.unit_id = u.id" ).
Where ( "po.organization_id = ?" , organizationID ).
Where ( "pc.type = ?" , entities . PurchaseCategoryTypeRawMaterial ).
Where ( "po.status = ?" , "received" ).
Where ( "po.transaction_date >= ? AND po.transaction_date <= ?" , dateFrom , dateTo )
query = r . applyPurchaseOrderItemOutletFilter ( query , outletID )
err := query .
Group ( "pc.id, pc.code, pc.name, pc.sort_order" ).
Order ( "pc.sort_order ASC, pc.name ASC" ).
Scan ( & results ). Error
return results , err
}
2026-06-18 15:27:20 +07:00
func ( r * AnalyticsRepositoryImpl ) getExclusiveSummaryOperationalExpenseBreakdown ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , dateFrom , dateTo time . Time ) ([] entities . ExclusiveSummaryCategoryTotal , error ) {
2026-06-17 18:17:08 +07:00
var results [] entities . ExclusiveSummaryCategoryTotal
2026-06-18 15:27:20 +07:00
query := r . db . WithContext ( ctx ).
2026-06-17 18:17:08 +07:00
Table ( "purchase_order_items poi" ).
Select ( `
pc.code as category_code,
pc.name as category_name,
2026-06-18 15:44:15 +07:00
COALESCE(SUM(` + purchaseOrderItemTotalAmountSQL () + `), 0) as amount
2026-06-17 18:17:08 +07:00
` ).
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 ( "pc.type = ?" , entities . PurchaseCategoryTypeExpense ).
Where ( "po.status = ?" , "received" ).
2026-06-18 15:27:20 +07:00
Where ( "po.transaction_date >= ? AND po.transaction_date <= ?" , dateFrom , dateTo )
query = r . applyPurchaseOrderItemOutletFilter ( query , outletID )
err := query .
2026-06-17 18:17:08 +07:00
Group ( "pc.id, pc.code, pc.name, pc.sort_order" ).
Order ( "pc.sort_order ASC, pc.name ASC" ).
Scan ( & results ). Error
return results , err
}
func ( r * AnalyticsRepositoryImpl ) getExclusiveSummaryDailySummary ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , dateFrom , dateTo time . Time ) ([] entities . ExclusiveSummaryDailySummary , error ) {
var results [] entities . ExclusiveSummaryDailySummary
rawQuery , args := r . exclusiveSummaryPurchaseOrderItemQuery ( organizationID , outletID , dateFrom , dateTo )
err := r . db . WithContext ( ctx ). Raw ( `
SELECT date, COUNT(*) as transaction_count, COALESCE(SUM(amount), 0) as total_cost
FROM (` + rawQuery + `) transactions
GROUP BY date
ORDER BY date ASC
` , args ... ). Scan ( & results ). Error
return results , err
}
func ( r * AnalyticsRepositoryImpl ) getExclusiveSummaryDailyTransactions ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID , dateFrom , dateTo time . Time ) ([] entities . ExclusiveSummaryDailyTransaction , error ) {
var results [] entities . ExclusiveSummaryDailyTransaction
rawQuery , args := r . exclusiveSummaryPurchaseOrderItemQuery ( organizationID , outletID , dateFrom , dateTo )
err := r . db . WithContext ( ctx ). Raw ( `
SELECT date, category_code, category_name, description, amount, source
FROM (` + rawQuery + `) transactions
ORDER BY date ASC, category_name ASC, description ASC
` , args ... ). Scan ( & results ). Error
return results , err
}
func ( r * AnalyticsRepositoryImpl ) exclusiveSummaryPurchaseOrderItemQuery ( organizationID uuid . UUID , outletID * uuid . UUID , dateFrom , dateTo time . Time ) ( string , [] interface {}) {
outletFilter := ""
args := [] interface {}{
organizationID ,
"received" ,
dateFrom ,
dateTo ,
}
if outletID != nil {
2026-06-18 15:27:20 +07:00
outletFilter = "AND po.outlet_id = ?"
args = append ( args , * outletID )
2026-06-17 18:17:08 +07:00
}
query := `
SELECT
DATE(po.transaction_date) as date,
pc.code as category_code,
pc.name as category_name,
COALESCE(NULLIF(poi.description, ''), i.name, pc.name) as description,
2026-06-18 15:44:15 +07:00
` + purchaseOrderItemTotalAmountSQL () + ` as amount,
2026-06-17 18:17:08 +07:00
'purchase_order' as source
FROM purchase_order_items poi
JOIN purchase_orders po ON poi.purchase_order_id = po.id
JOIN purchase_categories pc ON poi.purchase_category_id = pc.id
LEFT JOIN ingredients i ON poi.ingredient_id = i.id
LEFT JOIN units u ON poi.unit_id = u.id
WHERE po.organization_id = ?
AND po.status = ?
AND po.transaction_date >= ? AND po.transaction_date <= ?
` + outletFilter + `
`
return query , args
}
2026-06-17 18:26:40 +07:00
func ( r * AnalyticsRepositoryImpl ) GetExclusiveSummaryBankBalances ( ctx context . Context , organizationID uuid . UUID , outletID * uuid . UUID ) ([] entities . ExclusiveSummaryBankBalance , error ) {
type accountBalance struct {
Name string
OpeningBalance float64
CurrentBalance float64
Description * string
}
var accounts [] accountBalance
query := r . db . WithContext ( ctx ).
Table ( "accounts" ).
Select ( "name, opening_balance, current_balance, description" ).
Where ( "organization_id = ?" , organizationID ).
Where ( "account_type IN ?" , [] entities . AccountType { entities . AccountTypeCash , entities . AccountTypeWallet , entities . AccountTypeBank }).
Where ( "is_active = ?" , true )
if outletID != nil {
query = query . Where ( "outlet_id = ? OR outlet_id IS NULL" , * outletID )
} else {
query = query . Where ( "outlet_id IS NULL" )
}
err := query .
Order ( "number ASC, name ASC" ).
Scan ( & accounts ). Error
if err != nil {
return nil , err
}
balances := make ([] entities . ExclusiveSummaryBankBalance , len ( accounts ))
for i , account := range accounts {
openingBalance := account . OpeningBalance
closingBalance := account . CurrentBalance
balances [ i ] = entities . ExclusiveSummaryBankBalance {
Bank : account . Name ,
OpeningBalance : & openingBalance ,
ClosingBalance : & closingBalance ,
Notes : account . Description ,
}
}
return balances , nil
}