Products like fish are sold per weighing (4.2 ons, 5.6 ons), which the order line could not represent: quantity is INTEGER and prices are always computed as quantity * unit_price. Model one weighing as one order line. quantity stays INTEGER and keeps meaning "how many items"; the measured amount goes into a new nullable order_items.weight, and the line is priced weight * unit_price. Two weighings of the same product are two lines, never merged into one. Keeping quantity integral avoids float comparisons in void, refund and split bill, where accumulated rounding error would silently misbehave — "1.4 + 1.4 + 1.4" is not 4.2 in float64, which would leave a fully paid split-bill item marked unpaid. BillableQuantity() is now the single place that decides between weight and count; every price and cost calculation goes through it. Missing one would bill a 4.2 ons fish as a single ons — wrong money, no error. Two database constraints back the design: a weighed line always carries a positive weight, and its quantity is pinned to 1. The latter also makes void all-or-nothing for weighed lines, so the row-splitting branch can never produce a zero-weight remainder row. Also wires product.unit_id through the API, which was previously not settable at all, and corrects the misleading comment on the request's unit_price field — that value has never been used; price always comes from the database. Design notes and the audit of every price multiplication site are in docs/rfc-weight-based-products.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1665 lines
70 KiB
Go
1665 lines
70 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
|
|
"apskel-pos-be/internal/constants"
|
|
"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)
|
|
GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, team *entities.PurchaseTeamFilter, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error)
|
|
GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error)
|
|
GetProductAnalyticsPerCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerCategory, error)
|
|
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)
|
|
GetDashboardOverview(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.DashboardOverview, error)
|
|
GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ProfitLossAnalytics, error)
|
|
GetExclusiveSummaryAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error)
|
|
GetExclusiveSummaryBankBalances(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error)
|
|
GetOutletName(ctx context.Context, organizationID uuid.UUID, outletID uuid.UUID) (string, error)
|
|
}
|
|
|
|
type AnalyticsRepositoryImpl struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewAnalyticsRepositoryImpl(db *gorm.DB) *AnalyticsRepositoryImpl {
|
|
return &AnalyticsRepositoryImpl{
|
|
db: db,
|
|
}
|
|
}
|
|
|
|
func (r *AnalyticsRepositoryImpl) resolveOutletID(query *gorm.DB, outletID *uuid.UUID, column string) *gorm.DB {
|
|
if outletID != nil {
|
|
return query.Where(column+" = ?", *outletID)
|
|
}
|
|
return query
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
|
|
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,
|
|
COUNT(p.id) as payment_count
|
|
`).
|
|
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).
|
|
Where("o.is_void = ?", false).
|
|
Where("o.is_refund = ?", false).
|
|
Where("p.status = ?", entities.PaymentTransactionStatusCompleted).
|
|
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo)
|
|
|
|
query = r.resolveOutletID(query, outletID, "o.outlet_id")
|
|
|
|
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)"
|
|
}
|
|
|
|
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,
|
|
COALESCE(SUM(o.total_amount), 0) as sales,
|
|
COUNT(o.id) as orders,
|
|
COALESCE(SUM(oi_agg.total_items), 0) as items,
|
|
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
|
|
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
|
|
`
|
|
|
|
err := r.db.WithContext(ctx).Raw(rawQuery, args...).Scan(&results).Error
|
|
return results, err
|
|
}
|
|
|
|
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) {
|
|
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
|
|
}
|
|
}
|
|
return r.getPurchaseOrderPurchasingAnalytics(ctx, organizationID, outletID, team, outletName, dateFrom, dateTo, groupBy)
|
|
}
|
|
|
|
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) {
|
|
var summary entities.PurchasingSummary
|
|
summaryQuery := r.db.WithContext(ctx).
|
|
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
|
|
THEN COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) / COUNT(DISTINCT po.id)
|
|
ELSE 0
|
|
END as average_purchase_order_value,
|
|
COUNT(DISTINCT i.id) as total_ingredients,
|
|
COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) as total_vendors
|
|
`).
|
|
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 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)
|
|
summaryQuery = r.applyPurchaseOrderTeamFilter(summaryQuery, team)
|
|
|
|
if err := summaryQuery.Scan(&summary).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var dateFormat string
|
|
switch groupBy {
|
|
case "hour":
|
|
dateFormat = "DATE_TRUNC('hour', po.created_at)"
|
|
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 data []entities.PurchasingAnalyticsData
|
|
dataQuery := r.db.WithContext(ctx).
|
|
Table("purchase_orders po").
|
|
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
|
|
`).
|
|
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 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("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
|
|
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
|
|
Group(dateFormat).
|
|
Order(dateFormat)
|
|
dataQuery = r.applyPurchaseOrderItemOutletFilter(dataQuery, outletID)
|
|
dataQuery = r.applyPurchaseOrderTeamFilter(dataQuery, team)
|
|
|
|
if err := dataQuery.Scan(&data).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var ingredientData []entities.PurchasingIngredientData
|
|
ingredientQuery := r.db.WithContext(ctx).
|
|
Table("purchase_order_items poi").
|
|
Select(`
|
|
i.id as ingredient_id,
|
|
i.name as ingredient_name,
|
|
COALESCE(SUM(poi.quantity), 0) as quantity,
|
|
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total_cost,
|
|
CASE
|
|
WHEN SUM(poi.quantity) > 0
|
|
THEN COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) / SUM(poi.quantity)
|
|
ELSE 0
|
|
END as average_unit_cost,
|
|
COUNT(DISTINCT po.id) as purchase_order_count
|
|
`).
|
|
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("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).
|
|
Group("i.id, i.name").
|
|
Order("total_cost DESC")
|
|
ingredientQuery = r.applyPurchaseOrderItemOutletFilter(ingredientQuery, outletID)
|
|
ingredientQuery = r.applyPurchaseOrderTeamFilter(ingredientQuery, team)
|
|
|
|
if err := ingredientQuery.Scan(&ingredientData).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var vendorData []entities.PurchasingVendorData
|
|
vendorQuery := r.db.WithContext(ctx).
|
|
Table("purchase_orders po").
|
|
Select(`
|
|
v.id as vendor_id,
|
|
COALESCE(v.name, 'No Vendor') as vendor_name,
|
|
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total_cost,
|
|
COUNT(DISTINCT po.id) as purchase_order_count,
|
|
COUNT(DISTINCT i.id) as ingredient_count,
|
|
COALESCE(SUM(poi.quantity), 0) as quantity
|
|
`).
|
|
Joins("LEFT JOIN vendors v ON po.vendor_id = v.id").
|
|
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 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).
|
|
Group("v.id, COALESCE(v.name, 'No Vendor')").
|
|
Order("total_cost DESC")
|
|
vendorQuery = r.applyPurchaseOrderItemOutletFilter(vendorQuery, outletID)
|
|
vendorQuery = r.applyPurchaseOrderTeamFilter(vendorQuery, team)
|
|
|
|
if err := vendorQuery.Scan(&vendorData).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
teamData, err := r.getPurchaseOrderTeamBreakdown(ctx, organizationID, outletID, team, dateFrom, dateTo, summary.TotalPurchases)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
summary.TotalTeams = int64(len(teamData))
|
|
|
|
return &entities.PurchasingAnalytics{
|
|
OutletName: outletName,
|
|
Summary: summary,
|
|
Data: data,
|
|
IngredientData: ingredientData,
|
|
VendorData: vendorData,
|
|
TeamData: teamData,
|
|
}, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm.DB, outletID *uuid.UUID) *gorm.DB {
|
|
if outletID == nil {
|
|
return query
|
|
}
|
|
return query.Where("po.outlet_id = ?", *outletID)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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
|
|
|
|
query := r.db.WithContext(ctx).
|
|
Table("order_items oi").
|
|
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(oi.quantity), 0) as quantity_sold,
|
|
COALESCE(SUM(oi.weight), 0) as weight_sold,
|
|
COALESCE(SUM(oi.total_price), 0) as revenue,
|
|
CASE
|
|
WHEN SUM(oi.quantity) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(oi.quantity)
|
|
ELSE 0
|
|
END as average_price,
|
|
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
|
|
), p.cost, 0) * COALESCE(SUM(oi.quantity), 0) as standard_hpp_total,
|
|
CASE
|
|
WHEN SUM(oi.quantity) > 0 THEN COALESCE(SUM(oi.total_cost), 0) / SUM(oi.quantity)
|
|
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,
|
|
COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(oi.quantity), 0) as moving_average_hpp_total
|
|
`).
|
|
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 product_outlet_prices pop ON pop.product_id = p.id AND pop.outlet_id = o.outlet_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.
|
|
Group("p.id, p.name, p.sku, p.price, p.cost, pop.price, c.id, c.name, c.order, mahpp.hpp_per_unit").
|
|
Order("revenue DESC").
|
|
Limit(limit).
|
|
Scan(&results).Error
|
|
|
|
return results, err
|
|
}
|
|
|
|
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,
|
|
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,
|
|
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp,
|
|
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,
|
|
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp
|
|
`).
|
|
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.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.
|
|
Group("c.id, c.name").
|
|
Order("c.name ASC").
|
|
Scan(&results).Error
|
|
|
|
return results, err
|
|
}
|
|
|
|
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,
|
|
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,
|
|
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp,
|
|
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,
|
|
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp
|
|
`).
|
|
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.
|
|
Group("pc.id, pc.name").
|
|
Order("pc.name ASC").
|
|
Scan(&results).Error
|
|
|
|
return results, err
|
|
}
|
|
|
|
// 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 {
|
|
ID uuid.UUID
|
|
Name string
|
|
}
|
|
if err := r.db.WithContext(ctx).
|
|
Table("categories").
|
|
Select("id, name").
|
|
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,
|
|
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp,
|
|
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,
|
|
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp
|
|
`).
|
|
Scan(summary).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
summary.ParentCategoryID = parent.ID
|
|
summary.ParentCategoryName = parent.Name
|
|
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,
|
|
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp,
|
|
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,
|
|
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp
|
|
`).
|
|
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)
|
|
/ NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0),
|
|
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,
|
|
COALESCE(shpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as standard_hpp_total,
|
|
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)
|
|
/ NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0),
|
|
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,
|
|
COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as moving_average_hpp_total
|
|
`).
|
|
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,
|
|
COUNT(DISTINCT oi.order_id) as order_count
|
|
`).
|
|
// 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").
|
|
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
|
|
}
|
|
|
|
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(`
|
|
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,
|
|
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)
|
|
|
|
query = r.resolveOutletID(query, outletID, "o.outlet_id")
|
|
|
|
err := query.Scan(&result).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 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
|
|
|
|
return &result, nil
|
|
}
|
|
|
|
func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ProfitLossAnalytics, error) {
|
|
mtdStart := time.Date(dateTo.Year(), dateTo.Month(), 1, 0, 0, 0, 0, dateTo.Location())
|
|
todayStart := time.Date(dateTo.Year(), dateTo.Month(), dateTo.Day(), 0, 0, 0, 0, dateTo.Location())
|
|
todayEnd := todayStart.Add(24 * time.Hour).Add(-time.Nanosecond)
|
|
|
|
var summary entities.ProfitLossSummary
|
|
summaryQuery := r.db.WithContext(ctx).
|
|
Table("orders o").
|
|
Select(`
|
|
COALESCE(SUM(o.total_amount), 0) as total_revenue,
|
|
COALESCE(SUM(o.total_cost), 0) as total_cost,
|
|
COALESCE(SUM(o.total_amount - o.total_cost), 0) as gross_profit,
|
|
CASE
|
|
WHEN SUM(o.total_amount) > 0
|
|
THEN (SUM(o.total_amount - o.total_cost) / SUM(o.total_amount)) * 100
|
|
ELSE 0
|
|
END as gross_profit_margin,
|
|
COALESCE(SUM(o.tax_amount), 0) as total_tax,
|
|
COALESCE(SUM(o.discount_amount), 0) as total_discount,
|
|
COALESCE(SUM(o.total_amount - o.total_cost - o.discount_amount), 0) as net_profit,
|
|
CASE
|
|
WHEN SUM(o.total_amount) > 0
|
|
THEN (SUM(o.total_amount - o.total_cost - o.discount_amount) / SUM(o.total_amount)) * 100
|
|
ELSE 0
|
|
END as net_profit_margin,
|
|
COUNT(o.id) as total_orders,
|
|
CASE
|
|
WHEN COUNT(o.id) > 0
|
|
THEN SUM(o.total_amount - o.total_cost - o.discount_amount) / COUNT(o.id)
|
|
ELSE 0
|
|
END as average_profit,
|
|
CASE
|
|
WHEN SUM(o.total_cost) > 0
|
|
THEN (SUM(o.total_amount - o.total_cost) / SUM(o.total_cost)) * 100
|
|
ELSE 0
|
|
END as profitability_ratio
|
|
`).
|
|
Where("o.organization_id = ?", organizationID).
|
|
Where("o.status = ?", entities.OrderStatusCompleted).
|
|
Where("o.payment_status = ?", entities.PaymentStatusCompleted).
|
|
Where("o.is_void = false AND o.is_refund = false").
|
|
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo)
|
|
summaryQuery = r.resolveOutletID(summaryQuery, outletID, "o.outlet_id")
|
|
if err := summaryQuery.Scan(&summary).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
periodHPP, err := r.getPurchaseOrderRawMaterialTotal(ctx, organizationID, outletID, dateFrom, dateTo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applyProfitLossSummaryCost(&summary, periodHPP)
|
|
|
|
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
|
|
}
|
|
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).
|
|
Table("order_items oi").
|
|
Select(`
|
|
p.id as product_id,
|
|
p.name as product_name,
|
|
c.id as category_id,
|
|
c.name as category_name,
|
|
SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END) as quantity_sold,
|
|
SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END) as revenue,
|
|
SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END) as cost,
|
|
SUM(CASE WHEN oi.is_fully_refunded = false THEN (oi.total_price - COALESCE(oi.refund_amount, 0)) - (oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0))) ELSE 0 END) as gross_profit,
|
|
CASE
|
|
WHEN SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END) > 0
|
|
THEN (SUM(CASE WHEN oi.is_fully_refunded = false THEN (oi.total_price - COALESCE(oi.refund_amount, 0)) - (oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0))) ELSE 0 END) / SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END)) * 100
|
|
ELSE 0
|
|
END as gross_profit_margin,
|
|
AVG(CASE WHEN oi.is_fully_refunded = false THEN oi.unit_price ELSE NULL END) as average_price,
|
|
AVG(CASE WHEN oi.is_fully_refunded = false THEN oi.unit_cost ELSE NULL END) as average_cost,
|
|
AVG(CASE WHEN oi.is_fully_refunded = false THEN oi.unit_price - oi.unit_cost ELSE NULL END) as profit_per_unit
|
|
`).
|
|
Joins("JOIN orders o ON oi.order_id = o.id").
|
|
Joins("JOIN products p ON oi.product_id = p.id").
|
|
Joins("JOIN categories c ON p.category_id = c.id").
|
|
Where("o.organization_id = ?", organizationID).
|
|
Where("o.status = ?", entities.OrderStatusCompleted).
|
|
Where("o.payment_status = ?", entities.PaymentStatusCompleted).
|
|
Where("o.is_void = false AND o.is_refund = false").
|
|
Where("oi.status != ?", entities.OrderItemStatusCancelled).
|
|
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo).
|
|
Group("p.id, p.name, c.id, c.name").
|
|
Order("p.name ASC").
|
|
Limit(1000)
|
|
productQuery = r.resolveOutletID(productQuery, outletID, "o.outlet_id")
|
|
if err := productQuery.Scan(&productData).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
type revenueCostResult struct {
|
|
Revenue float64
|
|
Cost float64
|
|
}
|
|
|
|
var todayRC revenueCostResult
|
|
todayQuery := 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 <= ?", todayStart, todayEnd)
|
|
todayQuery = r.resolveOutletID(todayQuery, outletID, "o.outlet_id")
|
|
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).
|
|
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
|
|
}
|
|
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)
|
|
|
|
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
|
|
}
|
|
|
|
return &entities.ProfitLossAnalytics{
|
|
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,
|
|
}, 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
|
|
}
|
|
|
|
type purchasingTotals struct {
|
|
Total float64
|
|
RawMaterial float64
|
|
Expense float64
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
|
|
query := r.db.WithContext(ctx).
|
|
Table("expense_items ei").
|
|
Select(`COALESCE(parent_coa.name, coa.name, 'Lain-lain') as category_name, COALESCE(SUM(ei.amount), 0) as amount`).
|
|
Joins("JOIN expenses e ON ei.expense_id = e.id").
|
|
Joins("JOIN chart_of_accounts coa ON ei.chart_of_account_id = coa.id").
|
|
Joins("LEFT JOIN chart_of_accounts parent_coa ON coa.parent_id = parent_coa.id").
|
|
Where("e.organization_id = ?", organizationID).
|
|
Where("e.status = ?", "approved").
|
|
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo)
|
|
|
|
if outletID != nil {
|
|
query = query.Where("e.outlet_id = ?", *outletID)
|
|
}
|
|
|
|
err := query.
|
|
Group("COALESCE(parent_coa.name, coa.name, 'Lain-lain')").
|
|
Order("COALESCE(parent_coa.name, coa.name, 'Lain-lain')").
|
|
Scan(&results).Error
|
|
|
|
return results, err
|
|
}
|
|
|
|
func (r *AnalyticsRepositoryImpl) getOperationalExpenseItems(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.OperationalExpenseItem, error) {
|
|
var results []entities.OperationalExpenseItem
|
|
|
|
query := r.db.WithContext(ctx).
|
|
Table("expense_items ei").
|
|
Select(`COALESCE(NULLIF(ei.item, ''), ei.description, coa.name) as item, COALESCE(SUM(ei.amount), 0) as amount`).
|
|
Joins("JOIN expenses e ON ei.expense_id = e.id").
|
|
Joins("JOIN chart_of_accounts coa ON ei.chart_of_account_id = coa.id").
|
|
Where("e.organization_id = ?", organizationID).
|
|
Where("e.status = ?", "approved").
|
|
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo)
|
|
|
|
if outletID != nil {
|
|
query = query.Where("e.outlet_id = ?", *outletID)
|
|
}
|
|
|
|
err := query.
|
|
Group("COALESCE(NULLIF(ei.item, ''), ei.description, coa.name)").
|
|
Order("amount DESC").
|
|
Scan(&results).Error
|
|
|
|
return results, err
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
operationalExpenseBreakdown, err := r.getExclusiveSummaryOperationalExpenseBreakdown(ctx, organizationID, outletID, dateFrom, dateTo)
|
|
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,
|
|
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").
|
|
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
|
|
}
|
|
|
|
func (r *AnalyticsRepositoryImpl) getExclusiveSummaryOperationalExpenseBreakdown(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,
|
|
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("pc.type = ?", entities.PurchaseCategoryTypeExpense).
|
|
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
|
|
}
|
|
|
|
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 {
|
|
outletFilter = "AND po.outlet_id = ?"
|
|
args = append(args, *outletID)
|
|
}
|
|
|
|
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,
|
|
` + purchaseOrderItemTotalAmountSQL() + ` as amount,
|
|
'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
|
|
}
|
|
|
|
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
|
|
}
|