Compare commits

..
10 Commits
Author SHA1 Message Date
Efril 4f7e774043 feat(products): sort by 2026-08-11 20:47:38 +07:00
Efril a7c2d6cbb3 feat(category): update list filter category 2026-08-06 21:21:41 +07:00
Efril b9ac97178f feat: profit sharing 2026-08-05 19:28:38 +07:00
Efril 2b80c92caa feat: deployment 2026-07-09 22:43:01 +07:00
Efril f7dd0bd5e8 config: update port 2026-07-09 22:19:53 +07:00
Efril 1533914e4d config: prod and staging 2026-07-09 22:11:03 +07:00
Efril bfce4b865b update purchase date 2026-07-02 12:49:28 +07:00
Efril 581e4a5453 update purchase date 2026-07-02 12:23:12 +07:00
Efril 9b0fc9a63b feat: updat analytic profit loss add purchasing 2026-06-24 00:11:04 +07:00
Efril 793919cf10 feat: add outlet name at analytic response and new overview dashboard 2026-06-23 22:18:16 +07:00
32 changed files with 1903 additions and 79 deletions
+4
View File
@@ -9,3 +9,7 @@ vendor
# Firebase service account credentials
infra/firebase-service-account.json
# Config files containing secrets (manage manually on each server)
# infra/production.yaml
# infra/staging.yaml
+27 -7
View File
@@ -1,9 +1,21 @@
#PROJECT_NAME = "enaklo-pos-backend"
DB_USERNAME :=apskel
DB_PASSWORD :=7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk
DB_HOST :=62.72.45.250
DB_PORT :=5433
DB_NAME :=apskel_pos
# ─── Environment (default: staging) ──────────────────────────────────────────
ENV ?= staging
ifeq ($(ENV),production)
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
@@ -16,15 +28,19 @@ endif
.SILENT: help
help:
@echo
@echo "Usage: make [command]"
@echo "Usage: make [command] [ENV=staging|production]"
@echo
@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
@echo " build-http Build http server"
@echo
@echo " migration-create name={name} Create migration"
@echo " migration-up Up migrations"
@echo " migration-up ENV=production Up migrations (production DB)"
@echo " migration-down Down last migration"
@echo
@echo " docker-up Up docker services"
@@ -114,7 +130,11 @@ fmt:
@go fmt ./...
start:
go run main.go --env-path .env
ENV_MODE=$(ENV) go run cmd/server/main.go
.SILENT: run
run:
ENV_MODE=$(ENV) go run cmd/server/main.go
# Default
+2 -1
View File
@@ -12,13 +12,14 @@ import (
const (
YAML_PATH = "infra/%s"
ENV_MODE = "ENV_MODE"
DEFAULT_ENV_MODE = "development"
DEFAULT_ENV_MODE = "staging"
)
var (
validEnvMode = map[string]struct{}{
"local": {},
"development": {},
"staging": {},
"production": {},
}
)
+46 -8
View File
@@ -2,23 +2,61 @@
set -euo pipefail
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..."
git pull
echo "🐳 Building Docker image (production target)..."
docker build --target production -t $APP_NAME:latest .
echo "🐳 Building Docker image ($ENV_MODE)..."
docker build --target production -t "$IMAGE_NAME" .
echo "🛑 Stopping and removing old container..."
docker rm -f $APP_NAME 2>/dev/null || true
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
echo "🚀 Running new container..."
docker run -d --name $APP_NAME \
-p $PORT:$PORT \
docker run -d --name "$CONTAINER_NAME" \
-p "$PORT:4000" \
-e TZ=Asia/Jakarta \
-e ENV_MODE="$ENV_MODE" \
-v "$(pwd)/infra":/infra:ro \
-v "$(pwd)/templates":/templates:ro \
$APP_NAME:latest
"$IMAGE_NAME"
echo "✅ Deployment complete."
echo ""
echo "✅ Deployment $ENV_MODE complete."
echo " Container : $CONTAINER_NAME"
echo " Port : $PORT"
@@ -1,7 +1,7 @@
server:
base-url:
local-url:
self-order-url: http://localhost:5173
self-order-url:
port: 4000
jwt:
@@ -9,7 +9,7 @@ jwt:
expires-ttl: 144000
secret: "5Lm25V3Qd7aut8dr4QUxm5PZUrSFs"
refresh_token:
expires-ttl: 7776000 # 3 months in minutes (90 days * 24 hours * 60 minutes)
expires-ttl: 7776000
secret: "R3fr3sh_T0k3n_S3cr3t_K3y_2024_P0S"
customer:
expires-ttl: 7776000
@@ -21,7 +21,7 @@ postgresql:
driver: postgres
db: apskel_pos
username: apskel
password: '7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk'
password: "7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk"
ssl-mode: disable
max-idle-connections-in-second: 600
max-open-connections-in-second: 600
@@ -45,11 +45,11 @@ s3:
endpoint: sin1.contabostorage.com
bucket_name: enaklo
log_level: Error
host_url: 'https://sin1.contabostorage.com/fda98c2228f246f29a7e466b86b3b9e7:'
host_url: "https://sin1.contabostorage.com/fda98c2228f246f29a7e466b86b3b9e7:"
log:
log_format: 'json'
log_level: 'debug'
log_format: "json"
log_level: "info"
fonnte:
api_url: "https://api.fonnte.com/send"
@@ -58,4 +58,4 @@ fonnte:
fcm:
credentials_file: "infra/firebase-service-account.json"
project_id: "apskel-pos-v2"
project_id: "apskel-pos-v2"
+61
View File
@@ -0,0 +1,61 @@
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"
+9
View File
@@ -0,0 +1,9 @@
package constants
// Budget allocation of revenue used by the parent category cut-off report.
// The three shares are expected to add up to 100.
const (
BudgetLimitPurchasePercent = 60.0
BudgetLimitOwnerPercent = 20.0
BudgetLimitTeamPercent = 20.0
)
+164 -6
View File
@@ -18,6 +18,7 @@ type PaymentMethodAnalyticsRequest struct {
type PaymentMethodAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
@@ -54,6 +55,7 @@ type SalesAnalyticsRequest struct {
type SalesAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
@@ -161,6 +163,7 @@ type ProductAnalyticsRequest struct {
type ProductAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsData `json:"data"`
@@ -198,6 +201,7 @@ type ProductAnalyticsPerCategoryRequest struct {
type ProductAnalyticsPerCategoryResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsPerCategoryData `json:"data"`
@@ -215,6 +219,135 @@ type ProductAnalyticsPerCategoryData struct {
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
}
// ProductAnalyticsPerParentCategoryRequest represents the request for product analytics per parent category
type ProductAnalyticsPerParentCategoryRequest struct {
OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"`
}
// ProductAnalyticsPerParentCategoryResponse represents the response for product analytics per parent category
type ProductAnalyticsPerParentCategoryResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsPerParentCategoryData `json:"data"`
Budget BudgetCutOff `json:"budget"`
}
type ProductAnalyticsPerParentCategoryData struct {
ParentCategoryID uuid.UUID `json:"parent_category_id"`
ParentCategoryName string `json:"parent_category_name"`
TotalRevenue float64 `json:"total_revenue"`
TotalQuantity int64 `json:"total_quantity"`
CategoryCount int64 `json:"category_count"`
ProductCount int64 `json:"product_count"`
OrderCount int64 `json:"order_count"`
TotalStandardHpp float64 `json:"total_standard_hpp"`
TotalFifoHpp float64 `json:"total_fifo_hpp"`
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
}
// ParentCategoryAnalyticsDetailRequest represents the request for the drill-down of one parent category
type ParentCategoryAnalyticsDetailRequest struct {
OrganizationID uuid.UUID
ParentCategoryID string
OutletID *string `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"`
}
// ParentCategoryAnalyticsDetailResponse represents the drill-down of one parent category
type ParentCategoryAnalyticsDetailResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
ParentCategoryID uuid.UUID `json:"parent_category_id"`
ParentCategoryName string `json:"parent_category_name"`
Summary ParentCategoryAnalyticsDetailSummary `json:"summary"`
Categories []ParentCategoryAnalyticsDetailData `json:"categories"`
Budget BudgetCutOff `json:"budget"`
}
type ParentCategoryAnalyticsDetailSummary struct {
TotalRevenue float64 `json:"total_revenue"`
TotalQuantity int64 `json:"total_quantity"`
CategoryCount int64 `json:"category_count"`
ProductCount int64 `json:"product_count"`
OrderCount int64 `json:"order_count"`
TotalStandardHpp float64 `json:"total_standard_hpp"`
TotalFifoHpp float64 `json:"total_fifo_hpp"`
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
}
type ParentCategoryAnalyticsDetailData struct {
CategoryID uuid.UUID `json:"category_id"`
CategoryName string `json:"category_name"`
TotalRevenue float64 `json:"total_revenue"`
TotalQuantity int64 `json:"total_quantity"`
ProductCount int64 `json:"product_count"`
OrderCount int64 `json:"order_count"`
TotalStandardHpp float64 `json:"total_standard_hpp"`
TotalFifoHpp float64 `json:"total_fifo_hpp"`
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
Products []ParentCategoryAnalyticsProductData `json:"products"`
}
type ParentCategoryAnalyticsProductData struct {
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
ProductSku string `json:"product_sku"`
ProductPrice float64 `json:"product_price"`
QuantitySold int64 `json:"quantity_sold"`
Revenue float64 `json:"revenue"`
AveragePrice float64 `json:"average_price"`
OrderCount int64 `json:"order_count"`
StandardHppPerUnit float64 `json:"standard_hpp_per_unit"`
StandardHppTotal float64 `json:"standard_hpp_total"`
FifoHppPerUnit float64 `json:"fifo_hpp_per_unit"`
FifoHppTotal float64 `json:"fifo_hpp_total"`
MovingAverageHppPerUnit float64 `json:"moving_average_hpp_per_unit"`
MovingAverageHppTotal float64 `json:"moving_average_hpp_total"`
}
// BudgetCutOff is the Monday-to-Sunday spending limit breakdown attached to the
// parent category reports.
type BudgetCutOff struct {
Percentages BudgetPercentages `json:"percentages"`
CutOffFrom time.Time `json:"cut_off_from"`
CutOffTo time.Time `json:"cut_off_to"`
Total BudgetPeriod `json:"total"`
Weekly []BudgetPeriod `json:"weekly"`
Monthly []BudgetMonthPeriod `json:"monthly"`
}
type BudgetPercentages struct {
Purchase float64 `json:"purchase"`
Owner float64 `json:"owner"`
Team float64 `json:"team"`
}
type BudgetPeriod struct {
PeriodStart time.Time `json:"period_start"`
PeriodEnd time.Time `json:"period_end"`
Revenue float64 `json:"revenue"`
OrderCount int64 `json:"order_count"`
LimitPurchase float64 `json:"limit_purchase"`
LimitOwner float64 `json:"limit_owner"`
LimitTeam float64 `json:"limit_team"`
}
type BudgetMonthPeriod struct {
Month string `json:"month"`
WeekCount int `json:"week_count"`
BudgetPeriod
}
// DashboardAnalyticsRequest represents the request for dashboard analytics
type DashboardAnalyticsRequest struct {
OrganizationID uuid.UUID
@@ -227,6 +360,7 @@ type DashboardAnalyticsRequest struct {
type DashboardAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
Overview DashboardOverview `json:"overview"`
@@ -237,12 +371,15 @@ type DashboardAnalyticsResponse struct {
// DashboardOverview represents the overview data for dashboard
type DashboardOverview struct {
TotalSales float64 `json:"total_sales"`
TotalOrders int64 `json:"total_orders"`
AverageOrderValue float64 `json:"average_order_value"`
TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_orders"`
TotalSales float64 `json:"total_sales"`
TotalOrders int64 `json:"total_orders"`
AverageOrderValue float64 `json:"average_order_value"`
TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_orders"`
TotalItemSold int64 `json:"total_item_sold"`
TotalLowStock int64 `json:"total_low_stock"`
TotalProductActive int64 `json:"total_product_active"`
}
type ProfitLossAnalyticsRequest struct {
@@ -256,6 +393,7 @@ type ProfitLossAnalyticsRequest struct {
type ProfitLossAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
@@ -263,10 +401,28 @@ type ProfitLossAnalyticsResponse struct {
Data []ProfitLossData `json:"data"`
ProductData []ProductProfitData `json:"product_data"`
MainSummary []ProfitLossSummaryRow `json:"main_summary"`
Purchasing ProfitLossPurchasing `json:"purchasing"`
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
}
type ProfitLossPurchasing struct {
TodayTotal float64 `json:"today_total"`
MtdTotal float64 `json:"mtd_total"`
TodayRawMaterial float64 `json:"today_raw_material"`
MtdRawMaterial float64 `json:"mtd_raw_material"`
TodayExpense float64 `json:"today_expense"`
MtdExpense float64 `json:"mtd_expense"`
Items []ProfitLossPurchasingItem `json:"items"`
}
type ProfitLossPurchasingItem struct {
Date time.Time `json:"date"`
Item string `json:"item"`
Quantity float64 `json:"quantity"`
Nominal float64 `json:"nominal"`
}
type ProfitLossSummary struct {
TotalRevenue float64 `json:"total_revenue"`
TotalCost float64 `json:"total_cost"`
@@ -349,6 +505,7 @@ type ExclusiveSummaryMTDRequest struct {
type ExclusiveSummaryPeriodResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
Period ExclusiveSummaryPeriodRange `json:"period"`
Summary ExclusiveSummaryPeriodSummary `json:"summary"`
Reimburse ExclusiveSummaryReimburse `json:"reimburse"`
@@ -408,6 +565,7 @@ type ExclusiveSummaryDailyTransaction struct {
type ExclusiveSummaryMonthlyResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
Month string `json:"month"`
Summary ExclusiveSummaryMonthlySummary `json:"summary"`
Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"`
+6
View File
@@ -11,6 +11,7 @@ type CreateCategoryRequest struct {
Description *string `json:"description,omitempty"`
BusinessType *string `json:"business_type,omitempty"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Order *int `json:"order,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
@@ -20,6 +21,7 @@ type UpdateCategoryRequest struct {
Description *string `json:"description,omitempty"`
BusinessType *string `json:"business_type,omitempty"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Order *int `json:"order,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
@@ -27,6 +29,8 @@ type UpdateCategoryRequest struct {
type ListCategoriesRequest struct {
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Type string `json:"type,omitempty" validate:"omitempty,oneof=parent child"`
BusinessType string `json:"business_type,omitempty"`
Search string `json:"search,omitempty"`
Page int `json:"page" validate:"required,min=1"`
@@ -38,6 +42,8 @@ type CategoryResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
ParentName *string `json:"parent_name,omitempty"`
Name string `json:"name"`
Description *string `json:"description"`
BusinessType string `json:"business_type"`
+60 -10
View File
@@ -112,6 +112,39 @@ type ProductAnalyticsPerCategory struct {
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
}
// ProductAnalyticsPerParentCategory rolls the per-category figures up to the
// top-level category. A category without a parent is its own group.
type ProductAnalyticsPerParentCategory struct {
ParentCategoryID uuid.UUID `json:"parent_category_id"`
ParentCategoryName string `json:"parent_category_name"`
TotalRevenue float64 `json:"total_revenue"`
TotalQuantity int64 `json:"total_quantity"`
CategoryCount int64 `json:"category_count"`
ProductCount int64 `json:"product_count"`
OrderCount int64 `json:"order_count"`
TotalStandardHpp float64 `json:"total_standard_hpp"`
TotalFifoHpp float64 `json:"total_fifo_hpp"`
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
}
// ParentCategoryAnalyticsDetail is the drill-down for a single parent category:
// its own totals, the sub-categories underneath it, and the products in each.
type ParentCategoryAnalyticsDetail struct {
ParentCategoryID uuid.UUID
ParentCategoryName string
Summary *ProductAnalyticsPerParentCategory
Categories []*ProductAnalyticsPerCategory
Products []*ProductAnalytics
}
// BudgetCutOffWeek is one Monday-to-Sunday bucket of revenue, used to derive the
// weekly spending limits.
type BudgetCutOffWeek struct {
WeekStart time.Time `json:"week_start"`
Revenue float64 `json:"revenue"`
OrderCount int64 `json:"order_count"`
}
// DashboardOverview represents dashboard overview data
type DashboardOverview struct {
TotalSales float64 `json:"total_sales"`
@@ -120,19 +153,36 @@ type DashboardOverview struct {
TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_orders"`
TotalItemSold int64 `json:"total_item_sold"`
TotalLowStock int64 `json:"total_low_stock"`
TotalProductActive int64 `json:"total_product_active"`
}
type ProfitLossAnalytics struct {
Summary ProfitLossSummary
Data []ProfitLossData
ProductData []ProductProfitData
TodayRevenue float64
TodayCost float64
MtdRevenue float64
MtdCost float64
TodayExpenseByCategory []ExpenseCategoryTotal
MtdExpenseByCategory []ExpenseCategoryTotal
OperationalExpenseItems []OperationalExpenseItem
Summary ProfitLossSummary
Data []ProfitLossData
ProductData []ProductProfitData
TodayRevenue float64
TodayCost float64
MtdRevenue float64
MtdCost float64
TodayPurchasing float64
MtdPurchasing float64
TodayPurchasingRawMaterial float64
MtdPurchasingRawMaterial float64
TodayPurchasingExpense float64
MtdPurchasingExpense float64
PurchasingItems []PurchasingItemDetail
TodayExpenseByCategory []ExpenseCategoryTotal
MtdExpenseByCategory []ExpenseCategoryTotal
OperationalExpenseItems []OperationalExpenseItem
}
type PurchasingItemDetail struct {
Date time.Time
Item string
Quantity float64
Amount float64
}
type ProfitLossSummary struct {
+2
View File
@@ -34,6 +34,8 @@ type Category struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"`
ParentID *uuid.UUID `gorm:"type:uuid;index" json:"parent_id"`
Parent *Category `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
Description *string `gorm:"type:text" json:"description"`
Order int `gorm:"default:0" json:"order"`
+49
View File
@@ -157,6 +157,55 @@ func (h *AnalyticsHandler) GetProductAnalyticsPerCategory(c *gin.Context) {
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetProductAnalyticsPerCategory")
}
func (h *AnalyticsHandler) GetProductAnalyticsPerParentCategory(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.ProductAnalyticsPerParentCategoryRequest
if err := c.ShouldBindQuery(&req); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetProductAnalyticsPerParentCategory", err.Error())}), "AnalyticsHandler::GetProductAnalyticsPerParentCategory")
return
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
modelReq := transformer.ProductAnalyticsPerParentCategoryContractToModel(&req)
response, err := h.analyticsService.GetProductAnalyticsPerParentCategory(ctx, modelReq)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetProductAnalyticsPerParentCategory", err.Error())}), "AnalyticsHandler::GetProductAnalyticsPerParentCategory")
return
}
contractResp := transformer.ProductAnalyticsPerParentCategoryModelToContract(response)
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetProductAnalyticsPerParentCategory")
}
func (h *AnalyticsHandler) GetParentCategoryAnalyticsDetail(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.ParentCategoryAnalyticsDetailRequest
if err := c.ShouldBindQuery(&req); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetParentCategoryAnalyticsDetail", err.Error())}), "AnalyticsHandler::GetParentCategoryAnalyticsDetail")
return
}
req.OrganizationID = contextInfo.OrganizationID
req.ParentCategoryID = c.Param("parent_category_id")
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
modelReq := transformer.ParentCategoryAnalyticsDetailContractToModel(&req)
response, err := h.analyticsService.GetParentCategoryAnalyticsDetail(ctx, modelReq)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetParentCategoryAnalyticsDetail", err.Error())}), "AnalyticsHandler::GetParentCategoryAnalyticsDetail")
return
}
contractResp := transformer.ParentCategoryAnalyticsDetailModelToContract(response)
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetParentCategoryAnalyticsDetail")
}
func (h *AnalyticsHandler) GetDashboardAnalytics(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
+12
View File
@@ -191,6 +191,18 @@ func (h *CategoryHandler) ListCategories(c *gin.Context) {
req.OutletID = &outletID
}
}
if parentIDStr := c.Query("parent_id"); parentIDStr != "" {
if parentID, err := uuid.Parse(parentIDStr); err == nil {
req.ParentID = &parentID
}
}
// type=parent -> top level categories only
// type=child -> leaf categories (sub categories + top level ones without children)
if categoryType := c.Query("type"); categoryType != "" {
req.Type = categoryType
}
validationError, validationErrorCode := h.categoryValidator.ValidateListCategoriesRequest(req)
if validationError != nil {
logger.FromContext(ctx).WithError(validationError).Error("CategoryHandler::ListCategories -> request validation failed")
+14
View File
@@ -61,6 +61,7 @@ func CreateCategoryRequestToEntity(req *models.CreateCategoryRequest) *entities.
return &entities.Category{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
ParentID: req.ParentID,
Name: req.Name,
Description: req.Description,
Order: req.Order,
@@ -85,10 +86,19 @@ func CategoryEntityToResponse(entity *entities.Category) *models.CategoryRespons
}
}
// Parent name is only available when the Parent association is preloaded
var parentName *string
if entity.Parent != nil {
name := entity.Parent.Name
parentName = &name
}
return &models.CategoryResponse{
ID: entity.ID,
OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
ParentID: entity.ParentID,
ParentName: parentName,
Name: entity.Name,
Description: entity.Description,
ImageURL: imageURL,
@@ -127,6 +137,10 @@ func UpdateCategoryEntityFromRequest(entity *entities.Category, req *models.Upda
if req.OutletID != nil {
entity.OutletID = req.OutletID
}
if req.ParentID != nil {
entity.ParentID = req.ParentID
}
}
func CategoryEntitiesToModels(entities []*entities.Category) []*models.Category {
+164 -6
View File
@@ -19,6 +19,7 @@ type PaymentMethodAnalyticsRequest struct {
type PaymentMethodAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
@@ -58,6 +59,7 @@ type SalesAnalyticsRequest struct {
type SalesAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
@@ -171,6 +173,7 @@ type ProductAnalyticsRequest struct {
type ProductAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsData `json:"data"`
@@ -208,6 +211,7 @@ type ProductAnalyticsPerCategoryRequest struct {
type ProductAnalyticsPerCategoryResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsPerCategoryData `json:"data"`
@@ -225,6 +229,135 @@ type ProductAnalyticsPerCategoryData struct {
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
}
// ProductAnalyticsPerParentCategoryRequest represents the request for product analytics per parent category
type ProductAnalyticsPerParentCategoryRequest struct {
OrganizationID uuid.UUID `validate:"required"`
OutletID *uuid.UUID `validate:"omitempty"`
DateFrom time.Time `validate:"required"`
DateTo time.Time `validate:"required"`
}
// ProductAnalyticsPerParentCategoryResponse represents the response for product analytics per parent category
type ProductAnalyticsPerParentCategoryResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsPerParentCategoryData `json:"data"`
Budget BudgetCutOff `json:"budget"`
}
type ProductAnalyticsPerParentCategoryData struct {
ParentCategoryID uuid.UUID `json:"parent_category_id"`
ParentCategoryName string `json:"parent_category_name"`
TotalRevenue float64 `json:"total_revenue"`
TotalQuantity int64 `json:"total_quantity"`
CategoryCount int64 `json:"category_count"`
ProductCount int64 `json:"product_count"`
OrderCount int64 `json:"order_count"`
TotalStandardHpp float64 `json:"total_standard_hpp"`
TotalFifoHpp float64 `json:"total_fifo_hpp"`
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
}
// ParentCategoryAnalyticsDetailRequest represents the request for the drill-down of one parent category
type ParentCategoryAnalyticsDetailRequest struct {
OrganizationID uuid.UUID `validate:"required"`
ParentCategoryID uuid.UUID `validate:"required"`
OutletID *uuid.UUID `validate:"omitempty"`
DateFrom time.Time `validate:"required"`
DateTo time.Time `validate:"required"`
}
// ParentCategoryAnalyticsDetailResponse represents the drill-down of one parent category
type ParentCategoryAnalyticsDetailResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
ParentCategoryID uuid.UUID `json:"parent_category_id"`
ParentCategoryName string `json:"parent_category_name"`
Summary ParentCategoryAnalyticsDetailSummary `json:"summary"`
Categories []ParentCategoryAnalyticsDetailData `json:"categories"`
Budget BudgetCutOff `json:"budget"`
}
type ParentCategoryAnalyticsDetailSummary struct {
TotalRevenue float64 `json:"total_revenue"`
TotalQuantity int64 `json:"total_quantity"`
CategoryCount int64 `json:"category_count"`
ProductCount int64 `json:"product_count"`
OrderCount int64 `json:"order_count"`
TotalStandardHpp float64 `json:"total_standard_hpp"`
TotalFifoHpp float64 `json:"total_fifo_hpp"`
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
}
type ParentCategoryAnalyticsDetailData struct {
CategoryID uuid.UUID `json:"category_id"`
CategoryName string `json:"category_name"`
TotalRevenue float64 `json:"total_revenue"`
TotalQuantity int64 `json:"total_quantity"`
ProductCount int64 `json:"product_count"`
OrderCount int64 `json:"order_count"`
TotalStandardHpp float64 `json:"total_standard_hpp"`
TotalFifoHpp float64 `json:"total_fifo_hpp"`
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
Products []ParentCategoryAnalyticsProductData `json:"products"`
}
type ParentCategoryAnalyticsProductData struct {
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
ProductSku string `json:"product_sku"`
ProductPrice float64 `json:"product_price"`
QuantitySold int64 `json:"quantity_sold"`
Revenue float64 `json:"revenue"`
AveragePrice float64 `json:"average_price"`
OrderCount int64 `json:"order_count"`
StandardHppPerUnit float64 `json:"standard_hpp_per_unit"`
StandardHppTotal float64 `json:"standard_hpp_total"`
FifoHppPerUnit float64 `json:"fifo_hpp_per_unit"`
FifoHppTotal float64 `json:"fifo_hpp_total"`
MovingAverageHppPerUnit float64 `json:"moving_average_hpp_per_unit"`
MovingAverageHppTotal float64 `json:"moving_average_hpp_total"`
}
// BudgetCutOff is the Monday-to-Sunday spending limit breakdown attached to the
// parent category reports.
type BudgetCutOff struct {
Percentages BudgetPercentages `json:"percentages"`
CutOffFrom time.Time `json:"cut_off_from"`
CutOffTo time.Time `json:"cut_off_to"`
Total BudgetPeriod `json:"total"`
Weekly []BudgetPeriod `json:"weekly"`
Monthly []BudgetMonthPeriod `json:"monthly"`
}
type BudgetPercentages struct {
Purchase float64 `json:"purchase"`
Owner float64 `json:"owner"`
Team float64 `json:"team"`
}
type BudgetPeriod struct {
PeriodStart time.Time `json:"period_start"`
PeriodEnd time.Time `json:"period_end"`
Revenue float64 `json:"revenue"`
OrderCount int64 `json:"order_count"`
LimitPurchase float64 `json:"limit_purchase"`
LimitOwner float64 `json:"limit_owner"`
LimitTeam float64 `json:"limit_team"`
}
type BudgetMonthPeriod struct {
Month string `json:"month"`
WeekCount int `json:"week_count"`
BudgetPeriod
}
// DashboardAnalyticsRequest represents the request for dashboard analytics
type DashboardAnalyticsRequest struct {
OrganizationID uuid.UUID `validate:"required"`
@@ -237,6 +370,7 @@ type DashboardAnalyticsRequest struct {
type DashboardAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
Overview DashboardOverview `json:"overview"`
@@ -247,12 +381,15 @@ type DashboardAnalyticsResponse struct {
// DashboardOverview represents the overview data for dashboard
type DashboardOverview struct {
TotalSales float64 `json:"total_sales"`
TotalOrders int64 `json:"total_orders"`
AverageOrderValue float64 `json:"average_order_value"`
TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_orders"`
TotalSales float64 `json:"total_sales"`
TotalOrders int64 `json:"total_orders"`
AverageOrderValue float64 `json:"average_order_value"`
TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_orders"`
TotalItemSold int64 `json:"total_item_sold"`
TotalLowStock int64 `json:"total_low_stock"`
TotalProductActive int64 `json:"total_product_active"`
}
type ProfitLossAnalyticsRequest struct {
@@ -266,6 +403,7 @@ type ProfitLossAnalyticsRequest struct {
type ProfitLossAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
@@ -273,10 +411,28 @@ type ProfitLossAnalyticsResponse struct {
Data []ProfitLossData `json:"data"`
ProductData []ProductProfitData `json:"product_data"`
MainSummary []ProfitLossSummaryRow `json:"main_summary"`
Purchasing ProfitLossPurchasing `json:"purchasing"`
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
}
type ProfitLossPurchasing struct {
TodayTotal float64 `json:"today_total"`
MtdTotal float64 `json:"mtd_total"`
TodayRawMaterial float64 `json:"today_raw_material"`
MtdRawMaterial float64 `json:"mtd_raw_material"`
TodayExpense float64 `json:"today_expense"`
MtdExpense float64 `json:"mtd_expense"`
Items []ProfitLossPurchasingItem `json:"items"`
}
type ProfitLossPurchasingItem struct {
Date time.Time `json:"date"`
Item string `json:"item"`
Quantity float64 `json:"quantity"`
Nominal float64 `json:"nominal"`
}
type ProfitLossSummary struct {
TotalRevenue float64 `json:"total_revenue"`
TotalCost float64 `json:"total_cost"`
@@ -359,6 +515,7 @@ type ExclusiveSummaryMTDRequest struct {
type ExclusiveSummaryPeriodResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
Period ExclusiveSummaryPeriodRange `json:"period"`
Summary ExclusiveSummaryPeriodSummary `json:"summary"`
Reimburse ExclusiveSummaryReimburse `json:"reimburse"`
@@ -418,6 +575,7 @@ type ExclusiveSummaryDailyTransaction struct {
type ExclusiveSummaryMonthlyResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
Month string `json:"month"`
Summary ExclusiveSummaryMonthlySummary `json:"summary"`
Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"`
+4
View File
@@ -22,6 +22,7 @@ type Category struct {
type CreateCategoryRequest struct {
OrganizationID uuid.UUID `validate:"required"`
OutletID *uuid.UUID
ParentID *uuid.UUID
Name string `validate:"required,min=1,max=255"`
Description *string `validate:"omitempty,max=1000"`
ImageURL *string `validate:"omitempty,url"`
@@ -33,6 +34,7 @@ type UpdateCategoryRequest struct {
Description *string `validate:"omitempty,max=1000"`
ImageURL *string `validate:"omitempty,url"`
OutletID *uuid.UUID
ParentID *uuid.UUID
Order *int `validate:"omitempty,min=0"`
IsActive *bool
}
@@ -41,6 +43,8 @@ type CategoryResponse struct {
ID uuid.UUID
OrganizationID uuid.UUID
OutletID *uuid.UUID
ParentID *uuid.UUID
ParentName *string
Name string
Description *string
ImageURL *string
+292 -9
View File
@@ -6,9 +6,12 @@ import (
"strings"
"time"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
"github.com/google/uuid"
)
type AnalyticsProcessor interface {
@@ -17,6 +20,8 @@ type AnalyticsProcessor interface {
GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error)
GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error)
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error)
GetProductAnalyticsPerParentCategory(ctx context.Context, req *models.ProductAnalyticsPerParentCategoryRequest) (*models.ProductAnalyticsPerParentCategoryResponse, error)
GetParentCategoryAnalyticsDetail(ctx context.Context, req *models.ParentCategoryAnalyticsDetailRequest) (*models.ParentCategoryAnalyticsDetailResponse, error)
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error)
GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error)
@@ -36,6 +41,18 @@ func NewAnalyticsProcessorImpl(analyticsRepo repository.AnalyticsRepository, exp
}
}
// resolveOutletName fetches the outlet name from the database if outletID is provided
func (p *AnalyticsProcessorImpl) resolveOutletName(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) *string {
if outletID == nil {
return nil
}
name, err := p.analyticsRepo.GetOutletName(ctx, organizationID, *outletID)
if err != nil || name == "" {
return nil
}
return &name
}
func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) {
if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to")
@@ -90,6 +107,7 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context,
return &models.PaymentMethodAnalyticsResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom,
DateTo: req.DateTo,
GroupBy: req.GroupBy,
@@ -164,6 +182,7 @@ func (p *AnalyticsProcessorImpl) GetSalesAnalytics(ctx context.Context, req *mod
return &models.SalesAnalyticsResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom,
DateTo: req.DateTo,
GroupBy: req.GroupBy,
@@ -295,6 +314,7 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m
return &models.ProductAnalyticsResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom,
DateTo: req.DateTo,
Data: resultData,
@@ -332,12 +352,249 @@ func (p *AnalyticsProcessorImpl) GetProductAnalyticsPerCategory(ctx context.Cont
return &models.ProductAnalyticsPerCategoryResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom,
DateTo: req.DateTo,
Data: resultData,
}, nil
}
func (p *AnalyticsProcessorImpl) GetProductAnalyticsPerParentCategory(ctx context.Context, req *models.ProductAnalyticsPerParentCategoryRequest) (*models.ProductAnalyticsPerParentCategoryResponse, error) {
// Validate date range
if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to")
}
// Get analytics data from repository
analyticsData, err := p.analyticsRepo.GetProductAnalyticsPerParentCategory(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo)
if err != nil {
return nil, fmt.Errorf("failed to get product analytics per parent category: %w", err)
}
// Transform data
var resultData []models.ProductAnalyticsPerParentCategoryData
for _, data := range analyticsData {
resultData = append(resultData, models.ProductAnalyticsPerParentCategoryData{
ParentCategoryID: data.ParentCategoryID,
ParentCategoryName: data.ParentCategoryName,
TotalRevenue: data.TotalRevenue,
TotalQuantity: data.TotalQuantity,
CategoryCount: data.CategoryCount,
ProductCount: data.ProductCount,
OrderCount: data.OrderCount,
TotalStandardHpp: data.TotalStandardHpp,
TotalFifoHpp: data.TotalFifoHpp,
TotalMovingAverageHpp: data.TotalMovingAverageHpp,
})
}
budget, err := p.buildBudgetCutOff(ctx, req.OrganizationID, req.OutletID, nil, req.DateFrom, req.DateTo)
if err != nil {
return nil, err
}
return &models.ProductAnalyticsPerParentCategoryResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom,
DateTo: req.DateTo,
Data: resultData,
Budget: budget,
}, nil
}
func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Context, req *models.ParentCategoryAnalyticsDetailRequest) (*models.ParentCategoryAnalyticsDetailResponse, error) {
// Validate date range
if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to")
}
detail, err := p.analyticsRepo.GetParentCategoryAnalyticsDetail(ctx, req.OrganizationID, req.OutletID, req.ParentCategoryID, req.DateFrom, req.DateTo)
if err != nil {
return nil, fmt.Errorf("failed to get parent category analytics detail: %w", err)
}
// Bucket the product rows by the category they belong to
productsByCategory := make(map[uuid.UUID][]models.ParentCategoryAnalyticsProductData)
for _, product := range detail.Products {
productsByCategory[product.CategoryID] = append(productsByCategory[product.CategoryID], models.ParentCategoryAnalyticsProductData{
ProductID: product.ProductID,
ProductName: product.ProductName,
ProductSku: product.ProductSku,
ProductPrice: product.ProductPrice,
QuantitySold: product.QuantitySold,
Revenue: product.Revenue,
AveragePrice: product.AveragePrice,
OrderCount: product.OrderCount,
StandardHppPerUnit: product.StandardHppPerUnit,
StandardHppTotal: product.StandardHppTotal,
FifoHppPerUnit: product.FifoHppPerUnit,
FifoHppTotal: product.FifoHppTotal,
MovingAverageHppPerUnit: product.MovingAverageHppPerUnit,
MovingAverageHppTotal: product.MovingAverageHppTotal,
})
}
categories := make([]models.ParentCategoryAnalyticsDetailData, 0, len(detail.Categories))
for _, category := range detail.Categories {
products := productsByCategory[category.CategoryID]
if products == nil {
products = []models.ParentCategoryAnalyticsProductData{}
}
categories = append(categories, models.ParentCategoryAnalyticsDetailData{
CategoryID: category.CategoryID,
CategoryName: category.CategoryName,
TotalRevenue: category.TotalRevenue,
TotalQuantity: category.TotalQuantity,
ProductCount: category.ProductCount,
OrderCount: category.OrderCount,
TotalStandardHpp: category.TotalStandardHpp,
TotalFifoHpp: category.TotalFifoHpp,
TotalMovingAverageHpp: category.TotalMovingAverageHpp,
Products: products,
})
}
summary := models.ParentCategoryAnalyticsDetailSummary{}
if detail.Summary != nil {
summary = models.ParentCategoryAnalyticsDetailSummary{
TotalRevenue: detail.Summary.TotalRevenue,
TotalQuantity: detail.Summary.TotalQuantity,
CategoryCount: detail.Summary.CategoryCount,
ProductCount: detail.Summary.ProductCount,
OrderCount: detail.Summary.OrderCount,
TotalStandardHpp: detail.Summary.TotalStandardHpp,
TotalFifoHpp: detail.Summary.TotalFifoHpp,
TotalMovingAverageHpp: detail.Summary.TotalMovingAverageHpp,
}
}
budget, err := p.buildBudgetCutOff(ctx, req.OrganizationID, req.OutletID, &req.ParentCategoryID, req.DateFrom, req.DateTo)
if err != nil {
return nil, err
}
return &models.ParentCategoryAnalyticsDetailResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom,
DateTo: req.DateTo,
ParentCategoryID: detail.ParentCategoryID,
ParentCategoryName: detail.ParentCategoryName,
Summary: summary,
Categories: categories,
Budget: budget,
}, nil
}
// startOfWeek returns the Monday 00:00 of the week containing t, in t's own location.
func startOfWeek(t time.Time) time.Time {
daysSinceMonday := (int(t.Weekday()) + 6) % 7
year, month, day := t.Date()
return time.Date(year, month, day-daysSinceMonday, 0, 0, 0, 0, t.Location())
}
// endOfWeek returns the Sunday 23:59:59.999999999 of the week containing t.
func endOfWeek(t time.Time) time.Time {
return startOfWeek(t).AddDate(0, 0, 7).Add(-time.Nanosecond)
}
// newBudgetPeriod splits a period's revenue into the spending limits.
func newBudgetPeriod(start, end time.Time, revenue float64, orderCount int64) models.BudgetPeriod {
return models.BudgetPeriod{
PeriodStart: start,
PeriodEnd: end,
Revenue: revenue,
OrderCount: orderCount,
LimitPurchase: revenue * constants.BudgetLimitPurchasePercent / 100,
LimitOwner: revenue * constants.BudgetLimitOwnerPercent / 100,
LimitTeam: revenue * constants.BudgetLimitTeamPercent / 100,
}
}
// buildBudgetCutOff produces the weekly cut-off breakdown for the given scope. Weeks
// are always whole Monday-to-Sunday blocks, so the covered range is widened to the
// week boundaries around the requested dates. A nil parentCategoryID covers every
// category.
func (p *AnalyticsProcessorImpl) buildBudgetCutOff(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, parentCategoryID *uuid.UUID, dateFrom, dateTo time.Time) (models.BudgetCutOff, error) {
cutOffFrom := startOfWeek(dateFrom)
cutOffTo := endOfWeek(dateTo)
budget := models.BudgetCutOff{
Percentages: models.BudgetPercentages{
Purchase: constants.BudgetLimitPurchasePercent,
Owner: constants.BudgetLimitOwnerPercent,
Team: constants.BudgetLimitTeamPercent,
},
CutOffFrom: cutOffFrom,
CutOffTo: cutOffTo,
Weekly: []models.BudgetPeriod{},
Monthly: []models.BudgetMonthPeriod{},
}
rows, err := p.analyticsRepo.GetBudgetCutOffWeekly(ctx, organizationID, outletID, parentCategoryID, cutOffFrom, cutOffTo)
if err != nil {
return budget, fmt.Errorf("failed to get budget cut off: %w", err)
}
// Key the rows by their Monday so weeks without any sales can still be emitted
rowsByWeek := make(map[string]*entities.BudgetCutOffWeek, len(rows))
for _, row := range rows {
rowsByWeek[row.WeekStart.In(cutOffFrom.Location()).Format("2006-01-02")] = row
}
var (
totalRevenue float64
totalOrders int64
monthOrder []string
monthAccumulator = map[string]*models.BudgetMonthPeriod{}
)
for week := cutOffFrom; !week.After(cutOffTo); week = week.AddDate(0, 0, 7) {
var revenue float64
var orderCount int64
if row, ok := rowsByWeek[week.Format("2006-01-02")]; ok {
revenue, orderCount = row.Revenue, row.OrderCount
}
period := newBudgetPeriod(week, endOfWeek(week), revenue, orderCount)
budget.Weekly = append(budget.Weekly, period)
totalRevenue += revenue
totalOrders += orderCount
// A week belongs to the month of its Monday, so every week is counted once
monthKey := week.Format("2006-01")
month, ok := monthAccumulator[monthKey]
if !ok {
month = &models.BudgetMonthPeriod{Month: monthKey}
month.PeriodStart = period.PeriodStart
monthAccumulator[monthKey] = month
monthOrder = append(monthOrder, monthKey)
}
month.WeekCount++
month.PeriodEnd = period.PeriodEnd
month.Revenue += revenue
month.OrderCount += orderCount
}
for _, monthKey := range monthOrder {
month := monthAccumulator[monthKey]
budget.Monthly = append(budget.Monthly, models.BudgetMonthPeriod{
Month: month.Month,
WeekCount: month.WeekCount,
BudgetPeriod: newBudgetPeriod(month.PeriodStart, month.PeriodEnd, month.Revenue, month.OrderCount),
})
}
budget.Total = newBudgetPeriod(cutOffFrom, cutOffTo, totalRevenue, totalOrders)
return budget, nil
}
func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) {
// Validate date range
if req.DateFrom.After(req.DateTo) {
@@ -393,15 +650,19 @@ func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req
return &models.DashboardAnalyticsResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom,
DateTo: req.DateTo,
Overview: models.DashboardOverview{
TotalSales: overview.TotalSales,
TotalOrders: overview.TotalOrders,
AverageOrderValue: overview.AverageOrderValue,
TotalCustomers: overview.TotalCustomers,
VoidedOrders: overview.VoidedOrders,
RefundedOrders: overview.RefundedOrders,
TotalSales: overview.TotalSales,
TotalOrders: overview.TotalOrders,
AverageOrderValue: overview.AverageOrderValue,
TotalCustomers: overview.TotalCustomers,
VoidedOrders: overview.VoidedOrders,
RefundedOrders: overview.RefundedOrders,
TotalItemSold: overview.TotalItemSold,
TotalLowStock: overview.TotalLowStock,
TotalProductActive: overview.TotalProductActive,
},
TopProducts: topProducts.Data,
PaymentMethods: paymentMethods.Data,
@@ -604,9 +865,20 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
opsTotal += item.Amount
}
purchasingItems := make([]models.ProfitLossPurchasingItem, len(result.PurchasingItems))
for i, item := range result.PurchasingItems {
purchasingItems[i] = models.ProfitLossPurchasingItem{
Date: item.Date,
Item: item.Item,
Quantity: item.Quantity,
Nominal: item.Amount,
}
}
return &models.ProfitLossAnalyticsResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom,
DateTo: req.DateTo,
GroupBy: req.GroupBy,
@@ -623,9 +895,18 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
AverageProfit: result.Summary.AverageProfit,
ProfitabilityRatio: result.Summary.ProfitabilityRatio,
},
Data: data,
ProductData: productData,
MainSummary: mainSummary,
Data: data,
ProductData: productData,
MainSummary: mainSummary,
Purchasing: models.ProfitLossPurchasing{
TodayTotal: result.TodayPurchasing,
MtdTotal: result.MtdPurchasing,
TodayRawMaterial: result.TodayPurchasingRawMaterial,
MtdRawMaterial: result.MtdPurchasingRawMaterial,
TodayExpense: result.TodayPurchasingExpense,
MtdExpense: result.MtdPurchasingExpense,
Items: purchasingItems,
},
OperationalExpenses: opsItems,
OperationalExpensesTotal: opsTotal,
}, nil
@@ -721,6 +1002,7 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
return &models.ExclusiveSummaryMonthlyResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
Month: monthStart.Format("2006-01"),
Summary: models.ExclusiveSummaryMonthlySummary{
TotalSales: fullPeriod.Summary.Sales,
@@ -795,6 +1077,7 @@ func (p *AnalyticsProcessorImpl) buildExclusiveSummaryPeriod(ctx context.Context
return &models.ExclusiveSummaryPeriodResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
Period: models.ExclusiveSummaryPeriodRange{
DateFrom: req.DateFrom,
DateTo: req.DateTo,
@@ -14,6 +14,7 @@ import (
type analyticsRepositoryStub struct {
purchasingResult *entities.PurchasingAnalytics
budgetCutOffWeeks []*entities.BudgetCutOffWeek
profitLossResult *entities.ProfitLossAnalytics
exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics
bankBalances []entities.ExclusiveSummaryBankBalance
@@ -43,6 +44,18 @@ func (analyticsRepositoryStub) GetProductAnalyticsPerCategory(context.Context, u
return nil, nil
}
func (analyticsRepositoryStub) GetProductAnalyticsPerParentCategory(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.ProductAnalyticsPerParentCategory, error) {
return nil, nil
}
func (analyticsRepositoryStub) GetParentCategoryAnalyticsDetail(context.Context, uuid.UUID, *uuid.UUID, uuid.UUID, time.Time, time.Time) (*entities.ParentCategoryAnalyticsDetail, error) {
return nil, nil
}
func (s analyticsRepositoryStub) GetBudgetCutOffWeekly(context.Context, uuid.UUID, *uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.BudgetCutOffWeek, error) {
return s.budgetCutOffWeeks, nil
}
func (analyticsRepositoryStub) GetDashboardOverview(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) (*entities.DashboardOverview, error) {
return nil, nil
}
@@ -68,6 +81,10 @@ func (s *analyticsRepositoryStub) GetExclusiveSummaryBankBalances(context.Contex
return s.bankBalances, nil
}
func (analyticsRepositoryStub) GetOutletName(context.Context, uuid.UUID, uuid.UUID) (string, error) {
return "", nil
}
type expenseRepositoryStub struct{}
func (expenseRepositoryStub) Create(context.Context, *entities.Expense) error { return nil }
+152
View File
@@ -0,0 +1,152 @@
package processor
import (
"context"
"testing"
"time"
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
func jakarta(t *testing.T) *time.Location {
t.Helper()
loc, err := time.LoadLocation("Asia/Jakarta")
require.NoError(t, err)
return loc
}
func TestStartOfWeekLandsOnMonday(t *testing.T) {
loc := jakarta(t)
// 3 Aug 2026 is a Monday, so the whole week must collapse onto it
monday := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
for offset := 0; offset < 7; offset++ {
day := monday.AddDate(0, 0, offset).Add(13 * time.Hour)
got := startOfWeek(day)
require.Equal(t, monday, got, "day %s should map to %s", day, monday)
require.Equal(t, time.Monday, got.Weekday())
}
}
func TestEndOfWeekLandsOnSunday(t *testing.T) {
loc := jakarta(t)
// Sunday 9 Aug 2026 closes the week that starts Monday 3 Aug
got := endOfWeek(time.Date(2026, 8, 5, 9, 30, 0, 0, loc))
require.Equal(t, time.Sunday, got.Weekday())
require.Equal(t, 2026, got.Year())
require.Equal(t, time.August, got.Month())
require.Equal(t, 9, got.Day())
require.Equal(t, 23, got.Hour())
require.Equal(t, 59, got.Minute())
}
func TestBuildBudgetCutOffWidensToWholeWeeks(t *testing.T) {
loc := jakarta(t)
processor := &AnalyticsProcessorImpl{analyticsRepo: &analyticsRepositoryStub{}}
// Saturday 1 Aug to Monday 31 Aug 2026: both ends fall mid-week
from := time.Date(2026, 8, 1, 0, 0, 0, 0, loc)
to := time.Date(2026, 8, 31, 23, 59, 59, 0, loc)
budget, err := processor.buildBudgetCutOff(context.Background(), uuid.New(), nil, nil, from, to)
require.NoError(t, err)
// Reaches back into July and forward into September to keep weeks whole
require.Equal(t, time.Monday, budget.CutOffFrom.Weekday())
require.Equal(t, time.July, budget.CutOffFrom.Month())
require.Equal(t, 27, budget.CutOffFrom.Day())
require.Equal(t, time.Sunday, budget.CutOffTo.Weekday())
require.Equal(t, time.September, budget.CutOffTo.Month())
require.Equal(t, 6, budget.CutOffTo.Day())
require.Len(t, budget.Weekly, 6)
for _, week := range budget.Weekly {
require.Equal(t, time.Monday, week.PeriodStart.Weekday())
require.Equal(t, time.Sunday, week.PeriodEnd.Weekday())
}
// A week is filed under the month of its Monday, so the 27 Jul week counts as July
require.Len(t, budget.Monthly, 2)
require.Equal(t, "2026-07", budget.Monthly[0].Month)
require.Equal(t, 1, budget.Monthly[0].WeekCount)
require.Equal(t, "2026-08", budget.Monthly[1].Month)
require.Equal(t, 5, budget.Monthly[1].WeekCount)
}
func TestBuildBudgetCutOffAppliesLimits(t *testing.T) {
loc := jakarta(t)
weekStart := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{
{WeekStart: weekStart, Revenue: 10_000_000, OrderCount: 120},
}}
processor := &AnalyticsProcessorImpl{analyticsRepo: stub}
budget, err := processor.buildBudgetCutOff(context.Background(), uuid.New(), nil, nil, weekStart, weekStart.AddDate(0, 0, 6))
require.NoError(t, err)
require.Len(t, budget.Weekly, 1)
// 60 / 20 / 20 of the week's revenue
week := budget.Weekly[0]
require.Equal(t, float64(6_000_000), week.LimitPurchase)
require.Equal(t, float64(2_000_000), week.LimitOwner)
require.Equal(t, float64(2_000_000), week.LimitTeam)
require.Equal(t, int64(120), week.OrderCount)
// Totals mirror the single week
require.Equal(t, week.Revenue, budget.Total.Revenue)
require.Equal(t, week.LimitPurchase, budget.Total.LimitPurchase)
}
func TestBuildBudgetCutOffAccumulatesMonthlyFromWeeks(t *testing.T) {
loc := jakarta(t)
first := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{
{WeekStart: first, Revenue: 10_000_000, OrderCount: 100},
{WeekStart: first.AddDate(0, 0, 7), Revenue: 5_000_000, OrderCount: 60},
}}
processor := &AnalyticsProcessorImpl{analyticsRepo: stub}
budget, err := processor.buildBudgetCutOff(context.Background(), uuid.New(), nil, nil, first, first.AddDate(0, 0, 9))
require.NoError(t, err)
require.Len(t, budget.Monthly, 1)
month := budget.Monthly[0]
require.Equal(t, "2026-08", month.Month)
require.Equal(t, 2, month.WeekCount)
require.Equal(t, float64(15_000_000), month.Revenue)
require.Equal(t, int64(160), month.OrderCount)
// The month limit is the accumulation of its weeks
require.Equal(t, float64(9_000_000), month.LimitPurchase)
require.Equal(t, budget.Weekly[0].LimitPurchase+budget.Weekly[1].LimitPurchase, month.LimitPurchase)
}
func TestBuildBudgetCutOffEmitsWeeksWithoutSales(t *testing.T) {
loc := jakarta(t)
first := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
// Only the third week has sales; the two quiet weeks must still be reported
stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{
{WeekStart: first.AddDate(0, 0, 14), Revenue: 4_000_000, OrderCount: 40},
}}
processor := &AnalyticsProcessorImpl{analyticsRepo: stub}
budget, err := processor.buildBudgetCutOff(context.Background(), uuid.New(), nil, nil, first, first.AddDate(0, 0, 16))
require.NoError(t, err)
require.Len(t, budget.Weekly, 3)
require.Zero(t, budget.Weekly[0].Revenue)
require.Zero(t, budget.Weekly[0].LimitPurchase)
require.Zero(t, budget.Weekly[1].Revenue)
require.Equal(t, float64(4_000_000), budget.Weekly[2].Revenue)
require.Equal(t, float64(4_000_000), budget.Total.Revenue)
}
+30
View File
@@ -53,6 +53,18 @@ func (p *CategoryProcessorImpl) CreateCategory(ctx context.Context, req *models.
return nil, fmt.Errorf("category with name '%s' already exists for this organization", req.Name)
}
var parentName *string
if req.ParentID != nil {
parentCategory, err := p.categoryRepo.GetByID(ctx, *req.ParentID)
if err != nil {
return nil, fmt.Errorf("parent category not found: %w", err)
}
if parentCategory.OrganizationID != req.OrganizationID {
return nil, fmt.Errorf("parent category must belong to the same organization")
}
parentName = &parentCategory.Name
}
// Map request to entity
categoryEntity := mappers.CreateCategoryRequestToEntity(req)
@@ -63,6 +75,7 @@ func (p *CategoryProcessorImpl) CreateCategory(ctx context.Context, req *models.
// Map entity to response model
response := mappers.CategoryEntityToResponse(categoryEntity)
response.ParentName = parentName
return response, nil
}
@@ -84,6 +97,23 @@ func (p *CategoryProcessorImpl) UpdateCategory(ctx context.Context, id uuid.UUID
}
}
if req.ParentID != nil {
if *req.ParentID == id {
return nil, fmt.Errorf("category cannot be its own parent")
}
parentCategory, err := p.categoryRepo.GetByID(ctx, *req.ParentID)
if err != nil {
return nil, fmt.Errorf("parent category not found: %w", err)
}
if parentCategory.OrganizationID != existingCategory.OrganizationID {
return nil, fmt.Errorf("parent category must belong to the same organization")
}
// Refresh the preloaded association so the response carries the new parent
existingCategory.Parent = parentCategory
}
// Apply updates to entity
mappers.UpdateCategoryEntityFromRequest(existingCategory, req)
+395 -10
View File
@@ -2,6 +2,7 @@ package repository
import (
"context"
"fmt"
"sort"
"time"
@@ -17,10 +18,14 @@ 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)
GetExclusiveSummaryBankBalances(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error)
GetOutletName(ctx context.Context, organizationID uuid.UUID, outletID uuid.UUID) (string, error)
}
type AnalyticsRepositoryImpl struct {
@@ -40,6 +45,22 @@ func (r *AnalyticsRepositoryImpl) resolveOutletID(query *gorm.DB, outletID *uuid
return query
}
func (r *AnalyticsRepositoryImpl) GetOutletName(ctx context.Context, organizationID uuid.UUID, outletID uuid.UUID) (string, error) {
var outlet struct {
Name string
}
result := r.db.WithContext(ctx).
Table("outlets").
Select("name").
Where("id = ? AND organization_id = ?", outletID, organizationID).
Limit(1).
Scan(&outlet)
if result.Error != nil {
return "", result.Error
}
return outlet.Name, nil
}
func purchaseOrderItemTotalAmountSQL() string {
return "CASE WHEN pc.type = '" + string(entities.PurchaseCategoryTypeRawMaterial) + "' THEN COALESCE(poi.quantity, 0) * poi.amount ELSE poi.amount END"
}
@@ -444,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
@@ -471,6 +738,41 @@ func (r *AnalyticsRepositoryImpl) GetDashboardOverview(ctx context.Context, orga
return nil, err
}
// Total item sold (sum of order_items quantity for completed orders in date range)
var totalItemSold int64
itemQuery := r.db.WithContext(ctx).
Table("order_items oi").
Select("COALESCE(SUM(oi.quantity), 0)").
Joins("JOIN orders o ON o.id = oi.order_id").
Where("o.organization_id = ?", organizationID).
Where("o.is_void = false AND o.is_refund = false AND o.payment_status = 'completed'").
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo)
itemQuery = r.resolveOutletID(itemQuery, outletID, "o.outlet_id")
itemQuery.Scan(&totalItemSold)
result.TotalItemSold = totalItemSold
// Total low stock (inventory where quantity <= reorder_level)
var totalLowStock int64
lowStockQuery := r.db.WithContext(ctx).
Table("inventory i").
Select("COUNT(i.id)").
Joins("JOIN products p ON p.id = i.product_id").
Where("p.organization_id = ?", organizationID).
Where("i.quantity <= i.reorder_level")
lowStockQuery = r.resolveOutletID(lowStockQuery, outletID, "i.outlet_id")
lowStockQuery.Scan(&totalLowStock)
result.TotalLowStock = totalLowStock
// Total active products
var totalProductActive int64
productQuery := r.db.WithContext(ctx).
Table("products p").
Select("COUNT(p.id)").
Where("p.organization_id = ?", organizationID).
Where("p.is_active = true")
productQuery.Scan(&totalProductActive)
result.TotalProductActive = totalProductActive
return &result, nil
}
@@ -695,17 +997,38 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
}
opsItems = mergeOperationalExpenseItems(opsItems, poOpsItems)
todayPurchasing, err := r.getPurchaseOrderTotals(ctx, organizationID, todayStart, todayEnd)
if err != nil {
return nil, err
}
mtdPurchasing, err := r.getPurchaseOrderTotals(ctx, organizationID, mtdStart, todayEnd)
if err != nil {
return nil, err
}
purchasingItems, err := r.getPurchasingItemDetails(ctx, organizationID, dateFrom, dateTo)
if err != nil {
return nil, err
}
return &entities.ProfitLossAnalytics{
Summary: summary,
Data: data,
ProductData: productData,
TodayRevenue: todayRC.Revenue,
TodayCost: todayRC.Cost,
MtdRevenue: mtdRC.Revenue,
MtdCost: mtdRC.Cost,
TodayExpenseByCategory: todayExpenseByCategory,
MtdExpenseByCategory: mtdExpenseByCategory,
OperationalExpenseItems: opsItems,
Summary: summary,
Data: data,
ProductData: productData,
TodayRevenue: todayRC.Revenue,
TodayCost: todayRC.Cost,
MtdRevenue: mtdRC.Revenue,
MtdCost: mtdRC.Cost,
TodayPurchasing: todayPurchasing.Total,
MtdPurchasing: mtdPurchasing.Total,
TodayPurchasingRawMaterial: todayPurchasing.RawMaterial,
MtdPurchasingRawMaterial: mtdPurchasing.RawMaterial,
TodayPurchasingExpense: todayPurchasing.Expense,
MtdPurchasingExpense: mtdPurchasing.Expense,
PurchasingItems: purchasingItems,
TodayExpenseByCategory: todayExpenseByCategory,
MtdExpenseByCategory: mtdExpenseByCategory,
OperationalExpenseItems: opsItems,
}, nil
}
@@ -732,6 +1055,68 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderRawMaterialTotal(ctx context.C
return result.Total, nil
}
type purchasingTotals struct {
Total float64
RawMaterial float64
Expense float64
}
func (r *AnalyticsRepositoryImpl) getPurchaseOrderTotals(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) (purchasingTotals, error) {
type result struct {
Total float64
RawMaterial float64
Expense float64
}
var res result
query := r.db.WithContext(ctx).
Table("purchase_order_items poi").
Select(`
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total,
COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material,
COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense
`).
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
Where("po.organization_id = ?", organizationID).
Where("po.status = ?", "received").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
if err := query.Scan(&res).Error; err != nil {
return purchasingTotals{}, err
}
return purchasingTotals{
Total: res.Total,
RawMaterial: res.RawMaterial,
Expense: res.Expense,
}, nil
}
func (r *AnalyticsRepositoryImpl) getPurchasingItemDetails(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) ([]entities.PurchasingItemDetail, error) {
var results []entities.PurchasingItemDetail
query := r.db.WithContext(ctx).
Table("purchase_order_items poi").
Select(`
po.transaction_date as date,
COALESCE(NULLIF(poi.description, ''), i.name, pc.name) as item,
COALESCE(poi.quantity, 0) as quantity,
CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeRawMaterial)+`' THEN COALESCE(poi.quantity, 0) * poi.amount ELSE poi.amount END as amount
`).
Joins("JOIN purchase_orders po 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").
Where("po.organization_id = ?", organizationID).
Where("po.status = ?", "received").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Order("po.transaction_date DESC, poi.created_at DESC")
if err := query.Scan(&results).Error; err != nil {
return nil, err
}
return results, nil
}
func (r *AnalyticsRepositoryImpl) getPurchaseOrderRawMaterialCostByPeriod(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) ([]entities.ProfitLossData, error) {
var dateFormat string
switch groupBy {
+26 -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,13 +55,29 @@ 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 {
return r.db.WithContext(ctx).Delete(&entities.Category{}, "id = ?", id).Error
}
// applyCategoryTypeFilter narrows the query by position in the category tree.
// - "parent": top level categories only (no parent of their own)
// - "child": leaf categories — sub categories plus top level categories that
// have no sub categories, i.e. everything a product can be assigned to
func applyCategoryTypeFilter(query *gorm.DB, value interface{}) *gorm.DB {
switch value {
case "parent":
return query.Where("parent_id IS NULL")
case "child":
return query.Where("NOT EXISTS (SELECT 1 FROM categories AS sub WHERE sub.parent_id = categories.id)")
default:
return query
}
}
func (r *CategoryRepositoryImpl) List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.Category, int64, error) {
var categories []*entities.Category
var total int64
@@ -75,6 +92,8 @@ func (r *CategoryRepositoryImpl) List(ctx context.Context, filters map[string]in
case "outlet_id":
// Include outlet-specific categories AND global categories (outlet_id IS NULL)
query = query.Where("outlet_id = ? OR outlet_id IS NULL", value)
case "type":
query = applyCategoryTypeFilter(query, value)
default:
query = query.Where(key+" = ?", value)
}
@@ -84,7 +103,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 +116,10 @@ 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)
case "type":
query = applyCategoryTypeFilter(query, value)
default:
query = query.Where(key+" = ?", value)
}
+21 -2
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)
}
@@ -110,10 +112,21 @@ func (r *ProductRepositoryImpl) List(ctx context.Context, filters map[string]int
return nil, 0, err
}
err := query.Limit(limit).Offset(offset).Find(&products).Error
// id is a tie-breaker so LIMIT/OFFSET paging stays stable when several products
// share the same created_at.
err := query.Order("created_at DESC, id DESC").Limit(limit).Offset(offset).Find(&products).Error
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 +140,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 +247,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)
}
@@ -250,6 +267,8 @@ func (r *ProductRepositoryImpl) ListWithOutletPrice(ctx context.Context, filters
return nil, 0, err
}
err := query.Limit(limit).Offset(offset).Find(&products).Error
// Columns are qualified because the outlet join brings a second created_at/id
// into scope. id is a tie-breaker for stable LIMIT/OFFSET paging.
err := query.Order("products.created_at DESC, products.id DESC").Limit(limit).Offset(offset).Find(&products).Error
return products, total, err
}
+2
View File
@@ -335,6 +335,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
analytics.GET("/purchasing", r.analyticsHandler.GetPurchasingAnalytics)
analytics.GET("/products", r.analyticsHandler.GetProductAnalytics)
analytics.GET("/categories", r.analyticsHandler.GetProductAnalyticsPerCategory)
analytics.GET("/parent-categories", r.analyticsHandler.GetProductAnalyticsPerParentCategory)
analytics.GET("/parent-categories/:parent_category_id", r.analyticsHandler.GetParentCategoryAnalyticsDetail)
analytics.GET("/dashboard", r.analyticsHandler.GetDashboardAnalytics)
analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics)
analytics.GET("/exclusive-summary/period", r.analyticsHandler.GetExclusiveSummaryPeriod)
+76
View File
@@ -16,6 +16,8 @@ type AnalyticsService interface {
GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error)
GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error)
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error)
GetProductAnalyticsPerParentCategory(ctx context.Context, req *models.ProductAnalyticsPerParentCategoryRequest) (*models.ProductAnalyticsPerParentCategoryResponse, error)
GetParentCategoryAnalyticsDetail(ctx context.Context, req *models.ParentCategoryAnalyticsDetailRequest) (*models.ParentCategoryAnalyticsDetailResponse, error)
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error)
GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error)
@@ -104,6 +106,36 @@ func (s *AnalyticsServiceImpl) GetProductAnalyticsPerCategory(ctx context.Contex
return response, nil
}
func (s *AnalyticsServiceImpl) GetProductAnalyticsPerParentCategory(ctx context.Context, req *models.ProductAnalyticsPerParentCategoryRequest) (*models.ProductAnalyticsPerParentCategoryResponse, error) {
// Validate request
if err := s.validateProductAnalyticsPerParentCategoryRequest(req); err != nil {
return nil, fmt.Errorf("validation error: %w", err)
}
// Process analytics request
response, err := s.analyticsProcessor.GetProductAnalyticsPerParentCategory(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to get product analytics per parent category: %w", err)
}
return response, nil
}
func (s *AnalyticsServiceImpl) GetParentCategoryAnalyticsDetail(ctx context.Context, req *models.ParentCategoryAnalyticsDetailRequest) (*models.ParentCategoryAnalyticsDetailResponse, error) {
// Validate request
if err := s.validateParentCategoryAnalyticsDetailRequest(req); err != nil {
return nil, fmt.Errorf("validation error: %w", err)
}
// Process analytics request
response, err := s.analyticsProcessor.GetParentCategoryAnalyticsDetail(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to get parent category analytics detail: %w", err)
}
return response, nil
}
func (s *AnalyticsServiceImpl) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) {
// Validate request
if err := s.validateDashboardAnalyticsRequest(req); err != nil {
@@ -253,6 +285,50 @@ func (s *AnalyticsServiceImpl) validateProductAnalyticsPerCategoryRequest(req *m
return nil
}
func (s *AnalyticsServiceImpl) validateProductAnalyticsPerParentCategoryRequest(req *models.ProductAnalyticsPerParentCategoryRequest) error {
if req.OrganizationID == uuid.Nil {
return fmt.Errorf("organization ID is required")
}
if req.DateFrom.IsZero() {
return fmt.Errorf("date_from is required")
}
if req.DateTo.IsZero() {
return fmt.Errorf("date_to is required")
}
if req.DateFrom.After(req.DateTo) {
return fmt.Errorf("date_from cannot be after date_to")
}
return nil
}
func (s *AnalyticsServiceImpl) validateParentCategoryAnalyticsDetailRequest(req *models.ParentCategoryAnalyticsDetailRequest) error {
if req.OrganizationID == uuid.Nil {
return fmt.Errorf("organization ID is required")
}
if req.ParentCategoryID == uuid.Nil {
return fmt.Errorf("parent category ID is required")
}
if req.DateFrom.IsZero() {
return fmt.Errorf("date_from is required")
}
if req.DateTo.IsZero() {
return fmt.Errorf("date_to is required")
}
if req.DateFrom.After(req.DateTo) {
return fmt.Errorf("date_from cannot be after date_to")
}
return nil
}
func (s *AnalyticsServiceImpl) validateDashboardAnalyticsRequest(req *models.DashboardAnalyticsRequest) error {
if req.OrganizationID == uuid.Nil {
return fmt.Errorf("organization ID is required")
@@ -33,6 +33,14 @@ func (analyticsProcessorStub) GetProductAnalyticsPerCategory(context.Context, *m
return nil, nil
}
func (analyticsProcessorStub) GetProductAnalyticsPerParentCategory(context.Context, *models.ProductAnalyticsPerParentCategoryRequest) (*models.ProductAnalyticsPerParentCategoryResponse, error) {
return nil, nil
}
func (analyticsProcessorStub) GetParentCategoryAnalyticsDetail(context.Context, *models.ParentCategoryAnalyticsDetailRequest) (*models.ParentCategoryAnalyticsDetailResponse, error) {
return nil, nil
}
func (analyticsProcessorStub) GetDashboardAnalytics(context.Context, *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) {
return nil, nil
}
+6
View File
@@ -91,6 +91,12 @@ func (s *CategoryServiceImpl) ListCategories(ctx context.Context, req *contract.
if req.BusinessType != "" {
filters["business_type"] = req.BusinessType
}
if req.ParentID != nil {
filters["parent_id"] = *req.ParentID
}
if req.Type != "" {
filters["type"] = req.Type
}
if req.Search != "" {
filters["search"] = req.Search
}
+228 -9
View File
@@ -66,6 +66,7 @@ func PaymentMethodAnalyticsModelToContract(resp *models.PaymentMethodAnalyticsRe
return &contract.PaymentMethodAnalyticsResponse{
OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom,
DateTo: resp.DateTo,
GroupBy: resp.GroupBy,
@@ -122,6 +123,7 @@ func SalesAnalyticsModelToContract(resp *models.SalesAnalyticsResponse) *contrac
return &contract.SalesAnalyticsResponse{
OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom,
DateTo: resp.DateTo,
GroupBy: resp.GroupBy,
@@ -285,6 +287,7 @@ func ProductAnalyticsModelToContract(resp *models.ProductAnalyticsResponse) *con
return &contract.ProductAnalyticsResponse{
OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom,
DateTo: resp.DateTo,
Data: data,
@@ -337,12 +340,202 @@ func ProductAnalyticsPerCategoryModelToContract(resp *models.ProductAnalyticsPer
return &contract.ProductAnalyticsPerCategoryResponse{
OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom,
DateTo: resp.DateTo,
Data: data,
}
}
// ProductAnalyticsPerParentCategoryContractToModel converts contract request to model
func ProductAnalyticsPerParentCategoryContractToModel(req *contract.ProductAnalyticsPerParentCategoryRequest) *models.ProductAnalyticsPerParentCategoryRequest {
var dateFrom, dateTo time.Time
// Parse date range using utility function
if fromTime, toTime, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo); err == nil {
if fromTime != nil {
dateFrom = *fromTime
}
if toTime != nil {
dateTo = *toTime
}
}
return &models.ProductAnalyticsPerParentCategoryRequest{
OrganizationID: req.OrganizationID,
OutletID: parseOutletID(req.OutletID),
DateFrom: dateFrom,
DateTo: dateTo,
}
}
// ProductAnalyticsPerParentCategoryModelToContract converts model response to contract
func ProductAnalyticsPerParentCategoryModelToContract(resp *models.ProductAnalyticsPerParentCategoryResponse) *contract.ProductAnalyticsPerParentCategoryResponse {
if resp == nil {
return nil
}
var data []contract.ProductAnalyticsPerParentCategoryData
for _, item := range resp.Data {
data = append(data, contract.ProductAnalyticsPerParentCategoryData{
ParentCategoryID: item.ParentCategoryID,
ParentCategoryName: item.ParentCategoryName,
TotalRevenue: item.TotalRevenue,
TotalQuantity: item.TotalQuantity,
CategoryCount: item.CategoryCount,
ProductCount: item.ProductCount,
OrderCount: item.OrderCount,
TotalStandardHpp: item.TotalStandardHpp,
TotalFifoHpp: item.TotalFifoHpp,
TotalMovingAverageHpp: item.TotalMovingAverageHpp,
})
}
return &contract.ProductAnalyticsPerParentCategoryResponse{
OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom,
DateTo: resp.DateTo,
Data: data,
Budget: BudgetCutOffModelToContract(resp.Budget),
}
}
// budgetPeriodModelToContract converts one budget period to contract
func budgetPeriodModelToContract(period models.BudgetPeriod) contract.BudgetPeriod {
return contract.BudgetPeriod{
PeriodStart: period.PeriodStart,
PeriodEnd: period.PeriodEnd,
Revenue: period.Revenue,
OrderCount: period.OrderCount,
LimitPurchase: period.LimitPurchase,
LimitOwner: period.LimitOwner,
LimitTeam: period.LimitTeam,
}
}
// BudgetCutOffModelToContract converts the budget cut-off block to contract
func BudgetCutOffModelToContract(budget models.BudgetCutOff) contract.BudgetCutOff {
weekly := make([]contract.BudgetPeriod, 0, len(budget.Weekly))
for _, week := range budget.Weekly {
weekly = append(weekly, budgetPeriodModelToContract(week))
}
monthly := make([]contract.BudgetMonthPeriod, 0, len(budget.Monthly))
for _, month := range budget.Monthly {
monthly = append(monthly, contract.BudgetMonthPeriod{
Month: month.Month,
WeekCount: month.WeekCount,
BudgetPeriod: budgetPeriodModelToContract(month.BudgetPeriod),
})
}
return contract.BudgetCutOff{
Percentages: contract.BudgetPercentages{
Purchase: budget.Percentages.Purchase,
Owner: budget.Percentages.Owner,
Team: budget.Percentages.Team,
},
CutOffFrom: budget.CutOffFrom,
CutOffTo: budget.CutOffTo,
Total: budgetPeriodModelToContract(budget.Total),
Weekly: weekly,
Monthly: monthly,
}
}
// ParentCategoryAnalyticsDetailContractToModel converts contract request to model
func ParentCategoryAnalyticsDetailContractToModel(req *contract.ParentCategoryAnalyticsDetailRequest) *models.ParentCategoryAnalyticsDetailRequest {
var dateFrom, dateTo time.Time
// Parse date range using utility function
if fromTime, toTime, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo); err == nil {
if fromTime != nil {
dateFrom = *fromTime
}
if toTime != nil {
dateTo = *toTime
}
}
// An unparseable id stays uuid.Nil and is rejected by the service validator
parentCategoryID, _ := uuid.Parse(req.ParentCategoryID)
return &models.ParentCategoryAnalyticsDetailRequest{
OrganizationID: req.OrganizationID,
ParentCategoryID: parentCategoryID,
OutletID: parseOutletID(req.OutletID),
DateFrom: dateFrom,
DateTo: dateTo,
}
}
// ParentCategoryAnalyticsDetailModelToContract converts model response to contract
func ParentCategoryAnalyticsDetailModelToContract(resp *models.ParentCategoryAnalyticsDetailResponse) *contract.ParentCategoryAnalyticsDetailResponse {
if resp == nil {
return nil
}
categories := make([]contract.ParentCategoryAnalyticsDetailData, 0, len(resp.Categories))
for _, category := range resp.Categories {
products := make([]contract.ParentCategoryAnalyticsProductData, 0, len(category.Products))
for _, product := range category.Products {
products = append(products, contract.ParentCategoryAnalyticsProductData{
ProductID: product.ProductID,
ProductName: product.ProductName,
ProductSku: product.ProductSku,
ProductPrice: product.ProductPrice,
QuantitySold: product.QuantitySold,
Revenue: product.Revenue,
AveragePrice: product.AveragePrice,
OrderCount: product.OrderCount,
StandardHppPerUnit: product.StandardHppPerUnit,
StandardHppTotal: product.StandardHppTotal,
FifoHppPerUnit: product.FifoHppPerUnit,
FifoHppTotal: product.FifoHppTotal,
MovingAverageHppPerUnit: product.MovingAverageHppPerUnit,
MovingAverageHppTotal: product.MovingAverageHppTotal,
})
}
categories = append(categories, contract.ParentCategoryAnalyticsDetailData{
CategoryID: category.CategoryID,
CategoryName: category.CategoryName,
TotalRevenue: category.TotalRevenue,
TotalQuantity: category.TotalQuantity,
ProductCount: category.ProductCount,
OrderCount: category.OrderCount,
TotalStandardHpp: category.TotalStandardHpp,
TotalFifoHpp: category.TotalFifoHpp,
TotalMovingAverageHpp: category.TotalMovingAverageHpp,
Products: products,
})
}
return &contract.ParentCategoryAnalyticsDetailResponse{
OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom,
DateTo: resp.DateTo,
ParentCategoryID: resp.ParentCategoryID,
ParentCategoryName: resp.ParentCategoryName,
Summary: contract.ParentCategoryAnalyticsDetailSummary{
TotalRevenue: resp.Summary.TotalRevenue,
TotalQuantity: resp.Summary.TotalQuantity,
CategoryCount: resp.Summary.CategoryCount,
ProductCount: resp.Summary.ProductCount,
OrderCount: resp.Summary.OrderCount,
TotalStandardHpp: resp.Summary.TotalStandardHpp,
TotalFifoHpp: resp.Summary.TotalFifoHpp,
TotalMovingAverageHpp: resp.Summary.TotalMovingAverageHpp,
},
Categories: categories,
Budget: BudgetCutOffModelToContract(resp.Budget),
}
}
// DashboardAnalyticsContractToModel converts contract request to model
func DashboardAnalyticsContractToModel(req *contract.DashboardAnalyticsRequest) *models.DashboardAnalyticsRequest {
var dateFrom, dateTo time.Time
@@ -421,15 +614,19 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse)
return &contract.DashboardAnalyticsResponse{
OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom,
DateTo: resp.DateTo,
Overview: contract.DashboardOverview{
TotalSales: resp.Overview.TotalSales,
TotalOrders: resp.Overview.TotalOrders,
AverageOrderValue: resp.Overview.AverageOrderValue,
TotalCustomers: resp.Overview.TotalCustomers,
VoidedOrders: resp.Overview.VoidedOrders,
RefundedOrders: resp.Overview.RefundedOrders,
TotalSales: resp.Overview.TotalSales,
TotalOrders: resp.Overview.TotalOrders,
AverageOrderValue: resp.Overview.AverageOrderValue,
TotalCustomers: resp.Overview.TotalCustomers,
VoidedOrders: resp.Overview.VoidedOrders,
RefundedOrders: resp.Overview.RefundedOrders,
TotalItemSold: resp.Overview.TotalItemSold,
TotalLowStock: resp.Overview.TotalLowStock,
TotalProductActive: resp.Overview.TotalProductActive,
},
TopProducts: topProducts,
PaymentMethods: paymentMethods,
@@ -516,9 +713,20 @@ func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse
}
}
purchasingItems := make([]contract.ProfitLossPurchasingItem, len(resp.Purchasing.Items))
for i, item := range resp.Purchasing.Items {
purchasingItems[i] = contract.ProfitLossPurchasingItem{
Date: item.Date,
Item: item.Item,
Quantity: item.Quantity,
Nominal: item.Nominal,
}
}
return &contract.ProfitLossAnalyticsResponse{
OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom,
DateTo: resp.DateTo,
GroupBy: resp.GroupBy,
@@ -535,9 +743,18 @@ func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse
AverageProfit: resp.Summary.AverageProfit,
ProfitabilityRatio: resp.Summary.ProfitabilityRatio,
},
Data: data,
ProductData: productData,
MainSummary: mainSummary,
Data: data,
ProductData: productData,
MainSummary: mainSummary,
Purchasing: contract.ProfitLossPurchasing{
TodayTotal: resp.Purchasing.TodayTotal,
MtdTotal: resp.Purchasing.MtdTotal,
TodayRawMaterial: resp.Purchasing.TodayRawMaterial,
MtdRawMaterial: resp.Purchasing.MtdRawMaterial,
TodayExpense: resp.Purchasing.TodayExpense,
MtdExpense: resp.Purchasing.MtdExpense,
Items: purchasingItems,
},
OperationalExpenses: opsItems,
OperationalExpensesTotal: resp.OperationalExpensesTotal,
}
@@ -664,6 +881,7 @@ func ExclusiveSummaryPeriodModelToContract(resp *models.ExclusiveSummaryPeriodRe
return &contract.ExclusiveSummaryPeriodResponse{
OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID,
OutletName: resp.OutletName,
Period: contract.ExclusiveSummaryPeriodRange{
DateFrom: resp.Period.DateFrom,
DateTo: resp.Period.DateTo,
@@ -726,6 +944,7 @@ func ExclusiveSummaryMonthlyModelToContract(resp *models.ExclusiveSummaryMonthly
return &contract.ExclusiveSummaryMonthlyResponse{
OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID,
OutletName: resp.OutletName,
Month: resp.Month,
Summary: contract.ExclusiveSummaryMonthlySummary{
TotalSales: resp.Summary.TotalSales,
@@ -14,6 +14,7 @@ func CreateCategoryRequestToModel(apctx *appcontext.ContextInfo, req *contract.C
return &models.CreateCategoryRequest{
OrganizationID: apctx.OrganizationID,
OutletID: req.OutletID,
ParentID: req.ParentID,
Name: req.Name,
Description: req.Description,
ImageURL: nil,
@@ -27,6 +28,7 @@ func UpdateCategoryRequestToModel(req *contract.UpdateCategoryRequest) *models.U
Description: req.Description,
ImageURL: nil,
OutletID: req.OutletID,
ParentID: req.ParentID,
Order: req.Order,
IsActive: nil,
}
@@ -41,6 +43,8 @@ func CategoryModelResponseToResponse(cat *models.CategoryResponse) *contract.Cat
ID: cat.ID,
OrganizationID: cat.OrganizationID,
OutletID: cat.OutletID,
ParentID: cat.ParentID,
ParentName: cat.ParentName,
Name: cat.Name,
Description: cat.Description,
BusinessType: "restaurant",
+5 -1
View File
@@ -59,7 +59,7 @@ func (v *CategoryValidatorImpl) ValidateUpdateCategoryRequest(req *contract.Upda
}
// At least one field should be provided for update
if req.Name == nil && req.Description == nil && req.BusinessType == nil && req.Metadata == nil {
if req.Name == nil && req.Description == nil && req.BusinessType == nil && req.ParentID == nil && req.Metadata == nil {
return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode
}
@@ -118,5 +118,9 @@ func (v *CategoryValidatorImpl) ValidateListCategoriesRequest(req *contract.List
}
}
if req.Type != "" && req.Type != "parent" && req.Type != "child" {
return errors.New("type must be either 'parent' or 'child'"), constants.MalformedFieldErrorCode
}
return nil, ""
}
@@ -0,0 +1,6 @@
ALTER TABLE categories
DROP CONSTRAINT IF EXISTS categories_parent_id_fkey;
ALTER TABLE categories
DROP COLUMN IF EXISTS parent_id;
DROP INDEX IF EXISTS idx_categories_parent_id;
@@ -0,0 +1,4 @@
ALTER TABLE categories
ADD COLUMN parent_id UUID REFERENCES categories(id) ON DELETE SET NULL;
CREATE INDEX idx_categories_parent_id ON categories(parent_id);