feat: profit sharing
This commit is contained in:
@@ -2,6 +2,7 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
@@ -17,6 +18,9 @@ type AnalyticsRepository interface {
|
||||
GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, 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)
|
||||
@@ -461,6 +465,252 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerCategory(ctx context.Con
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user