feat: profit sharing

This commit is contained in:
Efril
2026-08-05 19:28:38 +07:00
parent 2b80c92caa
commit b9ac97178f
26 changed files with 1378 additions and 4 deletions
+250
View File
@@ -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