Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d412959d7 | ||
|
|
b9ac97178f | ||
|
|
7b46da7007 | ||
|
|
2b80c92caa | ||
|
|
f7dd0bd5e8 | ||
|
|
1533914e4d | ||
|
|
bfce4b865b | ||
|
|
581e4a5453 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
)
|
||||
@@ -219,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
|
||||
|
||||
@@ -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,7 @@ 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"`
|
||||
BusinessType string `json:"business_type,omitempty"`
|
||||
Search string `json:"search,omitempty"`
|
||||
Page int `json:"page" validate:"required,min=1"`
|
||||
@@ -38,6 +41,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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -191,6 +191,11 @@ 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
|
||||
}
|
||||
}
|
||||
validationError, validationErrorCode := h.categoryValidator.ValidateListCategoriesRequest(req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(ctx).WithError(validationError).Error("CategoryHandler::ListCategories -> request validation failed")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -229,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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
@@ -19,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)
|
||||
@@ -356,6 +359,242 @@ func (p *AnalyticsProcessorImpl) GetProductAnalyticsPerCategory(ctx context.Cont
|
||||
}, 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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
@@ -17,6 +18,9 @@ type AnalyticsRepository interface {
|
||||
GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error)
|
||||
GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error)
|
||||
GetProductAnalyticsPerCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerCategory, error)
|
||||
GetProductAnalyticsPerParentCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerParentCategory, error)
|
||||
GetParentCategoryAnalyticsDetail(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, parentCategoryID uuid.UUID, dateFrom, dateTo time.Time) (*entities.ParentCategoryAnalyticsDetail, error)
|
||||
GetBudgetCutOffWeekly(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, parentCategoryID *uuid.UUID, cutOffFrom, cutOffTo time.Time) ([]*entities.BudgetCutOffWeek, error)
|
||||
GetDashboardOverview(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.DashboardOverview, error)
|
||||
GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ProfitLossAnalytics, error)
|
||||
GetExclusiveSummaryAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error)
|
||||
@@ -461,6 +465,252 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerCategory(ctx context.Con
|
||||
return results, err
|
||||
}
|
||||
|
||||
func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerParentCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerParentCategory, error) {
|
||||
var results []*entities.ProductAnalyticsPerParentCategory
|
||||
|
||||
query := r.db.WithContext(ctx).
|
||||
Table("order_items oi").
|
||||
Select(`
|
||||
pc.id as parent_category_id,
|
||||
pc.name as parent_category_name,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as total_revenue,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity,
|
||||
COUNT(DISTINCT c.id) as category_count,
|
||||
COUNT(DISTINCT p.id) as product_count,
|
||||
COUNT(DISTINCT oi.order_id) as order_count,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp
|
||||
`).
|
||||
Joins("JOIN products p ON oi.product_id = p.id").
|
||||
Joins("JOIN categories c ON p.category_id = c.id").
|
||||
// Categories without a parent roll up to themselves, so top-level categories still appear
|
||||
Joins("JOIN categories pc ON pc.id = COALESCE(c.parent_id, c.id)").
|
||||
Joins("JOIN orders o ON oi.order_id = o.id").
|
||||
Joins("LEFT JOIN (SELECT pr.product_id, SUM(pr.quantity * (1 + COALESCE(pr.waste_percentage, 0)/100.0) * i.cost) as hpp_per_unit FROM product_recipes pr JOIN ingredients i ON pr.ingredient_id = i.id GROUP BY pr.product_id) shpp ON shpp.product_id = p.id").
|
||||
Joins("LEFT JOIN (?) mahpp ON mahpp.product_id = p.id",
|
||||
r.db.Table("product_recipes pr2").
|
||||
Select("pr2.product_id, SUM(pr2.quantity * (1 + COALESCE(pr2.waste_percentage, 0)/100.0) * COALESCE(ma.moving_avg_cost, ing.cost)) as hpp_per_unit").
|
||||
Joins("JOIN ingredients ing ON pr2.ingredient_id = ing.id").
|
||||
Joins("LEFT JOIN (?) ma ON ma.ingredient_id = pr2.ingredient_id",
|
||||
r.db.Table("inventory_movements im").
|
||||
Select("im.item_id as ingredient_id, CASE WHEN SUM(im.quantity) > 0 THEN SUM(im.total_cost) / SUM(im.quantity) ELSE 0 END as moving_avg_cost").
|
||||
Where("im.movement_type = ?", "purchase").
|
||||
Where("im.item_type = ?", "INGREDIENT").
|
||||
Where("im.organization_id = ?", organizationID).
|
||||
Where("im.created_at <= ?", dateTo).
|
||||
Group("im.item_id"),
|
||||
).
|
||||
Group("pr2.product_id"),
|
||||
).
|
||||
Where("o.organization_id = ?", organizationID).
|
||||
Where("o.is_void = ?", false).
|
||||
Where("o.is_refund = ?", false).
|
||||
Where("o.payment_status = ?", entities.PaymentStatusCompleted).
|
||||
Where("oi.status != ?", entities.OrderItemStatusCancelled).
|
||||
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo)
|
||||
|
||||
query = r.resolveOutletID(query, outletID, "o.outlet_id")
|
||||
|
||||
err := query.
|
||||
Group("pc.id, pc.name").
|
||||
Order("pc.name ASC").
|
||||
Scan(&results).Error
|
||||
|
||||
return results, err
|
||||
}
|
||||
|
||||
// movingAverageHppSubquery builds the per-product moving-average HPP lookup shared by
|
||||
// the parent category detail queries.
|
||||
func (r *AnalyticsRepositoryImpl) movingAverageHppSubquery(organizationID uuid.UUID, dateTo time.Time) *gorm.DB {
|
||||
return r.db.Table("product_recipes pr2").
|
||||
Select("pr2.product_id, SUM(pr2.quantity * (1 + COALESCE(pr2.waste_percentage, 0)/100.0) * COALESCE(ma.moving_avg_cost, ing.cost)) as hpp_per_unit").
|
||||
Joins("JOIN ingredients ing ON pr2.ingredient_id = ing.id").
|
||||
Joins("LEFT JOIN (?) ma ON ma.ingredient_id = pr2.ingredient_id",
|
||||
r.db.Table("inventory_movements im").
|
||||
Select("im.item_id as ingredient_id, CASE WHEN SUM(im.quantity) > 0 THEN SUM(im.total_cost) / SUM(im.quantity) ELSE 0 END as moving_avg_cost").
|
||||
Where("im.movement_type = ?", "purchase").
|
||||
Where("im.item_type = ?", "INGREDIENT").
|
||||
Where("im.organization_id = ?", organizationID).
|
||||
Where("im.created_at <= ?", dateTo).
|
||||
Group("im.item_id"),
|
||||
).
|
||||
Group("pr2.product_id")
|
||||
}
|
||||
|
||||
// parentCategoryScopedQuery builds the common order_items -> product -> category join
|
||||
// restricted to a single parent category group. Categories without a parent belong to
|
||||
// their own group, so a leaf category resolves to itself.
|
||||
func (r *AnalyticsRepositoryImpl) parentCategoryScopedQuery(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, parentCategoryID uuid.UUID, dateFrom, dateTo time.Time) *gorm.DB {
|
||||
query := r.db.WithContext(ctx).
|
||||
Table("order_items oi").
|
||||
Joins("JOIN products p ON oi.product_id = p.id").
|
||||
Joins("JOIN categories c ON p.category_id = c.id").
|
||||
Joins("JOIN orders o ON oi.order_id = o.id").
|
||||
Joins("LEFT JOIN (SELECT pr.product_id, SUM(pr.quantity * (1 + COALESCE(pr.waste_percentage, 0)/100.0) * i.cost) as hpp_per_unit FROM product_recipes pr JOIN ingredients i ON pr.ingredient_id = i.id GROUP BY pr.product_id) shpp ON shpp.product_id = p.id").
|
||||
Joins("LEFT JOIN (?) mahpp ON mahpp.product_id = p.id", r.movingAverageHppSubquery(organizationID, dateTo)).
|
||||
Where("COALESCE(c.parent_id, c.id) = ?", parentCategoryID).
|
||||
Where("o.organization_id = ?", organizationID).
|
||||
Where("o.is_void = ?", false).
|
||||
Where("o.is_refund = ?", false).
|
||||
Where("o.payment_status = ?", entities.PaymentStatusCompleted).
|
||||
Where("oi.status != ?", entities.OrderItemStatusCancelled).
|
||||
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo)
|
||||
|
||||
return r.resolveOutletID(query, outletID, "o.outlet_id")
|
||||
}
|
||||
|
||||
func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, parentCategoryID uuid.UUID, dateFrom, dateTo time.Time) (*entities.ParentCategoryAnalyticsDetail, error) {
|
||||
// Resolve the category first so the endpoint still identifies the category when it
|
||||
// has no sales in the requested range, and rejects ids from another organization.
|
||||
var parent struct {
|
||||
ID uuid.UUID
|
||||
Name string
|
||||
}
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table("categories").
|
||||
Select("id, name").
|
||||
Where("id = ? AND organization_id = ?", parentCategoryID, organizationID).
|
||||
Scan(&parent).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parent.ID == uuid.Nil {
|
||||
return nil, fmt.Errorf("category not found")
|
||||
}
|
||||
|
||||
detail := &entities.ParentCategoryAnalyticsDetail{
|
||||
ParentCategoryID: parent.ID,
|
||||
ParentCategoryName: parent.Name,
|
||||
Categories: []*entities.ProductAnalyticsPerCategory{},
|
||||
Products: []*entities.ProductAnalytics{},
|
||||
}
|
||||
|
||||
// Totals for the whole parent group. Kept as its own aggregate because order_count
|
||||
// is a COUNT(DISTINCT order) and cannot be recovered by summing the category rows.
|
||||
summary := &entities.ProductAnalyticsPerParentCategory{}
|
||||
err := r.parentCategoryScopedQuery(ctx, organizationID, outletID, parentCategoryID, dateFrom, dateTo).
|
||||
Select(`
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as total_revenue,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity,
|
||||
COUNT(DISTINCT c.id) as category_count,
|
||||
COUNT(DISTINCT p.id) as product_count,
|
||||
COUNT(DISTINCT oi.order_id) as order_count,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp
|
||||
`).
|
||||
Scan(summary).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary.ParentCategoryID = parent.ID
|
||||
summary.ParentCategoryName = parent.Name
|
||||
detail.Summary = summary
|
||||
|
||||
// Sub-category rows.
|
||||
err = r.parentCategoryScopedQuery(ctx, organizationID, outletID, parentCategoryID, dateFrom, dateTo).
|
||||
Select(`
|
||||
c.id as category_id,
|
||||
c.name as category_name,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as total_revenue,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity,
|
||||
COUNT(DISTINCT p.id) as product_count,
|
||||
COUNT(DISTINCT oi.order_id) as order_count,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp
|
||||
`).
|
||||
Group("c.id, c.name, c.order").
|
||||
Order("c.order ASC, c.name ASC").
|
||||
Scan(&detail.Categories).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Product rows. Uses the same refund-aware arithmetic as the rows above so the
|
||||
// products of a category add up to that category's totals.
|
||||
err = r.parentCategoryScopedQuery(ctx, organizationID, outletID, parentCategoryID, dateFrom, dateTo).
|
||||
Joins("LEFT JOIN product_outlet_prices pop ON pop.product_id = p.id AND pop.outlet_id = o.outlet_id").
|
||||
Select(`
|
||||
p.id as product_id,
|
||||
p.name as product_name,
|
||||
p.sku as product_sku,
|
||||
COALESCE(
|
||||
NULLIF(pop.price, 0),
|
||||
(SELECT price FROM product_outlet_prices WHERE product_id = p.id ORDER BY updated_at DESC LIMIT 1),
|
||||
NULLIF(p.price, 0),
|
||||
0
|
||||
) as product_price,
|
||||
c.id as category_id,
|
||||
c.name as category_name,
|
||||
c.order as category_order,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as quantity_sold,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as revenue,
|
||||
COALESCE(
|
||||
SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END)
|
||||
/ NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0),
|
||||
0) as average_price,
|
||||
COUNT(DISTINCT oi.order_id) as order_count,
|
||||
COALESCE(shpp.hpp_per_unit, p.cost, 0) as standard_hpp_per_unit,
|
||||
COALESCE(shpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as standard_hpp_total,
|
||||
COALESCE(
|
||||
SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END)
|
||||
/ NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0),
|
||||
0) as fifo_hpp_per_unit,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as fifo_hpp_total,
|
||||
COALESCE(mahpp.hpp_per_unit, p.cost, 0) as moving_average_hpp_per_unit,
|
||||
COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as moving_average_hpp_total
|
||||
`).
|
||||
Group("p.id, p.name, p.sku, p.price, p.cost, pop.price, c.id, c.name, c.order, shpp.hpp_per_unit, mahpp.hpp_per_unit").
|
||||
Order("revenue DESC").
|
||||
Scan(&detail.Products).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
// GetBudgetCutOffWeekly buckets revenue and cost of goods sold into Monday-to-Sunday
|
||||
// weeks. DATE_TRUNC('week') is ISO, so the buckets start on Monday, and the connection
|
||||
// runs with TimeZone=Asia/Jakarta so the boundaries land on local midnight.
|
||||
// A nil parentCategoryID covers every category in scope.
|
||||
func (r *AnalyticsRepositoryImpl) GetBudgetCutOffWeekly(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, parentCategoryID *uuid.UUID, cutOffFrom, cutOffTo time.Time) ([]*entities.BudgetCutOffWeek, error) {
|
||||
var results []*entities.BudgetCutOffWeek
|
||||
|
||||
query := r.db.WithContext(ctx).
|
||||
Table("order_items oi").
|
||||
Select(`
|
||||
DATE_TRUNC('week', o.created_at) as week_start,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as revenue,
|
||||
COUNT(DISTINCT oi.order_id) as order_count
|
||||
`).
|
||||
// products and categories are joined to keep the scope identical to the report
|
||||
// the block is attached to, even when no parent category filter is applied
|
||||
Joins("JOIN products p ON oi.product_id = p.id").
|
||||
Joins("JOIN categories c ON p.category_id = c.id").
|
||||
Joins("JOIN orders o ON oi.order_id = o.id").
|
||||
Where("o.organization_id = ?", organizationID).
|
||||
Where("o.is_void = ?", false).
|
||||
Where("o.is_refund = ?", false).
|
||||
Where("o.payment_status = ?", entities.PaymentStatusCompleted).
|
||||
Where("oi.status != ?", entities.OrderItemStatusCancelled).
|
||||
Where("o.created_at >= ? AND o.created_at <= ?", cutOffFrom, cutOffTo)
|
||||
|
||||
if parentCategoryID != nil {
|
||||
query = query.Where("COALESCE(c.parent_id, c.id) = ?", *parentCategoryID)
|
||||
}
|
||||
|
||||
query = r.resolveOutletID(query, outletID, "o.outlet_id")
|
||||
|
||||
err := query.
|
||||
Group("DATE_TRUNC('week', o.created_at)").
|
||||
Order("week_start ASC").
|
||||
Scan(&results).Error
|
||||
|
||||
return results, err
|
||||
}
|
||||
|
||||
func (r *AnalyticsRepositoryImpl) GetDashboardOverview(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.DashboardOverview, error) {
|
||||
var result entities.DashboardOverview
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type CategoryRepositoryImpl struct {
|
||||
@@ -25,7 +26,7 @@ func (r *CategoryRepositoryImpl) Create(ctx context.Context, category *entities.
|
||||
|
||||
func (r *CategoryRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.Category, error) {
|
||||
var category entities.Category
|
||||
err := r.db.WithContext(ctx).First(&category, "id = ?", id).Error
|
||||
err := r.db.WithContext(ctx).Preload("Parent").First(&category, "id = ?", id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -54,7 +55,8 @@ func (r *CategoryRepositoryImpl) GetByBusinessType(ctx context.Context, business
|
||||
}
|
||||
|
||||
func (r *CategoryRepositoryImpl) Update(ctx context.Context, category *entities.Category) error {
|
||||
return r.db.WithContext(ctx).Save(category).Error
|
||||
// Omit associations so a preloaded Parent is not upserted back over parent_id
|
||||
return r.db.WithContext(ctx).Omit(clause.Associations).Save(category).Error
|
||||
}
|
||||
|
||||
func (r *CategoryRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
@@ -84,7 +86,7 @@ func (r *CategoryRepositoryImpl) List(ctx context.Context, filters map[string]in
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
err := query.Order("\"order\" ASC").Limit(limit).Offset(offset).Find(&categories).Error
|
||||
err := query.Preload("Parent").Order("\"order\" ASC").Limit(limit).Offset(offset).Find(&categories).Error
|
||||
return categories, total, err
|
||||
}
|
||||
|
||||
@@ -97,6 +99,8 @@ func (r *CategoryRepositoryImpl) Count(ctx context.Context, filters map[string]i
|
||||
case "search":
|
||||
searchValue := "%" + value.(string) + "%"
|
||||
query = query.Where("name ILIKE ? OR description ILIKE ?", searchValue, searchValue)
|
||||
case "outlet_id":
|
||||
query = query.Where("outlet_id = ? OR outlet_id IS NULL", value)
|
||||
default:
|
||||
query = query.Where(key+" = ?", value)
|
||||
}
|
||||
|
||||
@@ -101,6 +101,8 @@ func (r *ProductRepositoryImpl) List(ctx context.Context, filters map[string]int
|
||||
query = query.Where("price >= ?", value)
|
||||
case "price_max":
|
||||
query = query.Where("price <= ?", value)
|
||||
case "category_id":
|
||||
query = query.Where("category_id IN (?)", r.categoryAndChildrenIDs(value))
|
||||
default:
|
||||
query = query.Where(key+" = ?", value)
|
||||
}
|
||||
@@ -114,6 +116,15 @@ func (r *ProductRepositoryImpl) List(ctx context.Context, filters map[string]int
|
||||
return products, total, err
|
||||
}
|
||||
|
||||
// categoryAndChildrenIDs builds a subquery resolving to the category itself plus its
|
||||
// direct children, so filtering by a parent category also returns the children's
|
||||
// products. For a category without children it resolves to just that category.
|
||||
func (r *ProductRepositoryImpl) categoryAndChildrenIDs(categoryID interface{}) *gorm.DB {
|
||||
return r.db.Model(&entities.Category{}).
|
||||
Select("id").
|
||||
Where("id = ? OR parent_id = ?", categoryID, categoryID)
|
||||
}
|
||||
|
||||
func (r *ProductRepositoryImpl) Count(ctx context.Context, filters map[string]interface{}) (int64, error) {
|
||||
var count int64
|
||||
query := r.db.WithContext(ctx).Model(&entities.Product{})
|
||||
@@ -127,6 +138,8 @@ func (r *ProductRepositoryImpl) Count(ctx context.Context, filters map[string]in
|
||||
query = query.Where("price >= ?", value)
|
||||
case "price_max":
|
||||
query = query.Where("price <= ?", value)
|
||||
case "category_id":
|
||||
query = query.Where("category_id IN (?)", r.categoryAndChildrenIDs(value))
|
||||
default:
|
||||
query = query.Where(key+" = ?", value)
|
||||
}
|
||||
@@ -232,6 +245,8 @@ func (r *ProductRepositoryImpl) ListWithOutletPrice(ctx context.Context, filters
|
||||
query = query.Where("products.price >= ?", value)
|
||||
case "price_max":
|
||||
query = query.Where("products.price <= ?", value)
|
||||
case "category_id":
|
||||
query = query.Where("products.category_id IN (?)", r.categoryAndChildrenIDs(value))
|
||||
default:
|
||||
query = query.Where("products."+key+" = ?", value)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -91,6 +91,9 @@ 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.Search != "" {
|
||||
filters["search"] = req.Search
|
||||
}
|
||||
|
||||
@@ -347,6 +347,195 @@ func ProductAnalyticsPerCategoryModelToContract(resp *models.ProductAnalyticsPer
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user