Compare commits

..
Author SHA1 Message Date
ryan fbc65c5606 Add grouping by outlet_id 2026-06-24 16:30:40 +07:00
16 changed files with 286 additions and 198 deletions
-4
View File
@@ -9,7 +9,3 @@ vendor
# Firebase service account credentials # Firebase service account credentials
infra/firebase-service-account.json infra/firebase-service-account.json
# Config files containing secrets (manage manually on each server)
# infra/production.yaml
# infra/staging.yaml
+7 -27
View File
@@ -1,21 +1,9 @@
#PROJECT_NAME = "enaklo-pos-backend" #PROJECT_NAME = "enaklo-pos-backend"
DB_USERNAME :=apskel
# ─── Environment (default: staging) ────────────────────────────────────────── DB_PASSWORD :=7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk
ENV ?= staging DB_HOST :=62.72.45.250
DB_PORT :=5433
ifeq ($(ENV),production) DB_NAME :=apskel_pos
DB_USERNAME :=apskel
DB_PASSWORD :=7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk
DB_HOST :=62.72.45.250
DB_PORT :=5433
DB_NAME :=apskel_pos
else
DB_USERNAME :=apskel
DB_PASSWORD :=7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk
DB_HOST :=62.72.45.250
DB_PORT :=5433
DB_NAME :=apskel_pos_staging
endif
DB_URL = postgres://$(DB_USERNAME):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=disable DB_URL = postgres://$(DB_USERNAME):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=disable
@@ -28,19 +16,15 @@ endif
.SILENT: help .SILENT: help
help: help:
@echo @echo
@echo "Usage: make [command] [ENV=staging|production]" @echo "Usage: make [command]"
@echo @echo
@echo "Commands:" @echo "Commands:"
@echo " run Run server (default: staging)"
@echo " run ENV=production Run server with production config"
@echo
@echo " rename-project name={name} Rename project" @echo " rename-project name={name} Rename project"
@echo @echo
@echo " build-http Build http server" @echo " build-http Build http server"
@echo @echo
@echo " migration-create name={name} Create migration" @echo " migration-create name={name} Create migration"
@echo " migration-up Up migrations" @echo " migration-up Up migrations"
@echo " migration-up ENV=production Up migrations (production DB)"
@echo " migration-down Down last migration" @echo " migration-down Down last migration"
@echo @echo
@echo " docker-up Up docker services" @echo " docker-up Up docker services"
@@ -130,11 +114,7 @@ fmt:
@go fmt ./... @go fmt ./...
start: start:
ENV_MODE=$(ENV) go run cmd/server/main.go go run main.go --env-path .env
.SILENT: run
run:
ENV_MODE=$(ENV) go run cmd/server/main.go
# Default # Default
+1 -2
View File
@@ -12,14 +12,13 @@ import (
const ( const (
YAML_PATH = "infra/%s" YAML_PATH = "infra/%s"
ENV_MODE = "ENV_MODE" ENV_MODE = "ENV_MODE"
DEFAULT_ENV_MODE = "staging" DEFAULT_ENV_MODE = "development"
) )
var ( var (
validEnvMode = map[string]struct{}{ validEnvMode = map[string]struct{}{
"local": {}, "local": {},
"development": {}, "development": {},
"staging": {},
"production": {}, "production": {},
} }
) )
+8 -46
View File
@@ -2,61 +2,23 @@
set -euo pipefail set -euo pipefail
APP_NAME="apskel-pos" APP_NAME="apskel-pos"
PORT="4000"
# ─── Deteksi environment dari branch aktif ───────────────────────────────────
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
case "$CURRENT_BRANCH" in
main)
ENV_MODE="production"
PORT="4000"
;;
staging)
ENV_MODE="staging"
PORT="4001"
;;
*)
echo "❌ Branch '$CURRENT_BRANCH' tidak dikenali untuk deployment."
echo " Gunakan branch 'main' (production) atau 'staging' (staging)."
exit 1
;;
esac
CONTAINER_NAME="$APP_NAME"
IMAGE_NAME="$APP_NAME:$ENV_MODE"
echo "📦 Environment : $ENV_MODE"
echo "🌿 Branch : $CURRENT_BRANCH"
echo "🐳 Container : $CONTAINER_NAME"
echo "🔌 Port : $PORT"
echo ""
# ─── Pastikan config file ada ─────────────────────────────────────────────────
CONFIG_FILE="infra/$ENV_MODE.yaml"
if [ ! -f "$CONFIG_FILE" ]; then
echo "❌ Config file '$CONFIG_FILE' tidak ditemukan."
exit 1
fi
echo "🔄 Pulling latest code..." echo "🔄 Pulling latest code..."
git pull git pull
echo "🐳 Building Docker image ($ENV_MODE)..." echo "🐳 Building Docker image (production target)..."
docker build --target production -t "$IMAGE_NAME" . docker build --target production -t $APP_NAME:latest .
echo "🛑 Stopping and removing old container..." echo "🛑 Stopping and removing old container..."
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true docker rm -f $APP_NAME 2>/dev/null || true
echo "🚀 Running new container..." echo "🚀 Running new container..."
docker run -d --name "$CONTAINER_NAME" \ docker run -d --name $APP_NAME \
-p "$PORT:4000" \ -p $PORT:$PORT \
-e TZ=Asia/Jakarta \ -e TZ=Asia/Jakarta \
-e ENV_MODE="$ENV_MODE" \
-v "$(pwd)/infra":/infra:ro \ -v "$(pwd)/infra":/infra:ro \
-v "$(pwd)/templates":/templates:ro \ -v "$(pwd)/templates":/templates:ro \
"$IMAGE_NAME" $APP_NAME:latest
echo "" echo "✅ Deployment complete."
echo "✅ Deployment $ENV_MODE complete."
echo " Container : $CONTAINER_NAME"
echo " Port : $PORT"
@@ -1,7 +1,7 @@
server: server:
base-url: base-url:
local-url: local-url:
self-order-url: self-order-url: http://localhost:5173
port: 4000 port: 4000
jwt: jwt:
@@ -9,7 +9,7 @@ jwt:
expires-ttl: 144000 expires-ttl: 144000
secret: "5Lm25V3Qd7aut8dr4QUxm5PZUrSFs" secret: "5Lm25V3Qd7aut8dr4QUxm5PZUrSFs"
refresh_token: refresh_token:
expires-ttl: 7776000 expires-ttl: 7776000 # 3 months in minutes (90 days * 24 hours * 60 minutes)
secret: "R3fr3sh_T0k3n_S3cr3t_K3y_2024_P0S" secret: "R3fr3sh_T0k3n_S3cr3t_K3y_2024_P0S"
customer: customer:
expires-ttl: 7776000 expires-ttl: 7776000
@@ -21,7 +21,7 @@ postgresql:
driver: postgres driver: postgres
db: apskel_pos db: apskel_pos
username: apskel username: apskel
password: "7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk" password: '7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk'
ssl-mode: disable ssl-mode: disable
max-idle-connections-in-second: 600 max-idle-connections-in-second: 600
max-open-connections-in-second: 600 max-open-connections-in-second: 600
@@ -45,11 +45,11 @@ s3:
endpoint: sin1.contabostorage.com endpoint: sin1.contabostorage.com
bucket_name: enaklo bucket_name: enaklo
log_level: Error log_level: Error
host_url: "https://sin1.contabostorage.com/fda98c2228f246f29a7e466b86b3b9e7:" host_url: 'https://sin1.contabostorage.com/fda98c2228f246f29a7e466b86b3b9e7:'
log: log:
log_format: "json" log_format: 'json'
log_level: "info" log_level: 'debug'
fonnte: fonnte:
api_url: "https://api.fonnte.com/send" api_url: "https://api.fonnte.com/send"
@@ -58,4 +58,4 @@ fonnte:
fcm: fcm:
credentials_file: "infra/firebase-service-account.json" credentials_file: "infra/firebase-service-account.json"
project_id: "apskel-pos-v2" project_id: "apskel-pos-v2"
-61
View File
@@ -1,61 +0,0 @@
server:
base-url:
local-url:
self-order-url:
port: 4000
jwt:
token:
expires-ttl: 144000
secret: "eZ7LAZJuSOGSHxb1ZYaZCkrBo5YBvc"
refresh_token:
expires-ttl: 7776000
secret: "EMx2DKPtMp0jQNpLvzzCsZkoUHe0d9"
customer:
expires-ttl: 7776000
secret: "layCV2rne0X57acWzSS3NxENmYJs7B"
postgresql:
host: 62.72.45.250
port: 5433
driver: postgres
db: apskel_pos_staging
username: apskel
password: "7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk"
ssl-mode: disable
max-idle-connections-in-second: 600
max-open-connections-in-second: 600
connection-max-life-time-in-second: 600
debug: false
redis:
host: 62.72.45.250
port: 6380
password: "CmICdmnX1EZPhVBYzQPEGw==U"
db: 1
dial_timeout: 5s
read_timeout: 3s
write_timeout: 3s
pool_size: 10
min_idle_connections: 5
s3:
access_key_id: cf9a475e18bc7626cbdbf09709d82a64
access_key_secret: 91f3321294d3e23035427a0ecb893ada
endpoint: sin1.contabostorage.com
bucket_name: enaklo
log_level: Error
host_url: "https://sin1.contabostorage.com/fda98c2228f246f29a7e466b86b3b9e7:"
log:
log_format: "json"
log_level: "info"
fonnte:
api_url: "https://api.fonnte.com/send"
token: "bADQrf9NTXfLZQCK2wGg"
timeout: 30
fcm:
credentials_file: "infra/firebase-service-account.json"
project_id: "apskel-pos-v2"
+16 -1
View File
@@ -90,7 +90,7 @@ type PurchasingAnalyticsRequest struct {
OutletID *string `form:"outlet_id,omitempty"` OutletID *string `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"` DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"` DateTo string `form:"date_to" validate:"required"`
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"` GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month outlet_id"`
} }
type PurchasingAnalyticsResponse struct { type PurchasingAnalyticsResponse struct {
@@ -102,6 +102,7 @@ type PurchasingAnalyticsResponse struct {
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
Summary PurchasingSummary `json:"summary"` Summary PurchasingSummary `json:"summary"`
Data []PurchasingAnalyticsData `json:"data"` Data []PurchasingAnalyticsData `json:"data"`
OutletData []PurchasingOutletData `json:"outlet_data,omitempty"`
IngredientData []PurchasingIngredientData `json:"ingredient_data"` IngredientData []PurchasingIngredientData `json:"ingredient_data"`
VendorData []PurchasingVendorData `json:"vendor_data"` VendorData []PurchasingVendorData `json:"vendor_data"`
} }
@@ -132,6 +133,20 @@ type PurchasingAnalyticsData struct {
Vendors int64 `json:"vendors"` Vendors int64 `json:"vendors"`
} }
type PurchasingOutletData struct {
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName string `json:"outlet_name"`
Purchases float64 `json:"purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
}
type PurchasingIngredientData struct { type PurchasingIngredientData struct {
IngredientID uuid.UUID `json:"ingredient_id"` IngredientID uuid.UUID `json:"ingredient_id"`
IngredientName string `json:"ingredient_name"` IngredientName string `json:"ingredient_name"`
+24 -9
View File
@@ -32,6 +32,7 @@ type PurchasingAnalytics struct {
OutletName *string `json:"outlet_name,omitempty"` OutletName *string `json:"outlet_name,omitempty"`
Summary PurchasingSummary `json:"summary"` Summary PurchasingSummary `json:"summary"`
Data []PurchasingAnalyticsData `json:"data"` Data []PurchasingAnalyticsData `json:"data"`
OutletData []PurchasingOutletData `json:"outlet_data,omitempty"`
IngredientData []PurchasingIngredientData `json:"ingredient_data"` IngredientData []PurchasingIngredientData `json:"ingredient_data"`
VendorData []PurchasingVendorData `json:"vendor_data"` VendorData []PurchasingVendorData `json:"vendor_data"`
} }
@@ -62,6 +63,20 @@ type PurchasingAnalyticsData struct {
Vendors int64 `json:"vendors"` Vendors int64 `json:"vendors"`
} }
type PurchasingOutletData struct {
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName string `json:"outlet_name"`
Purchases float64 `json:"purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
}
type PurchasingIngredientData struct { type PurchasingIngredientData struct {
IngredientID uuid.UUID `json:"ingredient_id"` IngredientID uuid.UUID `json:"ingredient_id"`
IngredientName string `json:"ingredient_name"` IngredientName string `json:"ingredient_name"`
@@ -114,15 +129,15 @@ type ProductAnalyticsPerCategory struct {
// DashboardOverview represents dashboard overview data // DashboardOverview represents dashboard overview data
type DashboardOverview struct { type DashboardOverview struct {
TotalSales float64 `json:"total_sales"` TotalSales float64 `json:"total_sales"`
TotalOrders int64 `json:"total_orders"` TotalOrders int64 `json:"total_orders"`
AverageOrderValue float64 `json:"average_order_value"` AverageOrderValue float64 `json:"average_order_value"`
TotalCustomers int64 `json:"total_customers"` TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"` VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_orders"` RefundedOrders int64 `json:"refunded_orders"`
TotalItemSold int64 `json:"total_item_sold"` TotalItemSold int64 `json:"total_item_sold"`
TotalLowStock int64 `json:"total_low_stock"` TotalLowStock int64 `json:"total_low_stock"`
TotalProductActive int64 `json:"total_product_active"` TotalProductActive int64 `json:"total_product_active"`
} }
type ProfitLossAnalytics struct { type ProfitLossAnalytics struct {
+22 -7
View File
@@ -95,7 +95,7 @@ type PurchasingAnalyticsRequest struct {
OutletID *uuid.UUID `validate:"omitempty"` OutletID *uuid.UUID `validate:"omitempty"`
DateFrom time.Time `validate:"required"` DateFrom time.Time `validate:"required"`
DateTo time.Time `validate:"required"` DateTo time.Time `validate:"required"`
GroupBy string `validate:"omitempty,oneof=day hour week month"` GroupBy string `validate:"omitempty,oneof=day hour week month outlet_id"`
} }
// PurchasingAnalyticsResponse represents the response for purchasing analytics // PurchasingAnalyticsResponse represents the response for purchasing analytics
@@ -108,6 +108,7 @@ type PurchasingAnalyticsResponse struct {
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
Summary PurchasingSummary `json:"summary"` Summary PurchasingSummary `json:"summary"`
Data []PurchasingAnalyticsData `json:"data"` Data []PurchasingAnalyticsData `json:"data"`
OutletData []PurchasingOutletData `json:"outlet_data,omitempty"`
IngredientData []PurchasingIngredientData `json:"ingredient_data"` IngredientData []PurchasingIngredientData `json:"ingredient_data"`
VendorData []PurchasingVendorData `json:"vendor_data"` VendorData []PurchasingVendorData `json:"vendor_data"`
} }
@@ -140,6 +141,20 @@ type PurchasingAnalyticsData struct {
Vendors int64 `json:"vendors"` Vendors int64 `json:"vendors"`
} }
type PurchasingOutletData struct {
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName string `json:"outlet_name"`
Purchases float64 `json:"purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
}
// PurchasingIngredientData represents purchasing analytics for an ingredient // PurchasingIngredientData represents purchasing analytics for an ingredient
type PurchasingIngredientData struct { type PurchasingIngredientData struct {
IngredientID uuid.UUID `json:"ingredient_id"` IngredientID uuid.UUID `json:"ingredient_id"`
@@ -288,12 +303,12 @@ type ProfitLossAnalyticsResponse struct {
} }
type ProfitLossPurchasing struct { type ProfitLossPurchasing struct {
TodayTotal float64 `json:"today_total"` TodayTotal float64 `json:"today_total"`
MtdTotal float64 `json:"mtd_total"` MtdTotal float64 `json:"mtd_total"`
TodayRawMaterial float64 `json:"today_raw_material"` TodayRawMaterial float64 `json:"today_raw_material"`
MtdRawMaterial float64 `json:"mtd_raw_material"` MtdRawMaterial float64 `json:"mtd_raw_material"`
TodayExpense float64 `json:"today_expense"` TodayExpense float64 `json:"today_expense"`
MtdExpense float64 `json:"mtd_expense"` MtdExpense float64 `json:"mtd_expense"`
Items []ProfitLossPurchasingItem `json:"items"` Items []ProfitLossPurchasingItem `json:"items"`
} }
+18
View File
@@ -218,6 +218,23 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
} }
} }
outletData := make([]models.PurchasingOutletData, len(result.OutletData))
for i, item := range result.OutletData {
outletData[i] = models.PurchasingOutletData{
OutletID: item.OutletID,
OutletName: item.OutletName,
Purchases: item.Purchases,
RawMaterialPurchases: item.RawMaterialPurchases,
ExpensePurchases: item.ExpensePurchases,
PurchaseOrders: item.PurchaseOrders,
RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders,
ExpenseCount: item.ExpenseCount,
Quantity: item.Quantity,
Ingredients: item.Ingredients,
Vendors: item.Vendors,
}
}
ingredientData := make([]models.PurchasingIngredientData, len(result.IngredientData)) ingredientData := make([]models.PurchasingIngredientData, len(result.IngredientData))
for i, item := range result.IngredientData { for i, item := range result.IngredientData {
ingredientData[i] = models.PurchasingIngredientData{ ingredientData[i] = models.PurchasingIngredientData{
@@ -262,6 +279,7 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
TotalVendors: result.Summary.TotalVendors, TotalVendors: result.Summary.TotalVendors,
}, },
Data: data, Data: data,
OutletData: outletData,
IngredientData: ingredientData, IngredientData: ingredientData,
VendorData: vendorData, VendorData: vendorData,
}, nil }, nil
@@ -145,6 +145,52 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T)
require.Equal(t, float64(175), result.Data[0].ExpensePurchases) require.Equal(t, float64(175), result.Data[0].ExpensePurchases)
} }
func TestAnalyticsProcessorGetPurchasingAnalyticsMapsOutletData(t *testing.T) {
outletID := uuid.New()
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
purchasingResult: &entities.PurchasingAnalytics{
Summary: entities.PurchasingSummary{
TotalPurchases: 500,
},
OutletData: []entities.PurchasingOutletData{
{
OutletID: &outletID,
OutletName: "Outlet A",
Purchases: 500,
RawMaterialPurchases: 350,
ExpensePurchases: 150,
PurchaseOrders: 4,
RawMaterialPurchaseOrders: 3,
ExpenseCount: 2,
Quantity: 10,
Ingredients: 5,
Vendors: 2,
},
},
},
}, expenseRepositoryStub{})
result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{
OrganizationID: uuid.New(),
DateFrom: now,
DateTo: now,
GroupBy: "outlet_id",
})
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, "outlet_id", result.GroupBy)
require.Empty(t, result.Data)
require.Len(t, result.OutletData, 1)
require.Equal(t, &outletID, result.OutletData[0].OutletID)
require.Equal(t, "Outlet A", result.OutletData[0].OutletName)
require.Equal(t, float64(500), result.OutletData[0].Purchases)
require.Equal(t, float64(350), result.OutletData[0].RawMaterialPurchases)
require.Equal(t, float64(150), result.OutletData[0].ExpensePurchases)
require.Equal(t, int64(4), result.OutletData[0].PurchaseOrders)
}
func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) { func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) {
productID := uuid.New() productID := uuid.New()
categoryID := uuid.New() categoryID := uuid.New()
+65 -30
View File
@@ -224,34 +224,68 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
} }
var data []entities.PurchasingAnalyticsData var data []entities.PurchasingAnalyticsData
dataQuery := r.db.WithContext(ctx). var outletData []entities.PurchasingOutletData
Table("purchase_orders po"). if groupBy == "outlet_id" {
Select(` outletQuery := r.db.WithContext(ctx).
`+dateFormat+` as date, Table("purchase_orders po").
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as purchases, Select(`
COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material_purchases, po.outlet_id as outlet_id,
COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense_purchases, COALESCE(o.name, 'No Outlet') as outlet_name,
COUNT(DISTINCT po.id) as purchase_orders, COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as purchases,
COUNT(DISTINCT CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeRawMaterial)+`' THEN po.id END) as raw_material_purchase_orders, COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material_purchases,
COUNT(CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeExpense)+`' THEN poi.id END) as expense_count, COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense_purchases,
COALESCE(SUM(poi.quantity), 0) as quantity, COUNT(DISTINCT po.id) as purchase_orders,
COUNT(DISTINCT i.id) as ingredients, COUNT(DISTINCT CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeRawMaterial)+`' THEN po.id END) as raw_material_purchase_orders,
COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) as vendors COUNT(CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeExpense)+`' THEN poi.id END) as expense_count,
`). COALESCE(SUM(poi.quantity), 0) as quantity,
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). COUNT(DISTINCT i.id) as ingredients,
Joins("LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.id"). COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) as vendors
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id"). `).
Joins("LEFT JOIN units u ON poi.unit_id = u.id"). Joins("LEFT JOIN outlets o ON po.outlet_id = o.id").
Where("po.organization_id = ?", organizationID). Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
Where("po.status != ?", "cancelled"). Joins("LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Group(dateFormat). Where("po.organization_id = ?", organizationID).
Order(dateFormat) Where("po.status != ?", "cancelled").
dataQuery = r.applyPurchaseOrderItemOutletFilter(dataQuery, outletID) Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group("po.outlet_id, COALESCE(o.name, 'No Outlet')").
Order("outlet_name ASC")
outletQuery = r.applyPurchaseOrderItemOutletFilter(outletQuery, outletID)
if err := dataQuery.Scan(&data).Error; err != nil { if err := outletQuery.Scan(&outletData).Error; err != nil {
return nil, err return nil, err
}
} else {
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)
if err := dataQuery.Scan(&data).Error; err != nil {
return nil, err
}
} }
var ingredientData []entities.PurchasingIngredientData var ingredientData []entities.PurchasingIngredientData
@@ -315,6 +349,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
OutletName: outletName, OutletName: outletName,
Summary: summary, Summary: summary,
Data: data, Data: data,
OutletData: outletData,
IngredientData: ingredientData, IngredientData: ingredientData,
VendorData: vendorData, VendorData: vendorData,
}, nil }, nil
@@ -806,9 +841,9 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderRawMaterialTotal(ctx context.C
} }
type purchasingTotals struct { type purchasingTotals struct {
Total float64 Total float64
RawMaterial float64 RawMaterial float64
Expense float64 Expense float64
} }
func (r *AnalyticsRepositoryImpl) getPurchaseOrderTotals(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) (purchasingTotals, error) { func (r *AnalyticsRepositoryImpl) getPurchaseOrderTotals(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) (purchasingTotals, error) {
+5 -4
View File
@@ -196,10 +196,11 @@ func (s *AnalyticsServiceImpl) validatePurchasingAnalyticsRequest(req *models.Pu
if req.GroupBy != "" { if req.GroupBy != "" {
validGroupBy := map[string]bool{ validGroupBy := map[string]bool{
"day": true, "day": true,
"hour": true, "hour": true,
"week": true, "week": true,
"month": true, "month": true,
"outlet_id": true,
} }
if !validGroupBy[req.GroupBy] { if !validGroupBy[req.GroupBy] {
return fmt.Errorf("invalid group_by value: %s", req.GroupBy) return fmt.Errorf("invalid group_by value: %s", req.GroupBy)
@@ -132,6 +132,21 @@ func TestAnalyticsServiceGetPurchasingAnalyticsAllowsEmptyGroupBy(t *testing.T)
require.NotNil(t, resp) require.NotNil(t, resp)
} }
func TestAnalyticsServiceGetPurchasingAnalyticsAllowsOutletGroupBy(t *testing.T) {
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
resp, err := service.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{
OrganizationID: uuid.New(),
DateFrom: now,
DateTo: now,
GroupBy: "outlet_id",
})
require.NoError(t, err)
require.NotNil(t, resp)
}
func TestAnalyticsServiceGetProfitLossAnalyticsValidation(t *testing.T) { func TestAnalyticsServiceGetProfitLossAnalyticsValidation(t *testing.T) {
service := NewAnalyticsServiceImpl(analyticsProcessorStub{}) service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
@@ -184,6 +184,23 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse
} }
} }
outletData := make([]contract.PurchasingOutletData, len(resp.OutletData))
for i, item := range resp.OutletData {
outletData[i] = contract.PurchasingOutletData{
OutletID: item.OutletID,
OutletName: item.OutletName,
Purchases: item.Purchases,
RawMaterialPurchases: item.RawMaterialPurchases,
ExpensePurchases: item.ExpensePurchases,
PurchaseOrders: item.PurchaseOrders,
RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders,
ExpenseCount: item.ExpenseCount,
Quantity: item.Quantity,
Ingredients: item.Ingredients,
Vendors: item.Vendors,
}
}
ingredientData := make([]contract.PurchasingIngredientData, len(resp.IngredientData)) ingredientData := make([]contract.PurchasingIngredientData, len(resp.IngredientData))
for i, item := range resp.IngredientData { for i, item := range resp.IngredientData {
ingredientData[i] = contract.PurchasingIngredientData{ ingredientData[i] = contract.PurchasingIngredientData{
@@ -228,6 +245,7 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse
TotalVendors: resp.Summary.TotalVendors, TotalVendors: resp.Summary.TotalVendors,
}, },
Data: data, Data: data,
OutletData: outletData,
IngredientData: ingredientData, IngredientData: ingredientData,
VendorData: vendorData, VendorData: vendorData,
} }
@@ -105,6 +105,40 @@ func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) {
require.NotContains(t, string(payload), "outlet_name") require.NotContains(t, string(payload), "outlet_name")
} }
func TestPurchasingAnalyticsModelToContractCopiesOutletData(t *testing.T) {
outletID := uuid.New()
result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{
OrganizationID: uuid.New(),
GroupBy: "outlet_id",
OutletData: []models.PurchasingOutletData{
{
OutletID: &outletID,
OutletName: "Outlet A",
Purchases: 500,
RawMaterialPurchases: 350,
ExpensePurchases: 150,
PurchaseOrders: 4,
RawMaterialPurchaseOrders: 3,
ExpenseCount: 2,
Quantity: 10,
Ingredients: 5,
Vendors: 2,
},
},
})
require.NotNil(t, result)
require.Equal(t, "outlet_id", result.GroupBy)
require.Len(t, result.OutletData, 1)
require.Equal(t, &outletID, result.OutletData[0].OutletID)
require.Equal(t, "Outlet A", result.OutletData[0].OutletName)
require.Equal(t, float64(500), result.OutletData[0].Purchases)
require.Equal(t, float64(350), result.OutletData[0].RawMaterialPurchases)
require.Equal(t, float64(150), result.OutletData[0].ExpensePurchases)
require.Equal(t, int64(4), result.OutletData[0].PurchaseOrders)
}
func TestProfitLossAnalyticsContractToModelParsesDateRange(t *testing.T) { func TestProfitLossAnalyticsContractToModelParsesDateRange(t *testing.T) {
orgID := uuid.New() orgID := uuid.New()
outletID := uuid.New().String() outletID := uuid.New().String()