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
+7 -3
View File
@@ -7,6 +7,7 @@ import (
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type CategoryRepositoryImpl struct {
@@ -25,7 +26,7 @@ func (r *CategoryRepositoryImpl) Create(ctx context.Context, category *entities.
func (r *CategoryRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.Category, error) {
var category entities.Category
err := r.db.WithContext(ctx).First(&category, "id = ?", id).Error
err := r.db.WithContext(ctx).Preload("Parent").First(&category, "id = ?", id).Error
if err != nil {
return nil, err
}
@@ -54,7 +55,8 @@ func (r *CategoryRepositoryImpl) GetByBusinessType(ctx context.Context, business
}
func (r *CategoryRepositoryImpl) Update(ctx context.Context, category *entities.Category) error {
return r.db.WithContext(ctx).Save(category).Error
// Omit associations so a preloaded Parent is not upserted back over parent_id
return r.db.WithContext(ctx).Omit(clause.Associations).Save(category).Error
}
func (r *CategoryRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
@@ -84,7 +86,7 @@ func (r *CategoryRepositoryImpl) List(ctx context.Context, filters map[string]in
return nil, 0, err
}
err := query.Order("\"order\" ASC").Limit(limit).Offset(offset).Find(&categories).Error
err := query.Preload("Parent").Order("\"order\" ASC").Limit(limit).Offset(offset).Find(&categories).Error
return categories, total, err
}
@@ -97,6 +99,8 @@ func (r *CategoryRepositoryImpl) Count(ctx context.Context, filters map[string]i
case "search":
searchValue := "%" + value.(string) + "%"
query = query.Where("name ILIKE ? OR description ILIKE ?", searchValue, searchValue)
case "outlet_id":
query = query.Where("outlet_id = ? OR outlet_id IS NULL", value)
default:
query = query.Where(key+" = ?", value)
}
+15
View File
@@ -101,6 +101,8 @@ func (r *ProductRepositoryImpl) List(ctx context.Context, filters map[string]int
query = query.Where("price >= ?", value)
case "price_max":
query = query.Where("price <= ?", value)
case "category_id":
query = query.Where("category_id IN (?)", r.categoryAndChildrenIDs(value))
default:
query = query.Where(key+" = ?", value)
}
@@ -114,6 +116,15 @@ func (r *ProductRepositoryImpl) List(ctx context.Context, filters map[string]int
return products, total, err
}
// categoryAndChildrenIDs builds a subquery resolving to the category itself plus its
// direct children, so filtering by a parent category also returns the children's
// products. For a category without children it resolves to just that category.
func (r *ProductRepositoryImpl) categoryAndChildrenIDs(categoryID interface{}) *gorm.DB {
return r.db.Model(&entities.Category{}).
Select("id").
Where("id = ? OR parent_id = ?", categoryID, categoryID)
}
func (r *ProductRepositoryImpl) Count(ctx context.Context, filters map[string]interface{}) (int64, error) {
var count int64
query := r.db.WithContext(ctx).Model(&entities.Product{})
@@ -127,6 +138,8 @@ func (r *ProductRepositoryImpl) Count(ctx context.Context, filters map[string]in
query = query.Where("price >= ?", value)
case "price_max":
query = query.Where("price <= ?", value)
case "category_id":
query = query.Where("category_id IN (?)", r.categoryAndChildrenIDs(value))
default:
query = query.Where(key+" = ?", value)
}
@@ -232,6 +245,8 @@ func (r *ProductRepositoryImpl) ListWithOutletPrice(ctx context.Context, filters
query = query.Where("products.price >= ?", value)
case "price_max":
query = query.Where("products.price <= ?", value)
case "category_id":
query = query.Where("products.category_id IN (?)", r.categoryAndChildrenIDs(value))
default:
query = query.Where("products."+key+" = ?", value)
}