Update purchase analytics

This commit is contained in:
ryan 2026-06-10 13:18:49 +07:00
parent c3db919531
commit d0c090a657
8 changed files with 283 additions and 77 deletions

View File

@ -106,7 +106,11 @@ type PurchasingAnalyticsResponse struct {
type PurchasingSummary struct {
TotalPurchases float64 `json:"total_purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
NonInventoryPurchases float64 `json:"non_inventory_purchases"`
TotalPurchaseOrders int64 `json:"total_purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
NonInventoryExpenseCount int64 `json:"non_inventory_expense_count"`
TotalQuantity float64 `json:"total_quantity"`
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"`
@ -114,12 +118,16 @@ type PurchasingSummary struct {
}
type PurchasingAnalyticsData struct {
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
NonInventoryPurchases float64 `json:"non_inventory_purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
NonInventoryExpenseCount int64 `json:"non_inventory_expense_count"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
}
type PurchasingIngredientData struct {

View File

@ -38,7 +38,11 @@ type PurchasingAnalytics struct {
type PurchasingSummary struct {
TotalPurchases float64 `json:"total_purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
NonInventoryPurchases float64 `json:"non_inventory_purchases"`
TotalPurchaseOrders int64 `json:"total_purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
NonInventoryExpenseCount int64 `json:"non_inventory_expense_count"`
TotalQuantity float64 `json:"total_quantity"`
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"`
@ -46,12 +50,16 @@ type PurchasingSummary struct {
}
type PurchasingAnalyticsData struct {
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
NonInventoryPurchases float64 `json:"non_inventory_purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
NonInventoryExpenseCount int64 `json:"non_inventory_expense_count"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
}
type PurchasingIngredientData struct {

View File

@ -113,7 +113,11 @@ type PurchasingAnalyticsResponse struct {
// PurchasingSummary represents the summary of purchasing analytics
type PurchasingSummary struct {
TotalPurchases float64 `json:"total_purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
NonInventoryPurchases float64 `json:"non_inventory_purchases"`
TotalPurchaseOrders int64 `json:"total_purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
NonInventoryExpenseCount int64 `json:"non_inventory_expense_count"`
TotalQuantity float64 `json:"total_quantity"`
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"`
@ -122,12 +126,16 @@ type PurchasingSummary struct {
// PurchasingAnalyticsData represents purchasing analytics by time period
type PurchasingAnalyticsData struct {
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
NonInventoryPurchases float64 `json:"non_inventory_purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
NonInventoryExpenseCount int64 `json:"non_inventory_expense_count"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
}
// PurchasingIngredientData represents purchasing analytics for an ingredient

View File

@ -185,12 +185,16 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
data := make([]models.PurchasingAnalyticsData, len(result.Data))
for i, item := range result.Data {
data[i] = models.PurchasingAnalyticsData{
Date: item.Date,
Purchases: item.Purchases,
PurchaseOrders: item.PurchaseOrders,
Quantity: item.Quantity,
Ingredients: item.Ingredients,
Vendors: item.Vendors,
Date: item.Date,
Purchases: item.Purchases,
RawMaterialPurchases: item.RawMaterialPurchases,
NonInventoryPurchases: item.NonInventoryPurchases,
PurchaseOrders: item.PurchaseOrders,
RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders,
NonInventoryExpenseCount: item.NonInventoryExpenseCount,
Quantity: item.Quantity,
Ingredients: item.Ingredients,
Vendors: item.Vendors,
}
}
@ -227,7 +231,11 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
GroupBy: req.GroupBy,
Summary: models.PurchasingSummary{
TotalPurchases: result.Summary.TotalPurchases,
RawMaterialPurchases: result.Summary.RawMaterialPurchases,
NonInventoryPurchases: result.Summary.NonInventoryPurchases,
TotalPurchaseOrders: result.Summary.TotalPurchaseOrders,
RawMaterialPurchaseOrders: result.Summary.RawMaterialPurchaseOrders,
NonInventoryExpenseCount: result.Summary.NonInventoryExpenseCount,
TotalQuantity: result.Summary.TotalQuantity,
AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue,
TotalIngredients: result.Summary.TotalIngredients,

View File

@ -75,7 +75,23 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T)
purchasingResult: &entities.PurchasingAnalytics{
OutletName: &outletName,
Summary: entities.PurchasingSummary{
TotalPurchases: 125,
TotalPurchases: 300,
RawMaterialPurchases: 125,
NonInventoryPurchases: 175,
TotalPurchaseOrders: 3,
RawMaterialPurchaseOrders: 1,
NonInventoryExpenseCount: 2,
},
Data: []entities.PurchasingAnalyticsData{
{
Date: now,
Purchases: 300,
RawMaterialPurchases: 125,
NonInventoryPurchases: 175,
PurchaseOrders: 3,
RawMaterialPurchaseOrders: 1,
NonInventoryExpenseCount: 2,
},
},
},
}, expenseRepositoryStub{})
@ -92,7 +108,16 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T)
require.Equal(t, &outletID, result.OutletID)
require.NotNil(t, result.OutletName)
require.Equal(t, outletName, *result.OutletName)
require.Equal(t, float64(125), result.Summary.TotalPurchases)
require.Equal(t, float64(300), result.Summary.TotalPurchases)
require.Equal(t, float64(125), result.Summary.RawMaterialPurchases)
require.Equal(t, float64(175), result.Summary.NonInventoryPurchases)
require.Equal(t, int64(3), result.Summary.TotalPurchaseOrders)
require.Equal(t, int64(1), result.Summary.RawMaterialPurchaseOrders)
require.Equal(t, int64(2), result.Summary.NonInventoryExpenseCount)
require.Len(t, result.Data, 1)
require.Equal(t, float64(300), result.Data[0].Purchases)
require.Equal(t, float64(125), result.Data[0].RawMaterialPurchases)
require.Equal(t, float64(175), result.Data[0].NonInventoryPurchases)
}
func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) {

View File

@ -145,68 +145,179 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or
}
}
summaryQuery := r.db.WithContext(ctx).
Table("inventory_movements im").
Select(`
COALESCE(SUM(im.total_cost), 0) as total_purchases,
COUNT(DISTINCT im.reference_id) as total_purchase_orders,
COALESCE(SUM(im.quantity), 0) as total_quantity,
rawMaterialOutletFilter := ""
nonInventoryOutletFilter := ""
rawMaterialSummaryArgs := []interface{}{
organizationID,
entities.InventoryMovementTypePurchase,
"INGREDIENT",
entities.InventoryMovementReferenceTypePurchaseOrder,
dateFrom,
dateTo,
}
nonInventorySummaryArgs := []interface{}{
organizationID,
entities.PurchaseCategoryTypeNonInventory,
"approved",
dateFrom,
dateTo,
}
if outletID != nil {
rawMaterialOutletFilter = "AND im.outlet_id = ?"
nonInventoryOutletFilter = "AND e.outlet_id = ?"
rawMaterialSummaryArgs = append(rawMaterialSummaryArgs, *outletID)
nonInventorySummaryArgs = append(nonInventorySummaryArgs, *outletID)
}
summaryArgs := append(rawMaterialSummaryArgs, nonInventorySummaryArgs...)
summaryQuery := `
WITH raw_material AS (
SELECT
COALESCE(SUM(im.total_cost), 0) as raw_material_purchases,
COUNT(DISTINCT im.reference_id) as raw_material_purchase_orders,
COALESCE(SUM(im.quantity), 0) as total_quantity,
COUNT(DISTINCT im.item_id) as total_ingredients,
COUNT(DISTINCT po.vendor_id) as total_vendors
FROM inventory_movements im
LEFT JOIN purchase_orders po ON im.reference_id = po.id
WHERE im.organization_id = ?
AND im.movement_type = ?
AND im.item_type = ?
AND im.reference_type = ?
AND im.created_at >= ? AND im.created_at <= ?
` + rawMaterialOutletFilter + `
),
non_inventory AS (
SELECT
COALESCE(SUM(ei.amount), 0) as non_inventory_purchases,
COUNT(DISTINCT e.id) as non_inventory_expense_count
FROM expense_items ei
JOIN expenses e ON ei.expense_id = e.id
JOIN purchase_categories pc ON ei.purchase_category_id = pc.id
WHERE e.organization_id = ?
AND pc.type = ?
AND e.status = ?
AND e.transaction_date >= ? AND e.transaction_date <= ?
` + nonInventoryOutletFilter + `
)
SELECT
rm.raw_material_purchases + ni.non_inventory_purchases as total_purchases,
rm.raw_material_purchases,
ni.non_inventory_purchases,
rm.raw_material_purchase_orders + ni.non_inventory_expense_count as total_purchase_orders,
rm.raw_material_purchase_orders,
ni.non_inventory_expense_count,
rm.total_quantity,
CASE
WHEN COUNT(DISTINCT im.reference_id) > 0
THEN COALESCE(SUM(im.total_cost), 0) / COUNT(DISTINCT im.reference_id)
WHEN rm.raw_material_purchase_orders + ni.non_inventory_expense_count > 0
THEN (rm.raw_material_purchases + ni.non_inventory_purchases) / (rm.raw_material_purchase_orders + ni.non_inventory_expense_count)
ELSE 0
END as average_purchase_order_value,
COUNT(DISTINCT im.item_id) as total_ingredients,
COUNT(DISTINCT po.vendor_id) as total_vendors
`).
Joins("LEFT JOIN purchase_orders po ON im.reference_id = po.id").
Where("im.organization_id = ?", organizationID).
Where("im.movement_type = ?", entities.InventoryMovementTypePurchase).
Where("im.item_type = ?", "INGREDIENT").
Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder).
Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo)
rm.total_ingredients,
rm.total_vendors
FROM raw_material rm
CROSS JOIN non_inventory ni
`
summaryQuery = r.resolveOutletID(summaryQuery, outletID, "im.outlet_id")
if err := summaryQuery.Scan(&summary).Error; err != nil {
if err := r.db.WithContext(ctx).Raw(summaryQuery, summaryArgs...).Scan(&summary).Error; err != nil {
return nil, err
}
var dateFormat string
switch groupBy {
case "hour":
dateFormat = "DATE_TRUNC('hour', im.created_at)"
dateFormat = "DATE_TRUNC('hour', im.created_at)::timestamp"
case "week":
dateFormat = "DATE_TRUNC('week', im.created_at)"
dateFormat = "DATE_TRUNC('week', im.created_at)::timestamp"
case "month":
dateFormat = "DATE_TRUNC('month', im.created_at)"
dateFormat = "DATE_TRUNC('month', im.created_at)::timestamp"
default:
dateFormat = "DATE_TRUNC('day', im.created_at)"
dateFormat = "DATE_TRUNC('day', im.created_at)::timestamp"
}
nonInventoryDateFormat := "DATE_TRUNC('day', e.transaction_date)::timestamp"
switch groupBy {
case "hour":
nonInventoryDateFormat = "DATE_TRUNC('hour', e.transaction_date)::timestamp"
case "week":
nonInventoryDateFormat = "DATE_TRUNC('week', e.transaction_date)::timestamp"
case "month":
nonInventoryDateFormat = "DATE_TRUNC('month', e.transaction_date)::timestamp"
}
rawMaterialDataArgs := []interface{}{
organizationID,
entities.InventoryMovementTypePurchase,
"INGREDIENT",
entities.InventoryMovementReferenceTypePurchaseOrder,
dateFrom,
dateTo,
}
nonInventoryDataArgs := []interface{}{
organizationID,
entities.PurchaseCategoryTypeNonInventory,
"approved",
dateFrom,
dateTo,
}
if outletID != nil {
rawMaterialDataArgs = append(rawMaterialDataArgs, *outletID)
nonInventoryDataArgs = append(nonInventoryDataArgs, *outletID)
}
dataArgs := append(rawMaterialDataArgs, nonInventoryDataArgs...)
var data []entities.PurchasingAnalyticsData
dataQuery := r.db.WithContext(ctx).
Table("inventory_movements im").
Select(`
`+dateFormat+` as date,
COALESCE(SUM(im.total_cost), 0) as purchases,
COUNT(DISTINCT im.reference_id) as purchase_orders,
COALESCE(SUM(im.quantity), 0) as quantity,
COUNT(DISTINCT im.item_id) as ingredients,
COUNT(DISTINCT po.vendor_id) as vendors
`).
Joins("LEFT JOIN purchase_orders po ON im.reference_id = po.id").
Where("im.organization_id = ?", organizationID).
Where("im.movement_type = ?", entities.InventoryMovementTypePurchase).
Where("im.item_type = ?", "INGREDIENT").
Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder).
Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo).
Group(dateFormat).
Order(dateFormat)
dataQuery := `
WITH raw_material AS (
SELECT
` + dateFormat + ` as date,
COALESCE(SUM(im.total_cost), 0) as raw_material_purchases,
COUNT(DISTINCT im.reference_id) as raw_material_purchase_orders,
COALESCE(SUM(im.quantity), 0) as quantity,
COUNT(DISTINCT im.item_id) as ingredients,
COUNT(DISTINCT po.vendor_id) as vendors
FROM inventory_movements im
LEFT JOIN purchase_orders po ON im.reference_id = po.id
WHERE im.organization_id = ?
AND im.movement_type = ?
AND im.item_type = ?
AND im.reference_type = ?
AND im.created_at >= ? AND im.created_at <= ?
` + rawMaterialOutletFilter + `
GROUP BY 1
),
non_inventory AS (
SELECT
` + nonInventoryDateFormat + ` as date,
COALESCE(SUM(ei.amount), 0) as non_inventory_purchases,
COUNT(DISTINCT e.id) as non_inventory_expense_count
FROM expense_items ei
JOIN expenses e ON ei.expense_id = e.id
JOIN purchase_categories pc ON ei.purchase_category_id = pc.id
WHERE e.organization_id = ?
AND pc.type = ?
AND e.status = ?
AND e.transaction_date >= ? AND e.transaction_date <= ?
` + nonInventoryOutletFilter + `
GROUP BY 1
)
SELECT
COALESCE(rm.date, ni.date) as date,
COALESCE(rm.raw_material_purchases, 0) + COALESCE(ni.non_inventory_purchases, 0) as purchases,
COALESCE(rm.raw_material_purchases, 0) as raw_material_purchases,
COALESCE(ni.non_inventory_purchases, 0) as non_inventory_purchases,
COALESCE(rm.raw_material_purchase_orders, 0) + COALESCE(ni.non_inventory_expense_count, 0) as purchase_orders,
COALESCE(rm.raw_material_purchase_orders, 0) as raw_material_purchase_orders,
COALESCE(ni.non_inventory_expense_count, 0) as non_inventory_expense_count,
COALESCE(rm.quantity, 0) as quantity,
COALESCE(rm.ingredients, 0) as ingredients,
COALESCE(rm.vendors, 0) as vendors
FROM raw_material rm
FULL OUTER JOIN non_inventory ni ON rm.date = ni.date
ORDER BY date
`
dataQuery = r.resolveOutletID(dataQuery, outletID, "im.outlet_id")
if err := dataQuery.Scan(&data).Error; err != nil {
if err := r.db.WithContext(ctx).Raw(dataQuery, dataArgs...).Scan(&data).Error; err != nil {
return nil, err
}

View File

@ -169,12 +169,16 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse
data := make([]contract.PurchasingAnalyticsData, len(resp.Data))
for i, item := range resp.Data {
data[i] = contract.PurchasingAnalyticsData{
Date: item.Date,
Purchases: item.Purchases,
PurchaseOrders: item.PurchaseOrders,
Quantity: item.Quantity,
Ingredients: item.Ingredients,
Vendors: item.Vendors,
Date: item.Date,
Purchases: item.Purchases,
RawMaterialPurchases: item.RawMaterialPurchases,
NonInventoryPurchases: item.NonInventoryPurchases,
PurchaseOrders: item.PurchaseOrders,
RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders,
NonInventoryExpenseCount: item.NonInventoryExpenseCount,
Quantity: item.Quantity,
Ingredients: item.Ingredients,
Vendors: item.Vendors,
}
}
@ -211,7 +215,11 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse
GroupBy: resp.GroupBy,
Summary: contract.PurchasingSummary{
TotalPurchases: resp.Summary.TotalPurchases,
RawMaterialPurchases: resp.Summary.RawMaterialPurchases,
NonInventoryPurchases: resp.Summary.NonInventoryPurchases,
TotalPurchaseOrders: resp.Summary.TotalPurchaseOrders,
RawMaterialPurchaseOrders: resp.Summary.RawMaterialPurchaseOrders,
NonInventoryExpenseCount: resp.Summary.NonInventoryExpenseCount,
TotalQuantity: resp.Summary.TotalQuantity,
AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue,
TotalIngredients: resp.Summary.TotalIngredients,

View File

@ -52,17 +52,47 @@ func TestPurchasingAnalyticsContractToModelIgnoresInvalidOutlet(t *testing.T) {
func TestPurchasingAnalyticsModelToContractCopiesOutletName(t *testing.T) {
outletID := uuid.New()
outletName := "Main Outlet"
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{
OrganizationID: uuid.New(),
OutletID: &outletID,
OutletName: &outletName,
Summary: models.PurchasingSummary{
TotalPurchases: 300,
RawMaterialPurchases: 125,
NonInventoryPurchases: 175,
TotalPurchaseOrders: 3,
RawMaterialPurchaseOrders: 1,
NonInventoryExpenseCount: 2,
},
Data: []models.PurchasingAnalyticsData{
{
Date: now,
Purchases: 300,
RawMaterialPurchases: 125,
NonInventoryPurchases: 175,
PurchaseOrders: 3,
RawMaterialPurchaseOrders: 1,
NonInventoryExpenseCount: 2,
},
},
})
require.NotNil(t, result)
require.Equal(t, &outletID, result.OutletID)
require.NotNil(t, result.OutletName)
require.Equal(t, outletName, *result.OutletName)
require.Equal(t, float64(300), result.Summary.TotalPurchases)
require.Equal(t, float64(125), result.Summary.RawMaterialPurchases)
require.Equal(t, float64(175), result.Summary.NonInventoryPurchases)
require.Equal(t, int64(3), result.Summary.TotalPurchaseOrders)
require.Equal(t, int64(1), result.Summary.RawMaterialPurchaseOrders)
require.Equal(t, int64(2), result.Summary.NonInventoryExpenseCount)
require.Len(t, result.Data, 1)
require.Equal(t, float64(300), result.Data[0].Purchases)
require.Equal(t, float64(125), result.Data[0].RawMaterialPurchases)
require.Equal(t, float64(175), result.Data[0].NonInventoryPurchases)
}
func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) {