Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbc65c5606 |
@@ -9,7 +9,3 @@ vendor
|
|||||||
|
|
||||||
# Firebase service account credentials
|
# Firebase service account credentials
|
||||||
infra/firebase-service-account.json
|
infra/firebase-service-account.json
|
||||||
|
|
||||||
# Config files containing secrets (manage manually on each server)
|
|
||||||
# infra/production.yaml
|
|
||||||
# infra/staging.yaml
|
|
||||||
|
|||||||
@@ -1,21 +1,9 @@
|
|||||||
#PROJECT_NAME = "enaklo-pos-backend"
|
#PROJECT_NAME = "enaklo-pos-backend"
|
||||||
|
DB_USERNAME :=apskel
|
||||||
# ─── Environment (default: staging) ──────────────────────────────────────────
|
DB_PASSWORD :=7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk
|
||||||
ENV ?= staging
|
DB_HOST :=62.72.45.250
|
||||||
|
DB_PORT :=5433
|
||||||
ifeq ($(ENV),production)
|
DB_NAME :=apskel_pos
|
||||||
DB_USERNAME :=apskel
|
|
||||||
DB_PASSWORD :=7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk
|
|
||||||
DB_HOST :=62.72.45.250
|
|
||||||
DB_PORT :=5433
|
|
||||||
DB_NAME :=apskel_pos
|
|
||||||
else
|
|
||||||
DB_USERNAME :=apskel
|
|
||||||
DB_PASSWORD :=7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk
|
|
||||||
DB_HOST :=62.72.45.250
|
|
||||||
DB_PORT :=5433
|
|
||||||
DB_NAME :=apskel_pos_staging
|
|
||||||
endif
|
|
||||||
|
|
||||||
DB_URL = postgres://$(DB_USERNAME):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=disable
|
DB_URL = postgres://$(DB_USERNAME):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=disable
|
||||||
|
|
||||||
@@ -28,19 +16,15 @@ endif
|
|||||||
.SILENT: help
|
.SILENT: help
|
||||||
help:
|
help:
|
||||||
@echo
|
@echo
|
||||||
@echo "Usage: make [command] [ENV=staging|production]"
|
@echo "Usage: make [command]"
|
||||||
@echo
|
@echo
|
||||||
@echo "Commands:"
|
@echo "Commands:"
|
||||||
@echo " run Run server (default: staging)"
|
|
||||||
@echo " run ENV=production Run server with production config"
|
|
||||||
@echo
|
|
||||||
@echo " rename-project name={name} Rename project"
|
@echo " rename-project name={name} Rename project"
|
||||||
@echo
|
@echo
|
||||||
@echo " build-http Build http server"
|
@echo " build-http Build http server"
|
||||||
@echo
|
@echo
|
||||||
@echo " migration-create name={name} Create migration"
|
@echo " migration-create name={name} Create migration"
|
||||||
@echo " migration-up Up migrations"
|
@echo " migration-up Up migrations"
|
||||||
@echo " migration-up ENV=production Up migrations (production DB)"
|
|
||||||
@echo " migration-down Down last migration"
|
@echo " migration-down Down last migration"
|
||||||
@echo
|
@echo
|
||||||
@echo " docker-up Up docker services"
|
@echo " docker-up Up docker services"
|
||||||
@@ -130,11 +114,7 @@ fmt:
|
|||||||
@go fmt ./...
|
@go fmt ./...
|
||||||
|
|
||||||
start:
|
start:
|
||||||
ENV_MODE=$(ENV) go run cmd/server/main.go
|
go run main.go --env-path .env
|
||||||
|
|
||||||
.SILENT: run
|
|
||||||
run:
|
|
||||||
ENV_MODE=$(ENV) go run cmd/server/main.go
|
|
||||||
|
|
||||||
# Default
|
# Default
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -12,14 +12,13 @@ import (
|
|||||||
const (
|
const (
|
||||||
YAML_PATH = "infra/%s"
|
YAML_PATH = "infra/%s"
|
||||||
ENV_MODE = "ENV_MODE"
|
ENV_MODE = "ENV_MODE"
|
||||||
DEFAULT_ENV_MODE = "staging"
|
DEFAULT_ENV_MODE = "development"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
validEnvMode = map[string]struct{}{
|
validEnvMode = map[string]struct{}{
|
||||||
"local": {},
|
"local": {},
|
||||||
"development": {},
|
"development": {},
|
||||||
"staging": {},
|
|
||||||
"production": {},
|
"production": {},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
+8
-46
@@ -2,61 +2,23 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
APP_NAME="apskel-pos"
|
APP_NAME="apskel-pos"
|
||||||
|
PORT="4000"
|
||||||
# ─── Deteksi environment dari branch aktif ───────────────────────────────────
|
|
||||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
|
||||||
|
|
||||||
case "$CURRENT_BRANCH" in
|
|
||||||
main)
|
|
||||||
ENV_MODE="production"
|
|
||||||
PORT="4000"
|
|
||||||
;;
|
|
||||||
staging)
|
|
||||||
ENV_MODE="staging"
|
|
||||||
PORT="4001"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "❌ Branch '$CURRENT_BRANCH' tidak dikenali untuk deployment."
|
|
||||||
echo " Gunakan branch 'main' (production) atau 'staging' (staging)."
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
CONTAINER_NAME="$APP_NAME"
|
|
||||||
IMAGE_NAME="$APP_NAME:$ENV_MODE"
|
|
||||||
|
|
||||||
echo "📦 Environment : $ENV_MODE"
|
|
||||||
echo "🌿 Branch : $CURRENT_BRANCH"
|
|
||||||
echo "🐳 Container : $CONTAINER_NAME"
|
|
||||||
echo "🔌 Port : $PORT"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ─── Pastikan config file ada ─────────────────────────────────────────────────
|
|
||||||
CONFIG_FILE="infra/$ENV_MODE.yaml"
|
|
||||||
if [ ! -f "$CONFIG_FILE" ]; then
|
|
||||||
echo "❌ Config file '$CONFIG_FILE' tidak ditemukan."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "🔄 Pulling latest code..."
|
echo "🔄 Pulling latest code..."
|
||||||
git pull
|
git pull
|
||||||
|
|
||||||
echo "🐳 Building Docker image ($ENV_MODE)..."
|
echo "🐳 Building Docker image (production target)..."
|
||||||
docker build --target production -t "$IMAGE_NAME" .
|
docker build --target production -t $APP_NAME:latest .
|
||||||
|
|
||||||
echo "🛑 Stopping and removing old container..."
|
echo "🛑 Stopping and removing old container..."
|
||||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
docker rm -f $APP_NAME 2>/dev/null || true
|
||||||
|
|
||||||
echo "🚀 Running new container..."
|
echo "🚀 Running new container..."
|
||||||
docker run -d --name "$CONTAINER_NAME" \
|
docker run -d --name $APP_NAME \
|
||||||
-p "$PORT:4000" \
|
-p $PORT:$PORT \
|
||||||
-e TZ=Asia/Jakarta \
|
-e TZ=Asia/Jakarta \
|
||||||
-e ENV_MODE="$ENV_MODE" \
|
|
||||||
-v "$(pwd)/infra":/infra:ro \
|
-v "$(pwd)/infra":/infra:ro \
|
||||||
-v "$(pwd)/templates":/templates:ro \
|
-v "$(pwd)/templates":/templates:ro \
|
||||||
"$IMAGE_NAME"
|
$APP_NAME:latest
|
||||||
|
|
||||||
echo ""
|
echo "✅ Deployment complete."
|
||||||
echo "✅ Deployment $ENV_MODE complete."
|
|
||||||
echo " Container : $CONTAINER_NAME"
|
|
||||||
echo " Port : $PORT"
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
server:
|
server:
|
||||||
base-url:
|
base-url:
|
||||||
local-url:
|
local-url:
|
||||||
self-order-url:
|
self-order-url: http://localhost:5173
|
||||||
port: 4000
|
port: 4000
|
||||||
|
|
||||||
jwt:
|
jwt:
|
||||||
@@ -9,7 +9,7 @@ jwt:
|
|||||||
expires-ttl: 144000
|
expires-ttl: 144000
|
||||||
secret: "5Lm25V3Qd7aut8dr4QUxm5PZUrSFs"
|
secret: "5Lm25V3Qd7aut8dr4QUxm5PZUrSFs"
|
||||||
refresh_token:
|
refresh_token:
|
||||||
expires-ttl: 7776000
|
expires-ttl: 7776000 # 3 months in minutes (90 days * 24 hours * 60 minutes)
|
||||||
secret: "R3fr3sh_T0k3n_S3cr3t_K3y_2024_P0S"
|
secret: "R3fr3sh_T0k3n_S3cr3t_K3y_2024_P0S"
|
||||||
customer:
|
customer:
|
||||||
expires-ttl: 7776000
|
expires-ttl: 7776000
|
||||||
@@ -21,7 +21,7 @@ postgresql:
|
|||||||
driver: postgres
|
driver: postgres
|
||||||
db: apskel_pos
|
db: apskel_pos
|
||||||
username: apskel
|
username: apskel
|
||||||
password: "7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk"
|
password: '7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk'
|
||||||
ssl-mode: disable
|
ssl-mode: disable
|
||||||
max-idle-connections-in-second: 600
|
max-idle-connections-in-second: 600
|
||||||
max-open-connections-in-second: 600
|
max-open-connections-in-second: 600
|
||||||
@@ -45,11 +45,11 @@ s3:
|
|||||||
endpoint: sin1.contabostorage.com
|
endpoint: sin1.contabostorage.com
|
||||||
bucket_name: enaklo
|
bucket_name: enaklo
|
||||||
log_level: Error
|
log_level: Error
|
||||||
host_url: "https://sin1.contabostorage.com/fda98c2228f246f29a7e466b86b3b9e7:"
|
host_url: 'https://sin1.contabostorage.com/fda98c2228f246f29a7e466b86b3b9e7:'
|
||||||
|
|
||||||
log:
|
log:
|
||||||
log_format: "json"
|
log_format: 'json'
|
||||||
log_level: "info"
|
log_level: 'debug'
|
||||||
|
|
||||||
fonnte:
|
fonnte:
|
||||||
api_url: "https://api.fonnte.com/send"
|
api_url: "https://api.fonnte.com/send"
|
||||||
@@ -58,4 +58,4 @@ fonnte:
|
|||||||
|
|
||||||
fcm:
|
fcm:
|
||||||
credentials_file: "infra/firebase-service-account.json"
|
credentials_file: "infra/firebase-service-account.json"
|
||||||
project_id: "apskel-pos-v2"
|
project_id: "apskel-pos-v2"
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
server:
|
|
||||||
base-url:
|
|
||||||
local-url:
|
|
||||||
self-order-url:
|
|
||||||
port: 4000
|
|
||||||
|
|
||||||
jwt:
|
|
||||||
token:
|
|
||||||
expires-ttl: 144000
|
|
||||||
secret: "eZ7LAZJuSOGSHxb1ZYaZCkrBo5YBvc"
|
|
||||||
refresh_token:
|
|
||||||
expires-ttl: 7776000
|
|
||||||
secret: "EMx2DKPtMp0jQNpLvzzCsZkoUHe0d9"
|
|
||||||
customer:
|
|
||||||
expires-ttl: 7776000
|
|
||||||
secret: "layCV2rne0X57acWzSS3NxENmYJs7B"
|
|
||||||
|
|
||||||
postgresql:
|
|
||||||
host: 62.72.45.250
|
|
||||||
port: 5433
|
|
||||||
driver: postgres
|
|
||||||
db: apskel_pos_staging
|
|
||||||
username: apskel
|
|
||||||
password: "7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk"
|
|
||||||
ssl-mode: disable
|
|
||||||
max-idle-connections-in-second: 600
|
|
||||||
max-open-connections-in-second: 600
|
|
||||||
connection-max-life-time-in-second: 600
|
|
||||||
debug: false
|
|
||||||
|
|
||||||
redis:
|
|
||||||
host: 62.72.45.250
|
|
||||||
port: 6380
|
|
||||||
password: "CmICdmnX1EZPhVBYzQPEGw==U"
|
|
||||||
db: 1
|
|
||||||
dial_timeout: 5s
|
|
||||||
read_timeout: 3s
|
|
||||||
write_timeout: 3s
|
|
||||||
pool_size: 10
|
|
||||||
min_idle_connections: 5
|
|
||||||
|
|
||||||
s3:
|
|
||||||
access_key_id: cf9a475e18bc7626cbdbf09709d82a64
|
|
||||||
access_key_secret: 91f3321294d3e23035427a0ecb893ada
|
|
||||||
endpoint: sin1.contabostorage.com
|
|
||||||
bucket_name: enaklo
|
|
||||||
log_level: Error
|
|
||||||
host_url: "https://sin1.contabostorage.com/fda98c2228f246f29a7e466b86b3b9e7:"
|
|
||||||
|
|
||||||
log:
|
|
||||||
log_format: "json"
|
|
||||||
log_level: "info"
|
|
||||||
|
|
||||||
fonnte:
|
|
||||||
api_url: "https://api.fonnte.com/send"
|
|
||||||
token: "bADQrf9NTXfLZQCK2wGg"
|
|
||||||
timeout: 30
|
|
||||||
|
|
||||||
fcm:
|
|
||||||
credentials_file: "infra/firebase-service-account.json"
|
|
||||||
project_id: "apskel-pos-v2"
|
|
||||||
+1
-1
@@ -372,7 +372,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
|||||||
ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo),
|
ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo),
|
||||||
productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo),
|
productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo),
|
||||||
vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo),
|
vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo),
|
||||||
purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.purchaseCategoryRepo, repos.categoryRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo),
|
purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.purchaseCategoryRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo),
|
||||||
purchaseCategoryProcessor: processor.NewPurchaseCategoryProcessorImpl(repos.purchaseCategoryRepo),
|
purchaseCategoryProcessor: processor.NewPurchaseCategoryProcessorImpl(repos.purchaseCategoryRepo),
|
||||||
unitConverterProcessor: processor.NewIngredientUnitConverterProcessorImpl(repos.unitConverterRepo, repos.ingredientRepo, repos.unitRepo),
|
unitConverterProcessor: processor.NewIngredientUnitConverterProcessorImpl(repos.unitConverterRepo, repos.ingredientRepo, repos.unitRepo),
|
||||||
chartOfAccountTypeProcessor: processor.NewChartOfAccountTypeProcessorImpl(repos.chartOfAccountTypeRepo),
|
chartOfAccountTypeProcessor: processor.NewChartOfAccountTypeProcessorImpl(repos.chartOfAccountTypeRepo),
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package constants
|
|
||||||
|
|
||||||
// A purchase order is charged to a team. Teams come from the parent product
|
|
||||||
// categories, plus Pusat for spending that belongs to no single team.
|
|
||||||
const (
|
|
||||||
PurchaseTeamScopeCategory = "category"
|
|
||||||
PurchaseTeamScopeCentral = "central"
|
|
||||||
|
|
||||||
// PurchaseTeamCentralName is what Pusat is called in the picker. Pusat has no
|
|
||||||
// row of its own, so the name lives here rather than in the database.
|
|
||||||
PurchaseTeamCentralName = "Pusat"
|
|
||||||
|
|
||||||
// PurchaseTeamNone is the value the list filter takes to ask for purchases
|
|
||||||
// that have not been charged to any team yet.
|
|
||||||
PurchaseTeamNone = "none"
|
|
||||||
|
|
||||||
// PurchaseTeamNoneName labels those purchases in the reports.
|
|
||||||
PurchaseTeamNoneName = "Tanpa Team"
|
|
||||||
)
|
|
||||||
@@ -88,41 +88,23 @@ type SalesAnalyticsData struct {
|
|||||||
type PurchasingAnalyticsRequest struct {
|
type PurchasingAnalyticsRequest struct {
|
||||||
OrganizationID uuid.UUID
|
OrganizationID uuid.UUID
|
||||||
OutletID *string `form:"outlet_id,omitempty"`
|
OutletID *string `form:"outlet_id,omitempty"`
|
||||||
// Team narrows the report to one team: a parent category id, "central" for
|
DateFrom string `form:"date_from" validate:"required"`
|
||||||
// Pusat, or "none" for purchases charged to no team. Empty covers all teams.
|
DateTo string `form:"date_to" validate:"required"`
|
||||||
Team string `form:"team,omitempty"`
|
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month outlet_id"`
|
||||||
DateFrom string `form:"date_from" validate:"required"`
|
|
||||||
DateTo string `form:"date_to" validate:"required"`
|
|
||||||
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PurchasingAnalyticsResponse struct {
|
type PurchasingAnalyticsResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
OutletName *string `json:"outlet_name,omitempty"`
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
Team string `json:"team,omitempty"`
|
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
GroupBy string `json:"group_by"`
|
GroupBy string `json:"group_by"`
|
||||||
Summary PurchasingSummary `json:"summary"`
|
Summary PurchasingSummary `json:"summary"`
|
||||||
Data []PurchasingAnalyticsData `json:"data"`
|
Data []PurchasingAnalyticsData `json:"data"`
|
||||||
|
OutletData []PurchasingOutletData `json:"outlet_data,omitempty"`
|
||||||
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
||||||
VendorData []PurchasingVendorData `json:"vendor_data"`
|
VendorData []PurchasingVendorData `json:"vendor_data"`
|
||||||
TeamData []PurchasingTeamData `json:"team_data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PurchasingTeamData is one team's share of the purchases. Scope and CategoryID
|
|
||||||
// are exactly what the team filter takes, so a row doubles as a drill-down link.
|
|
||||||
type PurchasingTeamData struct {
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
CategoryID *uuid.UUID `json:"category_id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
TotalPurchases float64 `json:"total_purchases"`
|
|
||||||
RawMaterialPurchases float64 `json:"raw_material_purchases"`
|
|
||||||
ExpensePurchases float64 `json:"expense_purchases"`
|
|
||||||
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
|
||||||
Quantity float64 `json:"quantity"`
|
|
||||||
Percentage float64 `json:"percentage"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PurchasingSummary struct {
|
type PurchasingSummary struct {
|
||||||
@@ -136,7 +118,6 @@ type PurchasingSummary struct {
|
|||||||
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
|
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
|
||||||
TotalIngredients int64 `json:"total_ingredients"`
|
TotalIngredients int64 `json:"total_ingredients"`
|
||||||
TotalVendors int64 `json:"total_vendors"`
|
TotalVendors int64 `json:"total_vendors"`
|
||||||
TotalTeams int64 `json:"total_teams"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PurchasingAnalyticsData struct {
|
type PurchasingAnalyticsData struct {
|
||||||
@@ -152,6 +133,20 @@ type PurchasingAnalyticsData struct {
|
|||||||
Vendors int64 `json:"vendors"`
|
Vendors int64 `json:"vendors"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PurchasingOutletData struct {
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName string `json:"outlet_name"`
|
||||||
|
Purchases float64 `json:"purchases"`
|
||||||
|
RawMaterialPurchases float64 `json:"raw_material_purchases"`
|
||||||
|
ExpensePurchases float64 `json:"expense_purchases"`
|
||||||
|
PurchaseOrders int64 `json:"purchase_orders"`
|
||||||
|
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
|
||||||
|
ExpenseCount int64 `json:"expense_count"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Ingredients int64 `json:"ingredients"`
|
||||||
|
Vendors int64 `json:"vendors"`
|
||||||
|
}
|
||||||
|
|
||||||
type PurchasingIngredientData struct {
|
type PurchasingIngredientData struct {
|
||||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||||
IngredientName string `json:"ingredient_name"`
|
IngredientName string `json:"ingredient_name"`
|
||||||
@@ -239,135 +234,6 @@ type ProductAnalyticsPerCategoryData struct {
|
|||||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
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
|
// DashboardAnalyticsRequest represents the request for dashboard analytics
|
||||||
type DashboardAnalyticsRequest struct {
|
type DashboardAnalyticsRequest struct {
|
||||||
OrganizationID uuid.UUID
|
OrganizationID uuid.UUID
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ type CreateCategoryRequest struct {
|
|||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
BusinessType *string `json:"business_type,omitempty"`
|
BusinessType *string `json:"business_type,omitempty"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
|
||||||
Order *int `json:"order,omitempty"`
|
Order *int `json:"order,omitempty"`
|
||||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -21,7 +20,6 @@ type UpdateCategoryRequest struct {
|
|||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
BusinessType *string `json:"business_type,omitempty"`
|
BusinessType *string `json:"business_type,omitempty"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
|
||||||
Order *int `json:"order,omitempty"`
|
Order *int `json:"order,omitempty"`
|
||||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -29,8 +27,6 @@ type UpdateCategoryRequest struct {
|
|||||||
type ListCategoriesRequest struct {
|
type ListCategoriesRequest struct {
|
||||||
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
|
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
|
||||||
OutletID *uuid.UUID `json:"outlet_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"`
|
BusinessType string `json:"business_type,omitempty"`
|
||||||
Search string `json:"search,omitempty"`
|
Search string `json:"search,omitempty"`
|
||||||
Page int `json:"page" validate:"required,min=1"`
|
Page int `json:"page" validate:"required,min=1"`
|
||||||
@@ -42,8 +38,6 @@ type CategoryResponse struct {
|
|||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id"`
|
OutletID *uuid.UUID `json:"outlet_id"`
|
||||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
|
||||||
ParentName *string `json:"parent_name,omitempty"`
|
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
BusinessType string `json:"business_type"`
|
BusinessType string `json:"business_type"`
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ type ListIngredientUnitConvertersResponse struct {
|
|||||||
type IngredientUnitsResponse struct {
|
type IngredientUnitsResponse struct {
|
||||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||||
IngredientName string `json:"ingredient_name"`
|
IngredientName string `json:"ingredient_name"`
|
||||||
BaseUnitID *uuid.UUID `json:"base_unit_id"`
|
BaseUnitID uuid.UUID `json:"base_unit_id"`
|
||||||
BaseUnitName string `json:"base_unit_name"`
|
BaseUnitName string `json:"base_unit_name"`
|
||||||
Units []*UnitResponse `json:"units"`
|
Units []*UnitResponse `json:"units"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ type ProductRecipeIngredientResponse struct {
|
|||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id"`
|
OutletID *uuid.UUID `json:"outlet_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
UnitID *uuid.UUID `json:"unit_id"`
|
UnitID uuid.UUID `json:"unit_id"`
|
||||||
Cost float64 `json:"cost"`
|
Cost float64 `json:"cost"`
|
||||||
Stock float64 `json:"stock"`
|
Stock float64 `json:"stock"`
|
||||||
IsSemiFinished bool `json:"is_semi_finished"`
|
IsSemiFinished bool `json:"is_semi_finished"`
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ type CreatePurchaseOrderRequest struct {
|
|||||||
Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"`
|
Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"`
|
||||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||||
Message *string `json:"message,omitempty" validate:"omitempty"`
|
Message *string `json:"message,omitempty" validate:"omitempty"`
|
||||||
TeamScope *string `json:"team_scope,omitempty" validate:"omitempty,oneof=category central"`
|
|
||||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"`
|
|
||||||
Items []CreatePurchaseOrderItemRequest `json:"items" validate:"required,min=1,dive"`
|
Items []CreatePurchaseOrderItemRequest `json:"items" validate:"required,min=1,dive"`
|
||||||
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -30,16 +28,13 @@ type CreatePurchaseOrderItemRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UpdatePurchaseOrderRequest struct {
|
type UpdatePurchaseOrderRequest struct {
|
||||||
VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"`
|
VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"`
|
||||||
PONumber *string `json:"po_number,omitempty" validate:"omitempty,min=1,max=50"`
|
PONumber *string `json:"po_number,omitempty" validate:"omitempty,min=1,max=50"`
|
||||||
TransactionDate *string `json:"transaction_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
|
TransactionDate *string `json:"transaction_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
|
||||||
DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
|
DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
|
||||||
Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"`
|
Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"`
|
||||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||||
Message *string `json:"message,omitempty" validate:"omitempty"`
|
Message *string `json:"message,omitempty" validate:"omitempty"`
|
||||||
// An empty string clears the team; omitting the field leaves it untouched.
|
|
||||||
TeamScope *string `json:"team_scope,omitempty" validate:"omitempty"`
|
|
||||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"`
|
|
||||||
Items []UpdatePurchaseOrderItemRequest `json:"items,omitempty" validate:"omitempty,dive"`
|
Items []UpdatePurchaseOrderItemRequest `json:"items,omitempty" validate:"omitempty,dive"`
|
||||||
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -66,29 +61,13 @@ type PurchaseOrderResponse struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Message *string `json:"message"`
|
Message *string `json:"message"`
|
||||||
TotalAmount float64 `json:"total_amount"`
|
TotalAmount float64 `json:"total_amount"`
|
||||||
TeamScope *string `json:"team_scope"`
|
|
||||||
TeamCategoryID *uuid.UUID `json:"team_category_id"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
Team *PurchaseTeamResponse `json:"team,omitempty"`
|
|
||||||
Vendor *VendorResponse `json:"vendor,omitempty"`
|
Vendor *VendorResponse `json:"vendor,omitempty"`
|
||||||
Items []PurchaseOrderItemResponse `json:"items,omitempty"`
|
Items []PurchaseOrderItemResponse `json:"items,omitempty"`
|
||||||
Attachments []PurchaseOrderAttachmentResponse `json:"attachments,omitempty"`
|
Attachments []PurchaseOrderAttachmentResponse `json:"attachments,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PurchaseTeamResponse is one entry of the team picker. Teams come from the parent
|
|
||||||
// product categories; Pusat is the extra entry that has no category behind it, so
|
|
||||||
// its CategoryID is null.
|
|
||||||
type PurchaseTeamResponse struct {
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
CategoryID *uuid.UUID `json:"category_id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ListPurchaseTeamsResponse struct {
|
|
||||||
Teams []PurchaseTeamResponse `json:"teams"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PurchaseOrderItemResponse struct {
|
type PurchaseOrderItemResponse struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
|
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
|
||||||
@@ -114,20 +93,13 @@ type PurchaseOrderAttachmentResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ListPurchaseOrdersRequest struct {
|
type ListPurchaseOrdersRequest struct {
|
||||||
Page int `json:"page" validate:"min=1"`
|
Page int `json:"page" validate:"min=1"`
|
||||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||||
Search string `json:"search,omitempty"`
|
Search string `json:"search,omitempty"`
|
||||||
Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||||
VendorID *uuid.UUID `json:"vendor_id,omitempty"`
|
VendorID *uuid.UUID `json:"vendor_id,omitempty"`
|
||||||
// Team is the single-value form of the two filters below, so the team picker
|
StartDate *time.Time `json:"start_date,omitempty"`
|
||||||
// can send back what it was given: a parent category id, "central" for Pusat,
|
EndDate *time.Time `json:"end_date,omitempty"`
|
||||||
// or "none" for purchases with no team yet. It replaces them rather than
|
|
||||||
// narrowing alongside them.
|
|
||||||
Team string `json:"team,omitempty"`
|
|
||||||
TeamScope string `json:"team_scope,omitempty" validate:"omitempty,oneof=category central"`
|
|
||||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"`
|
|
||||||
StartDate *time.Time `json:"start_date,omitempty"`
|
|
||||||
EndDate *time.Time `json:"end_date,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListPurchaseOrdersResponse struct {
|
type ListPurchaseOrdersResponse struct {
|
||||||
|
|||||||
@@ -27,37 +27,14 @@ type SalesAnalytics struct {
|
|||||||
NetSales float64 `json:"net_sales"`
|
NetSales float64 `json:"net_sales"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PurchaseTeamFilter narrows purchasing figures to a single team: a parent
|
|
||||||
// category, Pusat, or the purchases that carry no team at all. A nil filter
|
|
||||||
// leaves the figures spanning every team.
|
|
||||||
type PurchaseTeamFilter struct {
|
|
||||||
Scope string
|
|
||||||
CategoryID *uuid.UUID
|
|
||||||
}
|
|
||||||
|
|
||||||
// PurchasingAnalytics represents purchasing analytics data
|
// PurchasingAnalytics represents purchasing analytics data
|
||||||
type PurchasingAnalytics struct {
|
type PurchasingAnalytics struct {
|
||||||
OutletName *string `json:"outlet_name,omitempty"`
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
Summary PurchasingSummary `json:"summary"`
|
Summary PurchasingSummary `json:"summary"`
|
||||||
Data []PurchasingAnalyticsData `json:"data"`
|
Data []PurchasingAnalyticsData `json:"data"`
|
||||||
|
OutletData []PurchasingOutletData `json:"outlet_data,omitempty"`
|
||||||
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
||||||
VendorData []PurchasingVendorData `json:"vendor_data"`
|
VendorData []PurchasingVendorData `json:"vendor_data"`
|
||||||
TeamData []PurchasingTeamData `json:"team_data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PurchasingTeamData is one team's share of the purchases: a parent category,
|
|
||||||
// Pusat, or the purchases charged to no team at all. Scope and CategoryID are
|
|
||||||
// what the team filter takes back, so a row can be clicked straight through.
|
|
||||||
type PurchasingTeamData struct {
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
CategoryID *uuid.UUID `json:"category_id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
TotalPurchases float64 `json:"total_purchases"`
|
|
||||||
RawMaterialPurchases float64 `json:"raw_material_purchases"`
|
|
||||||
ExpensePurchases float64 `json:"expense_purchases"`
|
|
||||||
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
|
||||||
Quantity float64 `json:"quantity"`
|
|
||||||
Percentage float64 `json:"percentage"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PurchasingSummary struct {
|
type PurchasingSummary struct {
|
||||||
@@ -71,7 +48,6 @@ type PurchasingSummary struct {
|
|||||||
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
|
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
|
||||||
TotalIngredients int64 `json:"total_ingredients"`
|
TotalIngredients int64 `json:"total_ingredients"`
|
||||||
TotalVendors int64 `json:"total_vendors"`
|
TotalVendors int64 `json:"total_vendors"`
|
||||||
TotalTeams int64 `json:"total_teams"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PurchasingAnalyticsData struct {
|
type PurchasingAnalyticsData struct {
|
||||||
@@ -87,6 +63,20 @@ type PurchasingAnalyticsData struct {
|
|||||||
Vendors int64 `json:"vendors"`
|
Vendors int64 `json:"vendors"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PurchasingOutletData struct {
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName string `json:"outlet_name"`
|
||||||
|
Purchases float64 `json:"purchases"`
|
||||||
|
RawMaterialPurchases float64 `json:"raw_material_purchases"`
|
||||||
|
ExpensePurchases float64 `json:"expense_purchases"`
|
||||||
|
PurchaseOrders int64 `json:"purchase_orders"`
|
||||||
|
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
|
||||||
|
ExpenseCount int64 `json:"expense_count"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Ingredients int64 `json:"ingredients"`
|
||||||
|
Vendors int64 `json:"vendors"`
|
||||||
|
}
|
||||||
|
|
||||||
type PurchasingIngredientData struct {
|
type PurchasingIngredientData struct {
|
||||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||||
IngredientName string `json:"ingredient_name"`
|
IngredientName string `json:"ingredient_name"`
|
||||||
@@ -137,50 +127,17 @@ type ProductAnalyticsPerCategory struct {
|
|||||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
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
|
// DashboardOverview represents dashboard overview data
|
||||||
type DashboardOverview struct {
|
type DashboardOverview struct {
|
||||||
TotalSales float64 `json:"total_sales"`
|
TotalSales float64 `json:"total_sales"`
|
||||||
TotalOrders int64 `json:"total_orders"`
|
TotalOrders int64 `json:"total_orders"`
|
||||||
AverageOrderValue float64 `json:"average_order_value"`
|
AverageOrderValue float64 `json:"average_order_value"`
|
||||||
TotalCustomers int64 `json:"total_customers"`
|
TotalCustomers int64 `json:"total_customers"`
|
||||||
VoidedOrders int64 `json:"voided_orders"`
|
VoidedOrders int64 `json:"voided_orders"`
|
||||||
RefundedOrders int64 `json:"refunded_orders"`
|
RefundedOrders int64 `json:"refunded_orders"`
|
||||||
TotalItemSold int64 `json:"total_item_sold"`
|
TotalItemSold int64 `json:"total_item_sold"`
|
||||||
TotalLowStock int64 `json:"total_low_stock"`
|
TotalLowStock int64 `json:"total_low_stock"`
|
||||||
TotalProductActive int64 `json:"total_product_active"`
|
TotalProductActive int64 `json:"total_product_active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProfitLossAnalytics struct {
|
type ProfitLossAnalytics struct {
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ type Category struct {
|
|||||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
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"`
|
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
|
||||||
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"`
|
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"`
|
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
|
||||||
Description *string `gorm:"type:text" json:"description"`
|
Description *string `gorm:"type:text" json:"description"`
|
||||||
Order int `gorm:"default:0" json:"order"`
|
Order int `gorm:"default:0" json:"order"`
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ type Ingredient struct {
|
|||||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"`
|
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"`
|
||||||
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"`
|
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"`
|
||||||
Name string `gorm:"not null;size:255" json:"name"`
|
Name string `gorm:"not null;size:255" json:"name"`
|
||||||
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"`
|
UnitID uuid.UUID `gorm:"type:uuid;not null;index" json:"unit_id"`
|
||||||
Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost"`
|
Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost"`
|
||||||
Stock float64 `gorm:"type:decimal(10,2);default:0.00" json:"stock"`
|
Stock float64 `gorm:"type:decimal(10,2);default:0.00" json:"stock"`
|
||||||
IsSemiFinished bool `gorm:"default:false" json:"is_semi_finished"`
|
IsSemiFinished bool `gorm:"default:false" json:"is_semi_finished"`
|
||||||
|
|||||||
@@ -20,17 +20,12 @@ type PurchaseOrder struct {
|
|||||||
Status string `gorm:"not null;size:20;default:'draft'" json:"status" validate:"required,oneof=draft sent approved received cancelled"`
|
Status string `gorm:"not null;size:20;default:'draft'" json:"status" validate:"required,oneof=draft sent approved received cancelled"`
|
||||||
Message *string `gorm:"type:text" json:"message" validate:"omitempty"`
|
Message *string `gorm:"type:text" json:"message" validate:"omitempty"`
|
||||||
TotalAmount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"total_amount"`
|
TotalAmount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"total_amount"`
|
||||||
// TeamScope is 'category' when the purchase is charged to a parent category, or
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
// 'central' for Pusat. Nil means no team was chosen, which is not the same as Pusat.
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||||
TeamScope *string `gorm:"size:20;index" json:"team_scope" validate:"omitempty,oneof=category central"`
|
|
||||||
TeamCategoryID *uuid.UUID `gorm:"type:uuid;index" json:"team_category_id" validate:"omitempty"`
|
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
|
||||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
|
||||||
|
|
||||||
Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||||
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
|
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
|
||||||
Vendor *Vendor `gorm:"foreignKey:VendorID" json:"vendor,omitempty"`
|
Vendor *Vendor `gorm:"foreignKey:VendorID" json:"vendor,omitempty"`
|
||||||
TeamCategory *Category `gorm:"foreignKey:TeamCategoryID" json:"team_category,omitempty"`
|
|
||||||
Items []PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderID" json:"items,omitempty"`
|
Items []PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderID" json:"items,omitempty"`
|
||||||
Attachments []PurchaseOrderAttachment `gorm:"foreignKey:PurchaseOrderID" json:"attachments,omitempty"`
|
Attachments []PurchaseOrderAttachment `gorm:"foreignKey:PurchaseOrderID" json:"attachments,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,55 +157,6 @@ func (h *AnalyticsHandler) GetProductAnalyticsPerCategory(c *gin.Context) {
|
|||||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetProductAnalyticsPerCategory")
|
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) {
|
func (h *AnalyticsHandler) GetDashboardAnalytics(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
contextInfo := appcontext.FromGinContext(ctx)
|
contextInfo := appcontext.FromGinContext(ctx)
|
||||||
|
|||||||
@@ -191,18 +191,6 @@ func (h *CategoryHandler) ListCategories(c *gin.Context) {
|
|||||||
req.OutletID = &outletID
|
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)
|
validationError, validationErrorCode := h.categoryValidator.ValidateListCategoriesRequest(req)
|
||||||
if validationError != nil {
|
if validationError != nil {
|
||||||
logger.FromContext(ctx).WithError(validationError).Error("CategoryHandler::ListCategories -> request validation failed")
|
logger.FromContext(ctx).WithError(validationError).Error("CategoryHandler::ListCategories -> request validation failed")
|
||||||
|
|||||||
@@ -176,20 +176,6 @@ func (h *PurchaseOrderHandler) ListPurchaseOrders(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if team := c.Query("team"); team != "" {
|
|
||||||
req.Team = team
|
|
||||||
}
|
|
||||||
|
|
||||||
if teamScope := c.Query("team_scope"); teamScope != "" {
|
|
||||||
req.TeamScope = teamScope
|
|
||||||
}
|
|
||||||
|
|
||||||
if teamCategoryIDStr := c.Query("team_category_id"); teamCategoryIDStr != "" {
|
|
||||||
if teamCategoryID, err := uuid.Parse(teamCategoryIDStr); err == nil {
|
|
||||||
req.TeamCategoryID = &teamCategoryID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if startDateStr := c.Query("start_date"); startDateStr != "" {
|
if startDateStr := c.Query("start_date"); startDateStr != "" {
|
||||||
if startDate, err := time.Parse("2006-01-02", startDateStr); err == nil {
|
if startDate, err := time.Parse("2006-01-02", startDateStr); err == nil {
|
||||||
req.StartDate = &startDate
|
req.StartDate = &startDate
|
||||||
@@ -238,21 +224,6 @@ func (h *PurchaseOrderHandler) GetPurchaseOrdersByStatus(c *gin.Context) {
|
|||||||
util.HandleResponse(c.Writer, c.Request, poResponse, "PurchaseOrderHandler::GetPurchaseOrdersByStatus")
|
util.HandleResponse(c.Writer, c.Request, poResponse, "PurchaseOrderHandler::GetPurchaseOrdersByStatus")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListPurchaseTeams serves the team picker for the purchase form: the parent
|
|
||||||
// categories of the caller's outlet, plus Pusat.
|
|
||||||
func (h *PurchaseOrderHandler) ListPurchaseTeams(c *gin.Context) {
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
contextInfo := appcontext.FromGinContext(ctx)
|
|
||||||
|
|
||||||
teamsResponse := h.purchaseOrderService.ListPurchaseTeams(ctx, contextInfo)
|
|
||||||
if teamsResponse.HasErrors() {
|
|
||||||
errorResp := teamsResponse.GetErrors()[0]
|
|
||||||
logger.FromContext(ctx).WithError(errorResp).Error("PurchaseOrderHandler::ListPurchaseTeams -> Failed to list purchase teams from service")
|
|
||||||
}
|
|
||||||
|
|
||||||
util.HandleResponse(c.Writer, c.Request, teamsResponse, "PurchaseOrderHandler::ListPurchaseTeams")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *PurchaseOrderHandler) GetOverduePurchaseOrders(c *gin.Context) {
|
func (h *PurchaseOrderHandler) GetOverduePurchaseOrders(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
contextInfo := appcontext.FromGinContext(ctx)
|
contextInfo := appcontext.FromGinContext(ctx)
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ func CreateCategoryRequestToEntity(req *models.CreateCategoryRequest) *entities.
|
|||||||
return &entities.Category{
|
return &entities.Category{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
ParentID: req.ParentID,
|
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
Order: req.Order,
|
Order: req.Order,
|
||||||
@@ -86,19 +85,10 @@ 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{
|
return &models.CategoryResponse{
|
||||||
ID: entity.ID,
|
ID: entity.ID,
|
||||||
OrganizationID: entity.OrganizationID,
|
OrganizationID: entity.OrganizationID,
|
||||||
OutletID: entity.OutletID,
|
OutletID: entity.OutletID,
|
||||||
ParentID: entity.ParentID,
|
|
||||||
ParentName: parentName,
|
|
||||||
Name: entity.Name,
|
Name: entity.Name,
|
||||||
Description: entity.Description,
|
Description: entity.Description,
|
||||||
ImageURL: imageURL,
|
ImageURL: imageURL,
|
||||||
@@ -137,10 +127,6 @@ func UpdateCategoryEntityFromRequest(entity *entities.Category, req *models.Upda
|
|||||||
if req.OutletID != nil {
|
if req.OutletID != nil {
|
||||||
entity.OutletID = req.OutletID
|
entity.OutletID = req.OutletID
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.ParentID != nil {
|
|
||||||
entity.ParentID = req.ParentID
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func CategoryEntitiesToModels(entities []*entities.Category) []*models.Category {
|
func CategoryEntitiesToModels(entities []*entities.Category) []*models.Category {
|
||||||
|
|||||||
@@ -1,33 +1,10 @@
|
|||||||
package mappers
|
package mappers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"apskel-pos-be/internal/constants"
|
|
||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// purchaseTeamFromEntity renders the team a purchase order is charged to. It returns
|
|
||||||
// nil when no team was chosen, which is distinct from a purchase charged to Pusat.
|
|
||||||
// The category name is only filled in when TeamCategory was preloaded.
|
|
||||||
func purchaseTeamFromEntity(entity *entities.PurchaseOrder) *models.PurchaseTeam {
|
|
||||||
if entity.TeamScope == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
team := &models.PurchaseTeam{Scope: *entity.TeamScope}
|
|
||||||
switch *entity.TeamScope {
|
|
||||||
case constants.PurchaseTeamScopeCentral:
|
|
||||||
team.Name = constants.PurchaseTeamCentralName
|
|
||||||
case constants.PurchaseTeamScopeCategory:
|
|
||||||
team.CategoryID = entity.TeamCategoryID
|
|
||||||
if entity.TeamCategory != nil {
|
|
||||||
team.Name = entity.TeamCategory.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return team
|
|
||||||
}
|
|
||||||
|
|
||||||
func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.PurchaseOrder {
|
func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.PurchaseOrder {
|
||||||
if entity == nil {
|
if entity == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -45,8 +22,6 @@ func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.Purchase
|
|||||||
Status: entity.Status,
|
Status: entity.Status,
|
||||||
Message: entity.Message,
|
Message: entity.Message,
|
||||||
TotalAmount: entity.TotalAmount,
|
TotalAmount: entity.TotalAmount,
|
||||||
TeamScope: entity.TeamScope,
|
|
||||||
TeamCategoryID: entity.TeamCategoryID,
|
|
||||||
CreatedAt: entity.CreatedAt,
|
CreatedAt: entity.CreatedAt,
|
||||||
UpdatedAt: entity.UpdatedAt,
|
UpdatedAt: entity.UpdatedAt,
|
||||||
}
|
}
|
||||||
@@ -69,8 +44,6 @@ func PurchaseOrderModelToEntity(model *models.PurchaseOrder) *entities.PurchaseO
|
|||||||
Status: model.Status,
|
Status: model.Status,
|
||||||
Message: model.Message,
|
Message: model.Message,
|
||||||
TotalAmount: model.TotalAmount,
|
TotalAmount: model.TotalAmount,
|
||||||
TeamScope: model.TeamScope,
|
|
||||||
TeamCategoryID: model.TeamCategoryID,
|
|
||||||
CreatedAt: model.CreatedAt,
|
CreatedAt: model.CreatedAt,
|
||||||
UpdatedAt: model.UpdatedAt,
|
UpdatedAt: model.UpdatedAt,
|
||||||
}
|
}
|
||||||
@@ -93,11 +66,8 @@ func PurchaseOrderEntityToResponse(entity *entities.PurchaseOrder) *models.Purch
|
|||||||
Status: entity.Status,
|
Status: entity.Status,
|
||||||
Message: entity.Message,
|
Message: entity.Message,
|
||||||
TotalAmount: entity.TotalAmount,
|
TotalAmount: entity.TotalAmount,
|
||||||
TeamScope: entity.TeamScope,
|
|
||||||
TeamCategoryID: entity.TeamCategoryID,
|
|
||||||
CreatedAt: entity.CreatedAt,
|
CreatedAt: entity.CreatedAt,
|
||||||
UpdatedAt: entity.UpdatedAt,
|
UpdatedAt: entity.UpdatedAt,
|
||||||
Team: purchaseTeamFromEntity(entity),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map vendor if present
|
// Map vendor if present
|
||||||
|
|||||||
+24
-183
@@ -1,12 +1,8 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"apskel-pos-be/internal/constants"
|
|
||||||
"apskel-pos-be/internal/entities"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -97,34 +93,9 @@ type SalesAnalyticsData struct {
|
|||||||
type PurchasingAnalyticsRequest struct {
|
type PurchasingAnalyticsRequest struct {
|
||||||
OrganizationID uuid.UUID `validate:"required"`
|
OrganizationID uuid.UUID `validate:"required"`
|
||||||
OutletID *uuid.UUID `validate:"omitempty"`
|
OutletID *uuid.UUID `validate:"omitempty"`
|
||||||
// Team is the raw value the team picker sends: a parent category id,
|
DateFrom time.Time `validate:"required"`
|
||||||
// "central" for Pusat, "none" for purchases with no team, or empty for all.
|
DateTo time.Time `validate:"required"`
|
||||||
Team string
|
GroupBy string `validate:"omitempty,oneof=day hour week month outlet_id"`
|
||||||
DateFrom time.Time `validate:"required"`
|
|
||||||
DateTo time.Time `validate:"required"`
|
|
||||||
GroupBy string `validate:"omitempty,oneof=day hour week month"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParsePurchaseTeamFilter turns the team value the picker sends into the scope and
|
|
||||||
// category the purchasing queries filter on. An empty value spans every team; an
|
|
||||||
// unknown one is an error rather than a report that quietly ignores the filter.
|
|
||||||
func ParsePurchaseTeamFilter(team string) (*entities.PurchaseTeamFilter, error) {
|
|
||||||
switch team {
|
|
||||||
case "":
|
|
||||||
return nil, nil
|
|
||||||
case constants.PurchaseTeamScopeCentral, constants.PurchaseTeamNone:
|
|
||||||
return &entities.PurchaseTeamFilter{Scope: team}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
categoryID, err := uuid.Parse(team)
|
|
||||||
if err != nil || categoryID == uuid.Nil {
|
|
||||||
return nil, fmt.Errorf("team must be one of: central, none, or a category id")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &entities.PurchaseTeamFilter{
|
|
||||||
Scope: constants.PurchaseTeamScopeCategory,
|
|
||||||
CategoryID: &categoryID,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PurchasingAnalyticsResponse represents the response for purchasing analytics
|
// PurchasingAnalyticsResponse represents the response for purchasing analytics
|
||||||
@@ -132,28 +103,14 @@ type PurchasingAnalyticsResponse struct {
|
|||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
OutletName *string `json:"outlet_name,omitempty"`
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
Team string `json:"team,omitempty"`
|
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
GroupBy string `json:"group_by"`
|
GroupBy string `json:"group_by"`
|
||||||
Summary PurchasingSummary `json:"summary"`
|
Summary PurchasingSummary `json:"summary"`
|
||||||
Data []PurchasingAnalyticsData `json:"data"`
|
Data []PurchasingAnalyticsData `json:"data"`
|
||||||
|
OutletData []PurchasingOutletData `json:"outlet_data,omitempty"`
|
||||||
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
||||||
VendorData []PurchasingVendorData `json:"vendor_data"`
|
VendorData []PurchasingVendorData `json:"vendor_data"`
|
||||||
TeamData []PurchasingTeamData `json:"team_data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PurchasingTeamData represents purchasing analytics for a single team
|
|
||||||
type PurchasingTeamData struct {
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
CategoryID *uuid.UUID `json:"category_id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
TotalPurchases float64 `json:"total_purchases"`
|
|
||||||
RawMaterialPurchases float64 `json:"raw_material_purchases"`
|
|
||||||
ExpensePurchases float64 `json:"expense_purchases"`
|
|
||||||
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
|
||||||
Quantity float64 `json:"quantity"`
|
|
||||||
Percentage float64 `json:"percentage"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PurchasingSummary represents the summary of purchasing analytics
|
// PurchasingSummary represents the summary of purchasing analytics
|
||||||
@@ -168,7 +125,6 @@ type PurchasingSummary struct {
|
|||||||
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
|
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
|
||||||
TotalIngredients int64 `json:"total_ingredients"`
|
TotalIngredients int64 `json:"total_ingredients"`
|
||||||
TotalVendors int64 `json:"total_vendors"`
|
TotalVendors int64 `json:"total_vendors"`
|
||||||
TotalTeams int64 `json:"total_teams"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PurchasingAnalyticsData represents purchasing analytics by time period
|
// PurchasingAnalyticsData represents purchasing analytics by time period
|
||||||
@@ -185,6 +141,20 @@ type PurchasingAnalyticsData struct {
|
|||||||
Vendors int64 `json:"vendors"`
|
Vendors int64 `json:"vendors"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PurchasingOutletData struct {
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName string `json:"outlet_name"`
|
||||||
|
Purchases float64 `json:"purchases"`
|
||||||
|
RawMaterialPurchases float64 `json:"raw_material_purchases"`
|
||||||
|
ExpensePurchases float64 `json:"expense_purchases"`
|
||||||
|
PurchaseOrders int64 `json:"purchase_orders"`
|
||||||
|
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
|
||||||
|
ExpenseCount int64 `json:"expense_count"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Ingredients int64 `json:"ingredients"`
|
||||||
|
Vendors int64 `json:"vendors"`
|
||||||
|
}
|
||||||
|
|
||||||
// PurchasingIngredientData represents purchasing analytics for an ingredient
|
// PurchasingIngredientData represents purchasing analytics for an ingredient
|
||||||
type PurchasingIngredientData struct {
|
type PurchasingIngredientData struct {
|
||||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||||
@@ -274,135 +244,6 @@ type ProductAnalyticsPerCategoryData struct {
|
|||||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
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
|
// DashboardAnalyticsRequest represents the request for dashboard analytics
|
||||||
type DashboardAnalyticsRequest struct {
|
type DashboardAnalyticsRequest struct {
|
||||||
OrganizationID uuid.UUID `validate:"required"`
|
OrganizationID uuid.UUID `validate:"required"`
|
||||||
@@ -462,12 +303,12 @@ type ProfitLossAnalyticsResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProfitLossPurchasing struct {
|
type ProfitLossPurchasing struct {
|
||||||
TodayTotal float64 `json:"today_total"`
|
TodayTotal float64 `json:"today_total"`
|
||||||
MtdTotal float64 `json:"mtd_total"`
|
MtdTotal float64 `json:"mtd_total"`
|
||||||
TodayRawMaterial float64 `json:"today_raw_material"`
|
TodayRawMaterial float64 `json:"today_raw_material"`
|
||||||
MtdRawMaterial float64 `json:"mtd_raw_material"`
|
MtdRawMaterial float64 `json:"mtd_raw_material"`
|
||||||
TodayExpense float64 `json:"today_expense"`
|
TodayExpense float64 `json:"today_expense"`
|
||||||
MtdExpense float64 `json:"mtd_expense"`
|
MtdExpense float64 `json:"mtd_expense"`
|
||||||
Items []ProfitLossPurchasingItem `json:"items"`
|
Items []ProfitLossPurchasingItem `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ type Category struct {
|
|||||||
type CreateCategoryRequest struct {
|
type CreateCategoryRequest struct {
|
||||||
OrganizationID uuid.UUID `validate:"required"`
|
OrganizationID uuid.UUID `validate:"required"`
|
||||||
OutletID *uuid.UUID
|
OutletID *uuid.UUID
|
||||||
ParentID *uuid.UUID
|
|
||||||
Name string `validate:"required,min=1,max=255"`
|
Name string `validate:"required,min=1,max=255"`
|
||||||
Description *string `validate:"omitempty,max=1000"`
|
Description *string `validate:"omitempty,max=1000"`
|
||||||
ImageURL *string `validate:"omitempty,url"`
|
ImageURL *string `validate:"omitempty,url"`
|
||||||
@@ -34,7 +33,6 @@ type UpdateCategoryRequest struct {
|
|||||||
Description *string `validate:"omitempty,max=1000"`
|
Description *string `validate:"omitempty,max=1000"`
|
||||||
ImageURL *string `validate:"omitempty,url"`
|
ImageURL *string `validate:"omitempty,url"`
|
||||||
OutletID *uuid.UUID
|
OutletID *uuid.UUID
|
||||||
ParentID *uuid.UUID
|
|
||||||
Order *int `validate:"omitempty,min=0"`
|
Order *int `validate:"omitempty,min=0"`
|
||||||
IsActive *bool
|
IsActive *bool
|
||||||
}
|
}
|
||||||
@@ -43,8 +41,6 @@ type CategoryResponse struct {
|
|||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
OrganizationID uuid.UUID
|
OrganizationID uuid.UUID
|
||||||
OutletID *uuid.UUID
|
OutletID *uuid.UUID
|
||||||
ParentID *uuid.UUID
|
|
||||||
ParentName *string
|
|
||||||
Name string
|
Name string
|
||||||
Description *string
|
Description *string
|
||||||
ImageURL *string
|
ImageURL *string
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type Ingredient struct {
|
|||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id"`
|
OutletID *uuid.UUID `json:"outlet_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
UnitID *uuid.UUID `json:"unit_id"`
|
UnitID uuid.UUID `json:"unit_id"`
|
||||||
Cost float64 `json:"cost"`
|
Cost float64 `json:"cost"`
|
||||||
Stock float64 `json:"stock"`
|
Stock float64 `json:"stock"`
|
||||||
IsSemiFinished bool `json:"is_semi_finished"`
|
IsSemiFinished bool `json:"is_semi_finished"`
|
||||||
@@ -29,7 +29,7 @@ type CreateIngredientRequest struct {
|
|||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id"`
|
OutletID *uuid.UUID `json:"outlet_id"`
|
||||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||||
UnitID *uuid.UUID `json:"unit_id" validate:"omitempty"`
|
UnitID uuid.UUID `json:"unit_id" validate:"required"`
|
||||||
Cost float64 `json:"cost" validate:"min=0"`
|
Cost float64 `json:"cost" validate:"min=0"`
|
||||||
Stock float64 `json:"stock" validate:"min=0"`
|
Stock float64 `json:"stock" validate:"min=0"`
|
||||||
IsSemiFinished bool `json:"is_semi_finished"`
|
IsSemiFinished bool `json:"is_semi_finished"`
|
||||||
@@ -48,7 +48,7 @@ type CompositionItemRequest struct {
|
|||||||
type UpdateIngredientRequest struct {
|
type UpdateIngredientRequest struct {
|
||||||
OutletID *uuid.UUID `json:"outlet_id"`
|
OutletID *uuid.UUID `json:"outlet_id"`
|
||||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||||
UnitID *uuid.UUID `json:"unit_id" validate:"omitempty"`
|
UnitID uuid.UUID `json:"unit_id" validate:"required"`
|
||||||
Cost float64 `json:"cost" validate:"min=0"`
|
Cost float64 `json:"cost" validate:"min=0"`
|
||||||
Stock float64 `json:"stock" validate:"min=0"`
|
Stock float64 `json:"stock" validate:"min=0"`
|
||||||
IsSemiFinished bool `json:"is_semi_finished"`
|
IsSemiFinished bool `json:"is_semi_finished"`
|
||||||
@@ -61,7 +61,7 @@ type IngredientResponse struct {
|
|||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id"`
|
OutletID *uuid.UUID `json:"outlet_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
UnitID *uuid.UUID `json:"unit_id"`
|
UnitID uuid.UUID `json:"unit_id"`
|
||||||
Cost float64 `json:"cost"`
|
Cost float64 `json:"cost"`
|
||||||
Stock float64 `json:"stock"`
|
Stock float64 `json:"stock"`
|
||||||
IsSemiFinished bool `json:"is_semi_finished"`
|
IsSemiFinished bool `json:"is_semi_finished"`
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ type ListIngredientUnitConvertersResponse struct {
|
|||||||
type IngredientUnitsResponse struct {
|
type IngredientUnitsResponse struct {
|
||||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||||
IngredientName string `json:"ingredient_name"`
|
IngredientName string `json:"ingredient_name"`
|
||||||
BaseUnitID *uuid.UUID `json:"base_unit_id"`
|
BaseUnitID uuid.UUID `json:"base_unit_id"`
|
||||||
BaseUnitName string `json:"base_unit_name"`
|
BaseUnitName string `json:"base_unit_name"`
|
||||||
Units []*UnitResponse `json:"units"`
|
Units []*UnitResponse `json:"units"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,20 +18,10 @@ type PurchaseOrder struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Message *string `json:"message"`
|
Message *string `json:"message"`
|
||||||
TotalAmount float64 `json:"total_amount"`
|
TotalAmount float64 `json:"total_amount"`
|
||||||
TeamScope *string `json:"team_scope"`
|
|
||||||
TeamCategoryID *uuid.UUID `json:"team_category_id"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PurchaseTeam is one entry of the team picker: either a parent category or Pusat.
|
|
||||||
// Pusat carries no CategoryID because it has no category of its own.
|
|
||||||
type PurchaseTeam struct {
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
CategoryID *uuid.UUID `json:"category_id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PurchaseOrderItem struct {
|
type PurchaseOrderItem struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
|
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
|
||||||
@@ -64,11 +54,8 @@ type PurchaseOrderResponse struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Message *string `json:"message"`
|
Message *string `json:"message"`
|
||||||
TotalAmount float64 `json:"total_amount"`
|
TotalAmount float64 `json:"total_amount"`
|
||||||
TeamScope *string `json:"team_scope"`
|
|
||||||
TeamCategoryID *uuid.UUID `json:"team_category_id"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
Team *PurchaseTeam `json:"team,omitempty"`
|
|
||||||
Vendor *VendorResponse `json:"vendor,omitempty"`
|
Vendor *VendorResponse `json:"vendor,omitempty"`
|
||||||
Items []PurchaseOrderItemResponse `json:"items,omitempty"`
|
Items []PurchaseOrderItemResponse `json:"items,omitempty"`
|
||||||
Attachments []PurchaseOrderAttachmentResponse `json:"attachments,omitempty"`
|
Attachments []PurchaseOrderAttachmentResponse `json:"attachments,omitempty"`
|
||||||
@@ -107,8 +94,6 @@ type CreatePurchaseOrderRequest struct {
|
|||||||
Reference *string `json:"reference,omitempty"`
|
Reference *string `json:"reference,omitempty"`
|
||||||
Status *string `json:"status,omitempty"`
|
Status *string `json:"status,omitempty"`
|
||||||
Message *string `json:"message,omitempty"`
|
Message *string `json:"message,omitempty"`
|
||||||
TeamScope *string `json:"team_scope,omitempty"`
|
|
||||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"`
|
|
||||||
Items []CreatePurchaseOrderItemRequest `json:"items"`
|
Items []CreatePurchaseOrderItemRequest `json:"items"`
|
||||||
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -130,8 +115,6 @@ type UpdatePurchaseOrderRequest struct {
|
|||||||
Reference *string `json:"reference,omitempty"`
|
Reference *string `json:"reference,omitempty"`
|
||||||
Status *string `json:"status,omitempty"`
|
Status *string `json:"status,omitempty"`
|
||||||
Message *string `json:"message,omitempty"`
|
Message *string `json:"message,omitempty"`
|
||||||
TeamScope *string `json:"team_scope,omitempty"`
|
|
||||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"`
|
|
||||||
Items []UpdatePurchaseOrderItemRequest `json:"items,omitempty"`
|
Items []UpdatePurchaseOrderItemRequest `json:"items,omitempty"`
|
||||||
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -147,20 +130,13 @@ type UpdatePurchaseOrderItemRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ListPurchaseOrdersRequest struct {
|
type ListPurchaseOrdersRequest struct {
|
||||||
Page int `json:"page" validate:"min=1"`
|
Page int `json:"page" validate:"min=1"`
|
||||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||||
Search string `json:"search,omitempty"`
|
Search string `json:"search,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
VendorID *uuid.UUID `json:"vendor_id,omitempty"`
|
VendorID *uuid.UUID `json:"vendor_id,omitempty"`
|
||||||
Team string `json:"team,omitempty"`
|
StartDate *time.Time `json:"start_date,omitempty"`
|
||||||
TeamScope string `json:"team_scope,omitempty"`
|
EndDate *time.Time `json:"end_date,omitempty"`
|
||||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"`
|
|
||||||
StartDate *time.Time `json:"start_date,omitempty"`
|
|
||||||
EndDate *time.Time `json:"end_date,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ListPurchaseTeamsResponse struct {
|
|
||||||
Teams []PurchaseTeam `json:"teams"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListPurchaseOrdersResponse struct {
|
type ListPurchaseOrdersResponse struct {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"apskel-pos-be/internal/constants"
|
|
||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
"apskel-pos-be/internal/repository"
|
"apskel-pos-be/internal/repository"
|
||||||
@@ -20,8 +19,6 @@ type AnalyticsProcessor interface {
|
|||||||
GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error)
|
GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error)
|
||||||
GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error)
|
GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error)
|
||||||
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, 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)
|
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
|
||||||
GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error)
|
GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error)
|
||||||
GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error)
|
GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error)
|
||||||
@@ -200,12 +197,7 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
|
|||||||
req.GroupBy = "day"
|
req.GroupBy = "day"
|
||||||
}
|
}
|
||||||
|
|
||||||
teamFilter, err := models.ParsePurchaseTeamFilter(req.Team)
|
result, err := p.analyticsRepo.GetPurchasingAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := p.analyticsRepo.GetPurchasingAnalytics(ctx, req.OrganizationID, req.OutletID, teamFilter, req.DateFrom, req.DateTo, req.GroupBy)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get purchasing analytics: %w", err)
|
return nil, fmt.Errorf("failed to get purchasing analytics: %w", err)
|
||||||
}
|
}
|
||||||
@@ -226,6 +218,23 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
outletData := make([]models.PurchasingOutletData, len(result.OutletData))
|
||||||
|
for i, item := range result.OutletData {
|
||||||
|
outletData[i] = models.PurchasingOutletData{
|
||||||
|
OutletID: item.OutletID,
|
||||||
|
OutletName: item.OutletName,
|
||||||
|
Purchases: item.Purchases,
|
||||||
|
RawMaterialPurchases: item.RawMaterialPurchases,
|
||||||
|
ExpensePurchases: item.ExpensePurchases,
|
||||||
|
PurchaseOrders: item.PurchaseOrders,
|
||||||
|
RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders,
|
||||||
|
ExpenseCount: item.ExpenseCount,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
Ingredients: item.Ingredients,
|
||||||
|
Vendors: item.Vendors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ingredientData := make([]models.PurchasingIngredientData, len(result.IngredientData))
|
ingredientData := make([]models.PurchasingIngredientData, len(result.IngredientData))
|
||||||
for i, item := range result.IngredientData {
|
for i, item := range result.IngredientData {
|
||||||
ingredientData[i] = models.PurchasingIngredientData{
|
ingredientData[i] = models.PurchasingIngredientData{
|
||||||
@@ -250,26 +259,10 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
teamData := make([]models.PurchasingTeamData, len(result.TeamData))
|
|
||||||
for i, item := range result.TeamData {
|
|
||||||
teamData[i] = models.PurchasingTeamData{
|
|
||||||
Scope: item.Scope,
|
|
||||||
CategoryID: item.CategoryID,
|
|
||||||
Name: item.Name,
|
|
||||||
TotalPurchases: item.TotalPurchases,
|
|
||||||
RawMaterialPurchases: item.RawMaterialPurchases,
|
|
||||||
ExpensePurchases: item.ExpensePurchases,
|
|
||||||
PurchaseOrderCount: item.PurchaseOrderCount,
|
|
||||||
Quantity: item.Quantity,
|
|
||||||
Percentage: item.Percentage,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &models.PurchasingAnalyticsResponse{
|
return &models.PurchasingAnalyticsResponse{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
OutletName: result.OutletName,
|
OutletName: result.OutletName,
|
||||||
Team: req.Team,
|
|
||||||
DateFrom: req.DateFrom,
|
DateFrom: req.DateFrom,
|
||||||
DateTo: req.DateTo,
|
DateTo: req.DateTo,
|
||||||
GroupBy: req.GroupBy,
|
GroupBy: req.GroupBy,
|
||||||
@@ -284,12 +277,11 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
|
|||||||
AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue,
|
AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue,
|
||||||
TotalIngredients: result.Summary.TotalIngredients,
|
TotalIngredients: result.Summary.TotalIngredients,
|
||||||
TotalVendors: result.Summary.TotalVendors,
|
TotalVendors: result.Summary.TotalVendors,
|
||||||
TotalTeams: result.Summary.TotalTeams,
|
|
||||||
},
|
},
|
||||||
Data: data,
|
Data: data,
|
||||||
|
OutletData: outletData,
|
||||||
IngredientData: ingredientData,
|
IngredientData: ingredientData,
|
||||||
VendorData: vendorData,
|
VendorData: vendorData,
|
||||||
TeamData: teamData,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,242 +374,6 @@ func (p *AnalyticsProcessorImpl) GetProductAnalyticsPerCategory(ctx context.Cont
|
|||||||
}, nil
|
}, 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) {
|
func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) {
|
||||||
// Validate date range
|
// Validate date range
|
||||||
if req.DateFrom.After(req.DateTo) {
|
if req.DateFrom.After(req.DateTo) {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"apskel-pos-be/internal/constants"
|
|
||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
|
|
||||||
@@ -15,8 +14,6 @@ import (
|
|||||||
|
|
||||||
type analyticsRepositoryStub struct {
|
type analyticsRepositoryStub struct {
|
||||||
purchasingResult *entities.PurchasingAnalytics
|
purchasingResult *entities.PurchasingAnalytics
|
||||||
purchasingTeam *entities.PurchaseTeamFilter
|
|
||||||
budgetCutOffWeeks []*entities.BudgetCutOffWeek
|
|
||||||
profitLossResult *entities.ProfitLossAnalytics
|
profitLossResult *entities.ProfitLossAnalytics
|
||||||
exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics
|
exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics
|
||||||
bankBalances []entities.ExclusiveSummaryBankBalance
|
bankBalances []entities.ExclusiveSummaryBankBalance
|
||||||
@@ -34,8 +31,7 @@ func (analyticsRepositoryStub) GetSalesAnalytics(context.Context, uuid.UUID, *uu
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *analyticsRepositoryStub) GetPurchasingAnalytics(_ context.Context, _ uuid.UUID, _ *uuid.UUID, team *entities.PurchaseTeamFilter, _, _ time.Time, _ string) (*entities.PurchasingAnalytics, error) {
|
func (s analyticsRepositoryStub) GetPurchasingAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.PurchasingAnalytics, error) {
|
||||||
s.purchasingTeam = team
|
|
||||||
return s.purchasingResult, nil
|
return s.purchasingResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,18 +43,6 @@ func (analyticsRepositoryStub) GetProductAnalyticsPerCategory(context.Context, u
|
|||||||
return nil, nil
|
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) {
|
func (analyticsRepositoryStub) GetDashboardOverview(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) (*entities.DashboardOverview, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -161,68 +145,27 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T)
|
|||||||
require.Equal(t, float64(175), result.Data[0].ExpensePurchases)
|
require.Equal(t, float64(175), result.Data[0].ExpensePurchases)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnalyticsProcessorGetPurchasingAnalyticsPassesTeamFilter(t *testing.T) {
|
func TestAnalyticsProcessorGetPurchasingAnalyticsMapsOutletData(t *testing.T) {
|
||||||
categoryID := uuid.New()
|
outletID := uuid.New()
|
||||||
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
team string
|
|
||||||
want *entities.PurchaseTeamFilter
|
|
||||||
}{
|
|
||||||
{name: "all teams", team: "", want: nil},
|
|
||||||
{name: "pusat", team: constants.PurchaseTeamScopeCentral, want: &entities.PurchaseTeamFilter{Scope: constants.PurchaseTeamScopeCentral}},
|
|
||||||
{name: "no team", team: constants.PurchaseTeamNone, want: &entities.PurchaseTeamFilter{Scope: constants.PurchaseTeamNone}},
|
|
||||||
{
|
|
||||||
name: "category team",
|
|
||||||
team: categoryID.String(),
|
|
||||||
want: &entities.PurchaseTeamFilter{Scope: constants.PurchaseTeamScopeCategory, CategoryID: &categoryID},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
repo := &analyticsRepositoryStub{purchasingResult: &entities.PurchasingAnalytics{}}
|
|
||||||
processor := NewAnalyticsProcessorImpl(repo, expenseRepositoryStub{})
|
|
||||||
|
|
||||||
result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{
|
|
||||||
OrganizationID: uuid.New(),
|
|
||||||
Team: tt.team,
|
|
||||||
DateFrom: now,
|
|
||||||
DateTo: now,
|
|
||||||
})
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, tt.team, result.Team)
|
|
||||||
require.Equal(t, tt.want, repo.purchasingTeam)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnalyticsProcessorGetPurchasingAnalyticsMapsTeamBreakdown(t *testing.T) {
|
|
||||||
categoryID := uuid.New()
|
|
||||||
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||||
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
|
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
|
||||||
purchasingResult: &entities.PurchasingAnalytics{
|
purchasingResult: &entities.PurchasingAnalytics{
|
||||||
Summary: entities.PurchasingSummary{TotalPurchases: 300, TotalTeams: 2},
|
Summary: entities.PurchasingSummary{
|
||||||
TeamData: []entities.PurchasingTeamData{
|
TotalPurchases: 500,
|
||||||
|
},
|
||||||
|
OutletData: []entities.PurchasingOutletData{
|
||||||
{
|
{
|
||||||
Scope: constants.PurchaseTeamScopeCategory,
|
OutletID: &outletID,
|
||||||
CategoryID: &categoryID,
|
OutletName: "Outlet A",
|
||||||
Name: "Kitchen",
|
Purchases: 500,
|
||||||
TotalPurchases: 200,
|
RawMaterialPurchases: 350,
|
||||||
RawMaterialPurchases: 150,
|
ExpensePurchases: 150,
|
||||||
ExpensePurchases: 50,
|
PurchaseOrders: 4,
|
||||||
PurchaseOrderCount: 2,
|
RawMaterialPurchaseOrders: 3,
|
||||||
Quantity: 12,
|
ExpenseCount: 2,
|
||||||
Percentage: 66.67,
|
Quantity: 10,
|
||||||
},
|
Ingredients: 5,
|
||||||
{
|
Vendors: 2,
|
||||||
Scope: constants.PurchaseTeamNone,
|
|
||||||
Name: constants.PurchaseTeamNoneName,
|
|
||||||
TotalPurchases: 100,
|
|
||||||
PurchaseOrderCount: 1,
|
|
||||||
Percentage: 33.33,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -232,37 +175,20 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsMapsTeamBreakdown(t *testing.T)
|
|||||||
OrganizationID: uuid.New(),
|
OrganizationID: uuid.New(),
|
||||||
DateFrom: now,
|
DateFrom: now,
|
||||||
DateTo: now,
|
DateTo: now,
|
||||||
|
GroupBy: "outlet_id",
|
||||||
})
|
})
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, int64(2), result.Summary.TotalTeams)
|
require.NotNil(t, result)
|
||||||
require.Len(t, result.TeamData, 2)
|
require.Equal(t, "outlet_id", result.GroupBy)
|
||||||
require.Equal(t, constants.PurchaseTeamScopeCategory, result.TeamData[0].Scope)
|
require.Empty(t, result.Data)
|
||||||
require.Equal(t, &categoryID, result.TeamData[0].CategoryID)
|
require.Len(t, result.OutletData, 1)
|
||||||
require.Equal(t, "Kitchen", result.TeamData[0].Name)
|
require.Equal(t, &outletID, result.OutletData[0].OutletID)
|
||||||
require.Equal(t, float64(200), result.TeamData[0].TotalPurchases)
|
require.Equal(t, "Outlet A", result.OutletData[0].OutletName)
|
||||||
require.Equal(t, float64(150), result.TeamData[0].RawMaterialPurchases)
|
require.Equal(t, float64(500), result.OutletData[0].Purchases)
|
||||||
require.Equal(t, 66.67, result.TeamData[0].Percentage)
|
require.Equal(t, float64(350), result.OutletData[0].RawMaterialPurchases)
|
||||||
require.Equal(t, constants.PurchaseTeamNone, result.TeamData[1].Scope)
|
require.Equal(t, float64(150), result.OutletData[0].ExpensePurchases)
|
||||||
require.Nil(t, result.TeamData[1].CategoryID)
|
require.Equal(t, int64(4), result.OutletData[0].PurchaseOrders)
|
||||||
require.Equal(t, constants.PurchaseTeamNoneName, result.TeamData[1].Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnalyticsProcessorGetPurchasingAnalyticsRejectsUnknownTeam(t *testing.T) {
|
|
||||||
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
|
||||||
repo := &analyticsRepositoryStub{purchasingResult: &entities.PurchasingAnalytics{}}
|
|
||||||
processor := NewAnalyticsProcessorImpl(repo, expenseRepositoryStub{})
|
|
||||||
|
|
||||||
result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{
|
|
||||||
OrganizationID: uuid.New(),
|
|
||||||
Team: "marketing",
|
|
||||||
DateFrom: now,
|
|
||||||
DateTo: now,
|
|
||||||
})
|
|
||||||
|
|
||||||
require.Nil(t, result)
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Contains(t, err.Error(), "team must be one of")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) {
|
func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) {
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,6 @@ type CategoryRepository interface {
|
|||||||
GetByID(ctx context.Context, id uuid.UUID) (*entities.Category, error)
|
GetByID(ctx context.Context, id uuid.UUID) (*entities.Category, error)
|
||||||
GetWithProducts(ctx context.Context, id uuid.UUID) (*entities.Category, error)
|
GetWithProducts(ctx context.Context, id uuid.UUID) (*entities.Category, error)
|
||||||
GetByOrganization(ctx context.Context, organizationID uuid.UUID) ([]*entities.Category, error)
|
GetByOrganization(ctx context.Context, organizationID uuid.UUID) ([]*entities.Category, error)
|
||||||
ListParentCategories(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]*entities.Category, error)
|
|
||||||
GetByBusinessType(ctx context.Context, businessType string) ([]*entities.Category, error)
|
GetByBusinessType(ctx context.Context, businessType string) ([]*entities.Category, error)
|
||||||
Update(ctx context.Context, category *entities.Category) error
|
Update(ctx context.Context, category *entities.Category) error
|
||||||
Delete(ctx context.Context, id uuid.UUID) error
|
Delete(ctx context.Context, id uuid.UUID) error
|
||||||
@@ -54,18 +53,6 @@ func (p *CategoryProcessorImpl) CreateCategory(ctx context.Context, req *models.
|
|||||||
return nil, fmt.Errorf("category with name '%s' already exists for this organization", req.Name)
|
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
|
// Map request to entity
|
||||||
categoryEntity := mappers.CreateCategoryRequestToEntity(req)
|
categoryEntity := mappers.CreateCategoryRequestToEntity(req)
|
||||||
|
|
||||||
@@ -76,7 +63,6 @@ func (p *CategoryProcessorImpl) CreateCategory(ctx context.Context, req *models.
|
|||||||
|
|
||||||
// Map entity to response model
|
// Map entity to response model
|
||||||
response := mappers.CategoryEntityToResponse(categoryEntity)
|
response := mappers.CategoryEntityToResponse(categoryEntity)
|
||||||
response.ParentName = parentName
|
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,23 +84,6 @@ 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
|
// Apply updates to entity
|
||||||
mappers.UpdateCategoryEntityFromRequest(existingCategory, req)
|
mappers.UpdateCategoryEntityFromRequest(existingCategory, req)
|
||||||
|
|
||||||
|
|||||||
@@ -27,11 +27,8 @@ func NewIngredientProcessor(ingredientRepo IngredientRepository, unitRepo UnitRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *IngredientProcessorImpl) CreateIngredient(ctx context.Context, req *models.CreateIngredientRequest) (*models.IngredientResponse, error) {
|
func (p *IngredientProcessorImpl) CreateIngredient(ctx context.Context, req *models.CreateIngredientRequest) (*models.IngredientResponse, error) {
|
||||||
// The unit is optional, so it is only validated when one is supplied.
|
if _, err := p.unitRepo.GetByID(ctx, req.UnitID, req.OrganizationID); err != nil {
|
||||||
if req.UnitID != nil {
|
return nil, err
|
||||||
if _, err := p.unitRepo.GetByID(ctx, *req.UnitID, req.OrganizationID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ingredient := &entities.Ingredient{
|
ingredient := &entities.Ingredient{
|
||||||
@@ -110,8 +107,8 @@ func (p *IngredientProcessorImpl) UpdateIngredient(ctx context.Context, id uuid.
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.UnitID != nil && (existing.UnitID == nil || *req.UnitID != *existing.UnitID) {
|
if req.UnitID != existing.UnitID {
|
||||||
if _, err := p.unitRepo.GetByID(ctx, *req.UnitID, organizationID); err != nil {
|
if _, err := p.unitRepo.GetByID(ctx, req.UnitID, organizationID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,27 +266,15 @@ func (p *IngredientUnitConverterProcessorImpl) GetUnitsByIngredientID(ctx contex
|
|||||||
return nil, fmt.Errorf("failed to get ingredient: %w", err)
|
return nil, fmt.Errorf("failed to get ingredient: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
response := &models.IngredientUnitsResponse{
|
// Get the base unit details
|
||||||
IngredientID: ingredientID,
|
baseUnit, err := p.unitRepo.GetByID(ctx, ingredient.UnitID, organizationID)
|
||||||
IngredientName: ingredient.Name,
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get base unit: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
units := make([]*models.UnitResponse, 0)
|
// Start with the base unit
|
||||||
unitMap := make(map[uuid.UUID]bool)
|
units := []*models.UnitResponse{
|
||||||
|
mappers.MapUnitEntityToResponse(baseUnit),
|
||||||
// An ingredient does not necessarily have a unit assigned yet. When it has
|
|
||||||
// none there is no base unit to start from, so the only units on offer are
|
|
||||||
// the ones its converters mention.
|
|
||||||
if ingredient.UnitID != nil {
|
|
||||||
baseUnit, err := p.unitRepo.GetByID(ctx, *ingredient.UnitID, organizationID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get base unit: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
units = append(units, mappers.MapUnitEntityToResponse(baseUnit))
|
|
||||||
unitMap[baseUnit.ID] = true
|
|
||||||
response.BaseUnitID = &baseUnit.ID
|
|
||||||
response.BaseUnitName = baseUnit.Name
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all converters for this ingredient
|
// Get all converters for this ingredient
|
||||||
@@ -295,6 +283,10 @@ func (p *IngredientUnitConverterProcessorImpl) GetUnitsByIngredientID(ctx contex
|
|||||||
return nil, fmt.Errorf("failed to get converters: %w", err)
|
return nil, fmt.Errorf("failed to get converters: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add unique units from converters
|
||||||
|
unitMap := make(map[uuid.UUID]bool)
|
||||||
|
unitMap[baseUnit.ID] = true
|
||||||
|
|
||||||
for _, converter := range converters {
|
for _, converter := range converters {
|
||||||
if converter.IsActive {
|
if converter.IsActive {
|
||||||
// Add FromUnit if not already added
|
// Add FromUnit if not already added
|
||||||
@@ -317,7 +309,13 @@ func (p *IngredientUnitConverterProcessorImpl) GetUnitsByIngredientID(ctx contex
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Units = units
|
response := &models.IngredientUnitsResponse{
|
||||||
|
IngredientID: ingredientID,
|
||||||
|
IngredientName: ingredient.Name,
|
||||||
|
BaseUnitID: baseUnit.ID,
|
||||||
|
BaseUnitName: baseUnit.Name,
|
||||||
|
Units: units,
|
||||||
|
}
|
||||||
|
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -371,8 +371,8 @@ func (p *OrderIngredientTransactionProcessorImpl) CalculateWasteQuantities(ctx c
|
|||||||
|
|
||||||
// Get unit name
|
// Get unit name
|
||||||
unitName := "unit" // default
|
unitName := "unit" // default
|
||||||
if ingredient.UnitID != nil {
|
if ingredient.UnitID != uuid.Nil {
|
||||||
unit, err := p.unitRepo.GetByID(ctx, *ingredient.UnitID, organizationID)
|
unit, err := p.unitRepo.GetByID(ctx, ingredient.UnitID, organizationID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
unitName = unit.Name
|
unitName = unit.Name
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
package processor
|
package processor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"apskel-pos-be/internal/constants"
|
|
||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
"apskel-pos-be/internal/mappers"
|
"apskel-pos-be/internal/mappers"
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
@@ -21,7 +19,6 @@ type PurchaseOrderProcessor interface {
|
|||||||
GetPurchaseOrdersByStatus(ctx context.Context, organizationID uuid.UUID, status string) ([]*models.PurchaseOrderResponse, error)
|
GetPurchaseOrdersByStatus(ctx context.Context, organizationID uuid.UUID, status string) ([]*models.PurchaseOrderResponse, error)
|
||||||
GetOverduePurchaseOrders(ctx context.Context, organizationID uuid.UUID) ([]*models.PurchaseOrderResponse, error)
|
GetOverduePurchaseOrders(ctx context.Context, organizationID uuid.UUID) ([]*models.PurchaseOrderResponse, error)
|
||||||
UpdatePurchaseOrderStatus(ctx context.Context, id, organizationID, userID, outletID uuid.UUID, status string) (*models.PurchaseOrderResponse, error)
|
UpdatePurchaseOrderStatus(ctx context.Context, id, organizationID, userID, outletID uuid.UUID, status string) (*models.PurchaseOrderResponse, error)
|
||||||
ListPurchaseTeams(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PurchaseOrderProcessorImpl struct {
|
type PurchaseOrderProcessorImpl struct {
|
||||||
@@ -29,12 +26,8 @@ type PurchaseOrderProcessorImpl struct {
|
|||||||
vendorRepo VendorRepository
|
vendorRepo VendorRepository
|
||||||
ingredientRepo IngredientRepository
|
ingredientRepo IngredientRepository
|
||||||
purchaseCategoryRepo PurchaseCategoryRepository
|
purchaseCategoryRepo PurchaseCategoryRepository
|
||||||
categoryRepo CategoryRepository
|
|
||||||
unitRepo UnitRepository
|
unitRepo UnitRepository
|
||||||
fileRepo FileRepository
|
fileRepo FileRepository
|
||||||
// Kept wired but currently unused: purchase orders are a record of spending
|
|
||||||
// only, so nothing here moves stock or converts units. These stay so that
|
|
||||||
// tying purchases back to inventory is a change in one place.
|
|
||||||
inventoryMovementService InventoryMovementService
|
inventoryMovementService InventoryMovementService
|
||||||
unitConverterRepo IngredientUnitConverterRepository
|
unitConverterRepo IngredientUnitConverterRepository
|
||||||
}
|
}
|
||||||
@@ -44,7 +37,6 @@ func NewPurchaseOrderProcessorImpl(
|
|||||||
vendorRepo VendorRepository,
|
vendorRepo VendorRepository,
|
||||||
ingredientRepo IngredientRepository,
|
ingredientRepo IngredientRepository,
|
||||||
purchaseCategoryRepo PurchaseCategoryRepository,
|
purchaseCategoryRepo PurchaseCategoryRepository,
|
||||||
categoryRepo CategoryRepository,
|
|
||||||
unitRepo UnitRepository,
|
unitRepo UnitRepository,
|
||||||
fileRepo FileRepository,
|
fileRepo FileRepository,
|
||||||
inventoryMovementService InventoryMovementService,
|
inventoryMovementService InventoryMovementService,
|
||||||
@@ -55,7 +47,6 @@ func NewPurchaseOrderProcessorImpl(
|
|||||||
vendorRepo: vendorRepo,
|
vendorRepo: vendorRepo,
|
||||||
ingredientRepo: ingredientRepo,
|
ingredientRepo: ingredientRepo,
|
||||||
purchaseCategoryRepo: purchaseCategoryRepo,
|
purchaseCategoryRepo: purchaseCategoryRepo,
|
||||||
categoryRepo: categoryRepo,
|
|
||||||
unitRepo: unitRepo,
|
unitRepo: unitRepo,
|
||||||
fileRepo: fileRepo,
|
fileRepo: fileRepo,
|
||||||
inventoryMovementService: inventoryMovementService,
|
inventoryMovementService: inventoryMovementService,
|
||||||
@@ -72,11 +63,6 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
teamScope, teamCategoryID, err := p.resolvePurchaseTeam(ctx, organizationID, outletID, req.TeamScope, req.TeamCategoryID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if PO number already exists in organization
|
// Check if PO number already exists in organization
|
||||||
existingPO, err := p.purchaseOrderRepo.GetByPONumber(ctx, req.PONumber, organizationID)
|
existingPO, err := p.purchaseOrderRepo.GetByPONumber(ctx, req.PONumber, organizationID)
|
||||||
if err == nil && existingPO != nil {
|
if err == nil && existingPO != nil {
|
||||||
@@ -138,8 +124,6 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
|
|||||||
Status: "draft", // Default status
|
Status: "draft", // Default status
|
||||||
Message: req.Message,
|
Message: req.Message,
|
||||||
TotalAmount: totalAmount,
|
TotalAmount: totalAmount,
|
||||||
TeamScope: teamScope,
|
|
||||||
TeamCategoryID: teamCategoryID,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Status != nil {
|
if req.Status != nil {
|
||||||
@@ -237,16 +221,6 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
|
|||||||
poEntity.Message = req.Message
|
poEntity.Message = req.Message
|
||||||
}
|
}
|
||||||
|
|
||||||
// An omitted team_scope leaves the team as it is; an empty one clears it.
|
|
||||||
if req.TeamScope != nil {
|
|
||||||
teamScope, teamCategoryID, err := p.resolvePurchaseTeam(ctx, organizationID, poEntity.OutletID, req.TeamScope, req.TeamCategoryID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
poEntity.TeamScope = teamScope
|
|
||||||
poEntity.TeamCategoryID = teamCategoryID
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update items if provided
|
// Update items if provided
|
||||||
if req.Items != nil {
|
if req.Items != nil {
|
||||||
totalAmount := 0.0
|
totalAmount := 0.0
|
||||||
@@ -441,11 +415,71 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
|
|||||||
|
|
||||||
fmt.Println("status:", po.Status)
|
fmt.Println("status:", po.Status)
|
||||||
|
|
||||||
// A purchase order is a record of spending only. Receiving one does not move
|
// Check if status is changing to "received" and current status is not "received"
|
||||||
// ingredient stock, does not recalculate ingredient cost, and never converts
|
if status == "received" && po.Status != "received" {
|
||||||
// units: the quantity and unit on an item are kept exactly as the user
|
// Get purchase order with items for inventory update
|
||||||
// entered them. Raw material items are therefore treated the same way expense
|
poWithItems, err := p.purchaseOrderRepo.GetByID(ctx, id)
|
||||||
// items already were, and the ingredient on an item is just a reference.
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get purchase order with items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update inventory for each item
|
||||||
|
for _, item := range poWithItems.Items {
|
||||||
|
if item.PurchaseCategory != nil && item.PurchaseCategory.Type == entities.PurchaseCategoryTypeExpense {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.IngredientID == nil || item.UnitID == nil || item.Quantity == nil {
|
||||||
|
return nil, fmt.Errorf("purchase order item %s is missing raw material inventory fields", item.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get ingredient to find its base unit
|
||||||
|
ingredient, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get ingredient %s: %w", *item.IngredientID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert quantity to ingredient's base unit if needed
|
||||||
|
quantityToAdd := *item.Quantity
|
||||||
|
if *item.UnitID != ingredient.UnitID {
|
||||||
|
// Convert from purchase unit to ingredient's base unit
|
||||||
|
convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, *item.IngredientID, *item.UnitID, ingredient.UnitID, organizationID, *item.Quantity)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", *item.IngredientID, *item.UnitID, ingredient.UnitID, err)
|
||||||
|
}
|
||||||
|
quantityToAdd = convertedQuantity
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate unit cost in ingredient's base unit
|
||||||
|
unitCost := 0.0
|
||||||
|
if quantityToAdd > 0 {
|
||||||
|
unitCost = calculatePurchaseOrderItemTotal(item.Quantity, item.Amount) / quantityToAdd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inventory movement for ingredient purchase
|
||||||
|
reason := fmt.Sprintf("Purchase order %s received", po.PONumber)
|
||||||
|
referenceType := entities.InventoryMovementReferenceTypePurchaseOrder
|
||||||
|
referenceID := &id
|
||||||
|
|
||||||
|
err = p.inventoryMovementService.CreateIngredientMovement(
|
||||||
|
ctx,
|
||||||
|
*item.IngredientID,
|
||||||
|
organizationID,
|
||||||
|
outletID,
|
||||||
|
userID,
|
||||||
|
entities.InventoryMovementTypePurchase,
|
||||||
|
quantityToAdd,
|
||||||
|
unitCost,
|
||||||
|
reason,
|
||||||
|
&referenceType,
|
||||||
|
referenceID,
|
||||||
|
&item.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create inventory movement for ingredient %s: %w", *item.IngredientID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update the purchase order status
|
// Update the purchase order status
|
||||||
statusOutletID := po.OutletID
|
statusOutletID := po.OutletID
|
||||||
@@ -467,79 +501,6 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
|
|||||||
return mappers.PurchaseOrderEntityToResponse(updatedPO), nil
|
return mappers.PurchaseOrderEntityToResponse(updatedPO), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListPurchaseTeams returns the teams a purchase can be charged to: the parent
|
|
||||||
// categories of the outlet in scope, followed by Pusat. Pusat has no category row,
|
|
||||||
// so it is appended here rather than read from the database.
|
|
||||||
func (p *PurchaseOrderProcessorImpl) ListPurchaseTeams(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) {
|
|
||||||
categories, err := p.categoryRepo.ListParentCategories(ctx, organizationID, outletID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to list parent categories: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
teams := make([]models.PurchaseTeam, 0, len(categories)+1)
|
|
||||||
for _, category := range categories {
|
|
||||||
categoryID := category.ID
|
|
||||||
teams = append(teams, models.PurchaseTeam{
|
|
||||||
Scope: constants.PurchaseTeamScopeCategory,
|
|
||||||
CategoryID: &categoryID,
|
|
||||||
Name: category.Name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
teams = append(teams, models.PurchaseTeam{
|
|
||||||
Scope: constants.PurchaseTeamScopeCentral,
|
|
||||||
Name: constants.PurchaseTeamCentralName,
|
|
||||||
})
|
|
||||||
|
|
||||||
return &models.ListPurchaseTeamsResponse{Teams: teams}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolvePurchaseTeam turns a requested team into the scope/category pair stored on
|
|
||||||
// the purchase order, mirroring the database check constraint. A nil or empty scope
|
|
||||||
// leaves the purchase without a team, which is deliberately different from Pusat.
|
|
||||||
// Which outlet's Pusat a purchase belongs to comes from the purchase order's outlet,
|
|
||||||
// so 'central' needs nothing stored beyond the scope itself.
|
|
||||||
func (p *PurchaseOrderProcessorImpl) resolvePurchaseTeam(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, scope *string, categoryID *uuid.UUID) (*string, *uuid.UUID, error) {
|
|
||||||
if scope == nil {
|
|
||||||
return nil, nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch strings.TrimSpace(*scope) {
|
|
||||||
case "":
|
|
||||||
return nil, nil, nil
|
|
||||||
|
|
||||||
case constants.PurchaseTeamScopeCentral:
|
|
||||||
resolved := constants.PurchaseTeamScopeCentral
|
|
||||||
return &resolved, nil, nil
|
|
||||||
|
|
||||||
case constants.PurchaseTeamScopeCategory:
|
|
||||||
if categoryID == nil {
|
|
||||||
return nil, nil, fmt.Errorf("team_category_id is required when team_scope is category")
|
|
||||||
}
|
|
||||||
|
|
||||||
category, err := p.categoryRepo.GetByID(ctx, *categoryID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("team category not found: %w", err)
|
|
||||||
}
|
|
||||||
if category.OrganizationID != organizationID {
|
|
||||||
return nil, nil, fmt.Errorf("team category does not belong to this organization")
|
|
||||||
}
|
|
||||||
if category.ParentID != nil {
|
|
||||||
return nil, nil, fmt.Errorf("team must be a parent category")
|
|
||||||
}
|
|
||||||
// Categories without an outlet are shared, so only an outlet-specific
|
|
||||||
// category has to match the outlet the purchase is booked against.
|
|
||||||
if category.OutletID != nil && outletID != nil && *category.OutletID != *outletID {
|
|
||||||
return nil, nil, fmt.Errorf("team category belongs to a different outlet")
|
|
||||||
}
|
|
||||||
|
|
||||||
resolved := constants.PurchaseTeamScopeCategory
|
|
||||||
return &resolved, &category.ID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, nil, fmt.Errorf("team_scope must be one of: category, central")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *PurchaseOrderProcessorImpl) validatePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) (*entities.PurchaseCategory, error) {
|
func (p *PurchaseOrderProcessorImpl) validatePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) (*entities.PurchaseCategory, error) {
|
||||||
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID)
|
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"apskel-pos-be/internal/constants"
|
|
||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -16,12 +14,9 @@ import (
|
|||||||
type AnalyticsRepository interface {
|
type AnalyticsRepository interface {
|
||||||
GetPaymentMethodAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.PaymentMethodAnalytics, error)
|
GetPaymentMethodAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.PaymentMethodAnalytics, error)
|
||||||
GetSalesAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) ([]*entities.SalesAnalytics, error)
|
GetSalesAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) ([]*entities.SalesAnalytics, error)
|
||||||
GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, team *entities.PurchaseTeamFilter, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error)
|
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)
|
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)
|
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)
|
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)
|
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)
|
GetExclusiveSummaryAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error)
|
||||||
@@ -160,7 +155,7 @@ func (r *AnalyticsRepositoryImpl) GetSalesAnalytics(ctx context.Context, organiz
|
|||||||
return results, err
|
return results, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, team *entities.PurchaseTeamFilter, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) {
|
func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) {
|
||||||
var outletName *string
|
var outletName *string
|
||||||
|
|
||||||
if outletID != nil {
|
if outletID != nil {
|
||||||
@@ -180,10 +175,10 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or
|
|||||||
outletName = &outlet.Name
|
outletName = &outlet.Name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return r.getPurchaseOrderPurchasingAnalytics(ctx, organizationID, outletID, team, outletName, dateFrom, dateTo, groupBy)
|
return r.getPurchaseOrderPurchasingAnalytics(ctx, organizationID, outletID, outletName, dateFrom, dateTo, groupBy)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, team *entities.PurchaseTeamFilter, outletName *string, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) {
|
func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, outletName *string, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) {
|
||||||
var summary entities.PurchasingSummary
|
var summary entities.PurchasingSummary
|
||||||
summaryQuery := r.db.WithContext(ctx).
|
summaryQuery := r.db.WithContext(ctx).
|
||||||
Table("purchase_orders po").
|
Table("purchase_orders po").
|
||||||
@@ -211,7 +206,6 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
|
|||||||
Where("po.status != ?", "cancelled").
|
Where("po.status != ?", "cancelled").
|
||||||
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
|
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
|
||||||
summaryQuery = r.applyPurchaseOrderItemOutletFilter(summaryQuery, outletID)
|
summaryQuery = r.applyPurchaseOrderItemOutletFilter(summaryQuery, outletID)
|
||||||
summaryQuery = r.applyPurchaseOrderTeamFilter(summaryQuery, team)
|
|
||||||
|
|
||||||
if err := summaryQuery.Scan(&summary).Error; err != nil {
|
if err := summaryQuery.Scan(&summary).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -230,35 +224,68 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
|
|||||||
}
|
}
|
||||||
|
|
||||||
var data []entities.PurchasingAnalyticsData
|
var data []entities.PurchasingAnalyticsData
|
||||||
dataQuery := r.db.WithContext(ctx).
|
var outletData []entities.PurchasingOutletData
|
||||||
Table("purchase_orders po").
|
if groupBy == "outlet_id" {
|
||||||
Select(`
|
outletQuery := r.db.WithContext(ctx).
|
||||||
`+dateFormat+` as date,
|
Table("purchase_orders po").
|
||||||
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as purchases,
|
Select(`
|
||||||
COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material_purchases,
|
po.outlet_id as outlet_id,
|
||||||
COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense_purchases,
|
COALESCE(o.name, 'No Outlet') as outlet_name,
|
||||||
COUNT(DISTINCT po.id) as purchase_orders,
|
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as purchases,
|
||||||
COUNT(DISTINCT CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeRawMaterial)+`' THEN po.id END) as raw_material_purchase_orders,
|
COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material_purchases,
|
||||||
COUNT(CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeExpense)+`' THEN poi.id END) as expense_count,
|
COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense_purchases,
|
||||||
COALESCE(SUM(poi.quantity), 0) as quantity,
|
COUNT(DISTINCT po.id) as purchase_orders,
|
||||||
COUNT(DISTINCT i.id) as ingredients,
|
COUNT(DISTINCT CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeRawMaterial)+`' THEN po.id END) as raw_material_purchase_orders,
|
||||||
COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) as vendors
|
COUNT(CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeExpense)+`' THEN poi.id END) as expense_count,
|
||||||
`).
|
COALESCE(SUM(poi.quantity), 0) as quantity,
|
||||||
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
|
COUNT(DISTINCT i.id) as ingredients,
|
||||||
Joins("LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
|
COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) as vendors
|
||||||
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
|
`).
|
||||||
Joins("LEFT JOIN units u ON poi.unit_id = u.id").
|
Joins("LEFT JOIN outlets o ON po.outlet_id = o.id").
|
||||||
Where("po.organization_id = ?", organizationID).
|
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
|
||||||
Where("po.status != ?", "cancelled").
|
Joins("LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
|
||||||
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
|
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
|
||||||
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
|
Joins("LEFT JOIN units u ON poi.unit_id = u.id").
|
||||||
Group(dateFormat).
|
Where("po.organization_id = ?", organizationID).
|
||||||
Order(dateFormat)
|
Where("po.status != ?", "cancelled").
|
||||||
dataQuery = r.applyPurchaseOrderItemOutletFilter(dataQuery, outletID)
|
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
|
||||||
dataQuery = r.applyPurchaseOrderTeamFilter(dataQuery, team)
|
Group("po.outlet_id, COALESCE(o.name, 'No Outlet')").
|
||||||
|
Order("outlet_name ASC")
|
||||||
|
outletQuery = r.applyPurchaseOrderItemOutletFilter(outletQuery, outletID)
|
||||||
|
|
||||||
if err := dataQuery.Scan(&data).Error; err != nil {
|
if err := outletQuery.Scan(&outletData).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dataQuery := r.db.WithContext(ctx).
|
||||||
|
Table("purchase_orders po").
|
||||||
|
Select(`
|
||||||
|
`+dateFormat+` as date,
|
||||||
|
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as purchases,
|
||||||
|
COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material_purchases,
|
||||||
|
COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense_purchases,
|
||||||
|
COUNT(DISTINCT po.id) as purchase_orders,
|
||||||
|
COUNT(DISTINCT CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeRawMaterial)+`' THEN po.id END) as raw_material_purchase_orders,
|
||||||
|
COUNT(CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeExpense)+`' THEN poi.id END) as expense_count,
|
||||||
|
COALESCE(SUM(poi.quantity), 0) as quantity,
|
||||||
|
COUNT(DISTINCT i.id) as ingredients,
|
||||||
|
COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) as vendors
|
||||||
|
`).
|
||||||
|
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
|
||||||
|
Joins("LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
|
||||||
|
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
|
||||||
|
Joins("LEFT JOIN units u ON poi.unit_id = u.id").
|
||||||
|
Where("po.organization_id = ?", organizationID).
|
||||||
|
Where("po.status != ?", "cancelled").
|
||||||
|
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
|
||||||
|
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
|
||||||
|
Group(dateFormat).
|
||||||
|
Order(dateFormat)
|
||||||
|
dataQuery = r.applyPurchaseOrderItemOutletFilter(dataQuery, outletID)
|
||||||
|
|
||||||
|
if err := dataQuery.Scan(&data).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var ingredientData []entities.PurchasingIngredientData
|
var ingredientData []entities.PurchasingIngredientData
|
||||||
@@ -286,7 +313,6 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
|
|||||||
Group("i.id, i.name").
|
Group("i.id, i.name").
|
||||||
Order("total_cost DESC")
|
Order("total_cost DESC")
|
||||||
ingredientQuery = r.applyPurchaseOrderItemOutletFilter(ingredientQuery, outletID)
|
ingredientQuery = r.applyPurchaseOrderItemOutletFilter(ingredientQuery, outletID)
|
||||||
ingredientQuery = r.applyPurchaseOrderTeamFilter(ingredientQuery, team)
|
|
||||||
|
|
||||||
if err := ingredientQuery.Scan(&ingredientData).Error; err != nil {
|
if err := ingredientQuery.Scan(&ingredientData).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -314,105 +340,21 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
|
|||||||
Group("v.id, COALESCE(v.name, 'No Vendor')").
|
Group("v.id, COALESCE(v.name, 'No Vendor')").
|
||||||
Order("total_cost DESC")
|
Order("total_cost DESC")
|
||||||
vendorQuery = r.applyPurchaseOrderItemOutletFilter(vendorQuery, outletID)
|
vendorQuery = r.applyPurchaseOrderItemOutletFilter(vendorQuery, outletID)
|
||||||
vendorQuery = r.applyPurchaseOrderTeamFilter(vendorQuery, team)
|
|
||||||
|
|
||||||
if err := vendorQuery.Scan(&vendorData).Error; err != nil {
|
if err := vendorQuery.Scan(&vendorData).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
teamData, err := r.getPurchaseOrderTeamBreakdown(ctx, organizationID, outletID, team, dateFrom, dateTo, summary.TotalPurchases)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
summary.TotalTeams = int64(len(teamData))
|
|
||||||
|
|
||||||
return &entities.PurchasingAnalytics{
|
return &entities.PurchasingAnalytics{
|
||||||
OutletName: outletName,
|
OutletName: outletName,
|
||||||
Summary: summary,
|
Summary: summary,
|
||||||
Data: data,
|
Data: data,
|
||||||
|
OutletData: outletData,
|
||||||
IngredientData: ingredientData,
|
IngredientData: ingredientData,
|
||||||
VendorData: vendorData,
|
VendorData: vendorData,
|
||||||
TeamData: teamData,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getPurchaseOrderTeamBreakdown splits the purchases over the teams they were
|
|
||||||
// charged to. Purchases with no team are kept as their own row rather than
|
|
||||||
// dropped, so the rows still add up to the summary total.
|
|
||||||
func (r *AnalyticsRepositoryImpl) getPurchaseOrderTeamBreakdown(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, team *entities.PurchaseTeamFilter, dateFrom, dateTo time.Time, totalPurchases float64) ([]entities.PurchasingTeamData, error) {
|
|
||||||
var rows []struct {
|
|
||||||
Scope *string
|
|
||||||
CategoryID *uuid.UUID
|
|
||||||
CategoryName *string
|
|
||||||
TotalPurchases float64
|
|
||||||
RawMaterialPurchases float64
|
|
||||||
ExpensePurchases float64
|
|
||||||
PurchaseOrderCount int64
|
|
||||||
Quantity float64
|
|
||||||
}
|
|
||||||
|
|
||||||
query := r.db.WithContext(ctx).
|
|
||||||
Table("purchase_orders po").
|
|
||||||
Select(`
|
|
||||||
po.team_scope as scope,
|
|
||||||
po.team_category_id as category_id,
|
|
||||||
c.name as category_name,
|
|
||||||
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total_purchases,
|
|
||||||
COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material_purchases,
|
|
||||||
COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense_purchases,
|
|
||||||
COUNT(DISTINCT po.id) as purchase_order_count,
|
|
||||||
COALESCE(SUM(poi.quantity), 0) as quantity
|
|
||||||
`).
|
|
||||||
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
|
|
||||||
Joins("LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
|
|
||||||
Joins("LEFT JOIN categories c ON po.team_category_id = c.id").
|
|
||||||
Where("po.organization_id = ?", organizationID).
|
|
||||||
Where("po.status != ?", "cancelled").
|
|
||||||
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
|
|
||||||
Group("po.team_scope, po.team_category_id, c.name").
|
|
||||||
Order("total_purchases DESC")
|
|
||||||
query = r.applyPurchaseOrderItemOutletFilter(query, outletID)
|
|
||||||
query = r.applyPurchaseOrderTeamFilter(query, team)
|
|
||||||
|
|
||||||
if err := query.Scan(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
teamData := make([]entities.PurchasingTeamData, len(rows))
|
|
||||||
for i, row := range rows {
|
|
||||||
entry := entities.PurchasingTeamData{
|
|
||||||
CategoryID: row.CategoryID,
|
|
||||||
TotalPurchases: row.TotalPurchases,
|
|
||||||
RawMaterialPurchases: row.RawMaterialPurchases,
|
|
||||||
ExpensePurchases: row.ExpensePurchases,
|
|
||||||
PurchaseOrderCount: row.PurchaseOrderCount,
|
|
||||||
Quantity: row.Quantity,
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case row.Scope == nil:
|
|
||||||
entry.Scope = constants.PurchaseTeamNone
|
|
||||||
entry.Name = constants.PurchaseTeamNoneName
|
|
||||||
case *row.Scope == constants.PurchaseTeamScopeCentral:
|
|
||||||
entry.Scope = constants.PurchaseTeamScopeCentral
|
|
||||||
entry.Name = constants.PurchaseTeamCentralName
|
|
||||||
default:
|
|
||||||
entry.Scope = *row.Scope
|
|
||||||
if row.CategoryName != nil {
|
|
||||||
entry.Name = *row.CategoryName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if totalPurchases != 0 {
|
|
||||||
entry.Percentage = row.TotalPurchases / totalPurchases * 100
|
|
||||||
}
|
|
||||||
|
|
||||||
teamData[i] = entry
|
|
||||||
}
|
|
||||||
|
|
||||||
return teamData, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm.DB, outletID *uuid.UUID) *gorm.DB {
|
func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm.DB, outletID *uuid.UUID) *gorm.DB {
|
||||||
if outletID == nil {
|
if outletID == nil {
|
||||||
return query
|
return query
|
||||||
@@ -420,28 +362,6 @@ func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm
|
|||||||
return query.Where("po.outlet_id = ?", *outletID)
|
return query.Where("po.outlet_id = ?", *outletID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyPurchaseOrderTeamFilter narrows a purchase order query to the team the
|
|
||||||
// report asked for. A nil filter, or a category team without a category, leaves
|
|
||||||
// the query spanning every team.
|
|
||||||
func (r *AnalyticsRepositoryImpl) applyPurchaseOrderTeamFilter(query *gorm.DB, team *entities.PurchaseTeamFilter) *gorm.DB {
|
|
||||||
if team == nil {
|
|
||||||
return query
|
|
||||||
}
|
|
||||||
|
|
||||||
switch team.Scope {
|
|
||||||
case constants.PurchaseTeamNone:
|
|
||||||
return query.Where("po.team_scope IS NULL")
|
|
||||||
case constants.PurchaseTeamScopeCentral:
|
|
||||||
return query.Where("po.team_scope = ?", constants.PurchaseTeamScopeCentral)
|
|
||||||
case constants.PurchaseTeamScopeCategory:
|
|
||||||
if team.CategoryID != nil {
|
|
||||||
return query.Where("po.team_scope = ? AND po.team_category_id = ?", constants.PurchaseTeamScopeCategory, *team.CategoryID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return query
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) {
|
func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) {
|
||||||
var results []*entities.ProductAnalytics
|
var results []*entities.ProductAnalytics
|
||||||
|
|
||||||
@@ -576,252 +496,6 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerCategory(ctx context.Con
|
|||||||
return results, err
|
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) {
|
func (r *AnalyticsRepositoryImpl) GetDashboardOverview(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.DashboardOverview, error) {
|
||||||
var result entities.DashboardOverview
|
var result entities.DashboardOverview
|
||||||
|
|
||||||
@@ -1167,9 +841,9 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderRawMaterialTotal(ctx context.C
|
|||||||
}
|
}
|
||||||
|
|
||||||
type purchasingTotals struct {
|
type purchasingTotals struct {
|
||||||
Total float64
|
Total float64
|
||||||
RawMaterial float64
|
RawMaterial float64
|
||||||
Expense float64
|
Expense float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *AnalyticsRepositoryImpl) getPurchaseOrderTotals(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) (purchasingTotals, error) {
|
func (r *AnalyticsRepositoryImpl) getPurchaseOrderTotals(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) (purchasingTotals, error) {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type CategoryRepositoryImpl struct {
|
type CategoryRepositoryImpl struct {
|
||||||
@@ -26,7 +25,7 @@ func (r *CategoryRepositoryImpl) Create(ctx context.Context, category *entities.
|
|||||||
|
|
||||||
func (r *CategoryRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.Category, error) {
|
func (r *CategoryRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.Category, error) {
|
||||||
var category entities.Category
|
var category entities.Category
|
||||||
err := r.db.WithContext(ctx).Preload("Parent").First(&category, "id = ?", id).Error
|
err := r.db.WithContext(ctx).First(&category, "id = ?", id).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -48,26 +47,6 @@ func (r *CategoryRepositoryImpl) GetByOrganization(ctx context.Context, organiza
|
|||||||
return categories, err
|
return categories, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListParentCategories returns the top-level categories of an organization. These are
|
|
||||||
// the buckets the parent category reports roll up to via COALESCE(parent_id, id), so
|
|
||||||
// the list is deliberately every top-level category, not only those with children —
|
|
||||||
// otherwise a team could show up in a report but not be selectable on a purchase.
|
|
||||||
// Categories with no outlet of their own are shared, so they are always included.
|
|
||||||
func (r *CategoryRepositoryImpl) ListParentCategories(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]*entities.Category, error) {
|
|
||||||
var categories []*entities.Category
|
|
||||||
|
|
||||||
query := r.db.WithContext(ctx).
|
|
||||||
Where("organization_id = ?", organizationID).
|
|
||||||
Where("parent_id IS NULL")
|
|
||||||
|
|
||||||
if outletID != nil {
|
|
||||||
query = query.Where("outlet_id = ? OR outlet_id IS NULL", *outletID)
|
|
||||||
}
|
|
||||||
|
|
||||||
err := query.Order("\"order\" ASC, name ASC").Find(&categories).Error
|
|
||||||
return categories, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *CategoryRepositoryImpl) GetByBusinessType(ctx context.Context, businessType string) ([]*entities.Category, error) {
|
func (r *CategoryRepositoryImpl) GetByBusinessType(ctx context.Context, businessType string) ([]*entities.Category, error) {
|
||||||
var categories []*entities.Category
|
var categories []*entities.Category
|
||||||
err := r.db.WithContext(ctx).Where("business_type = ?", businessType).Find(&categories).Error
|
err := r.db.WithContext(ctx).Where("business_type = ?", businessType).Find(&categories).Error
|
||||||
@@ -75,29 +54,13 @@ func (r *CategoryRepositoryImpl) GetByBusinessType(ctx context.Context, business
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *CategoryRepositoryImpl) Update(ctx context.Context, category *entities.Category) error {
|
func (r *CategoryRepositoryImpl) Update(ctx context.Context, category *entities.Category) error {
|
||||||
// Omit associations so a preloaded Parent is not upserted back over parent_id
|
return r.db.WithContext(ctx).Save(category).Error
|
||||||
return r.db.WithContext(ctx).Omit(clause.Associations).Save(category).Error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *CategoryRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
|
func (r *CategoryRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
|
||||||
return r.db.WithContext(ctx).Delete(&entities.Category{}, "id = ?", id).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) {
|
func (r *CategoryRepositoryImpl) List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.Category, int64, error) {
|
||||||
var categories []*entities.Category
|
var categories []*entities.Category
|
||||||
var total int64
|
var total int64
|
||||||
@@ -112,8 +75,6 @@ func (r *CategoryRepositoryImpl) List(ctx context.Context, filters map[string]in
|
|||||||
case "outlet_id":
|
case "outlet_id":
|
||||||
// Include outlet-specific categories AND global categories (outlet_id IS NULL)
|
// Include outlet-specific categories AND global categories (outlet_id IS NULL)
|
||||||
query = query.Where("outlet_id = ? OR outlet_id IS NULL", value)
|
query = query.Where("outlet_id = ? OR outlet_id IS NULL", value)
|
||||||
case "type":
|
|
||||||
query = applyCategoryTypeFilter(query, value)
|
|
||||||
default:
|
default:
|
||||||
query = query.Where(key+" = ?", value)
|
query = query.Where(key+" = ?", value)
|
||||||
}
|
}
|
||||||
@@ -123,7 +84,7 @@ func (r *CategoryRepositoryImpl) List(ctx context.Context, filters map[string]in
|
|||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err := query.Preload("Parent").Order("\"order\" ASC").Limit(limit).Offset(offset).Find(&categories).Error
|
err := query.Order("\"order\" ASC").Limit(limit).Offset(offset).Find(&categories).Error
|
||||||
return categories, total, err
|
return categories, total, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,10 +97,6 @@ func (r *CategoryRepositoryImpl) Count(ctx context.Context, filters map[string]i
|
|||||||
case "search":
|
case "search":
|
||||||
searchValue := "%" + value.(string) + "%"
|
searchValue := "%" + value.(string) + "%"
|
||||||
query = query.Where("name ILIKE ? OR description ILIKE ?", searchValue, searchValue)
|
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:
|
default:
|
||||||
query = query.Where(key+" = ?", value)
|
query = query.Where(key+" = ?", value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,8 +101,6 @@ func (r *ProductRepositoryImpl) List(ctx context.Context, filters map[string]int
|
|||||||
query = query.Where("price >= ?", value)
|
query = query.Where("price >= ?", value)
|
||||||
case "price_max":
|
case "price_max":
|
||||||
query = query.Where("price <= ?", value)
|
query = query.Where("price <= ?", value)
|
||||||
case "category_id":
|
|
||||||
query = query.Where("category_id IN (?)", r.categoryAndChildrenIDs(value))
|
|
||||||
default:
|
default:
|
||||||
query = query.Where(key+" = ?", value)
|
query = query.Where(key+" = ?", value)
|
||||||
}
|
}
|
||||||
@@ -112,21 +110,10 @@ func (r *ProductRepositoryImpl) List(ctx context.Context, filters map[string]int
|
|||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// id is a tie-breaker so LIMIT/OFFSET paging stays stable when several products
|
err := query.Limit(limit).Offset(offset).Find(&products).Error
|
||||||
// share the same created_at.
|
|
||||||
err := query.Order("created_at DESC, id DESC").Limit(limit).Offset(offset).Find(&products).Error
|
|
||||||
return products, total, err
|
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) {
|
func (r *ProductRepositoryImpl) Count(ctx context.Context, filters map[string]interface{}) (int64, error) {
|
||||||
var count int64
|
var count int64
|
||||||
query := r.db.WithContext(ctx).Model(&entities.Product{})
|
query := r.db.WithContext(ctx).Model(&entities.Product{})
|
||||||
@@ -140,8 +127,6 @@ func (r *ProductRepositoryImpl) Count(ctx context.Context, filters map[string]in
|
|||||||
query = query.Where("price >= ?", value)
|
query = query.Where("price >= ?", value)
|
||||||
case "price_max":
|
case "price_max":
|
||||||
query = query.Where("price <= ?", value)
|
query = query.Where("price <= ?", value)
|
||||||
case "category_id":
|
|
||||||
query = query.Where("category_id IN (?)", r.categoryAndChildrenIDs(value))
|
|
||||||
default:
|
default:
|
||||||
query = query.Where(key+" = ?", value)
|
query = query.Where(key+" = ?", value)
|
||||||
}
|
}
|
||||||
@@ -247,8 +232,6 @@ func (r *ProductRepositoryImpl) ListWithOutletPrice(ctx context.Context, filters
|
|||||||
query = query.Where("products.price >= ?", value)
|
query = query.Where("products.price >= ?", value)
|
||||||
case "price_max":
|
case "price_max":
|
||||||
query = query.Where("products.price <= ?", value)
|
query = query.Where("products.price <= ?", value)
|
||||||
case "category_id":
|
|
||||||
query = query.Where("products.category_id IN (?)", r.categoryAndChildrenIDs(value))
|
|
||||||
default:
|
default:
|
||||||
query = query.Where("products."+key+" = ?", value)
|
query = query.Where("products."+key+" = ?", value)
|
||||||
}
|
}
|
||||||
@@ -267,8 +250,6 @@ func (r *ProductRepositoryImpl) ListWithOutletPrice(ctx context.Context, filters
|
|||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Columns are qualified because the outlet join brings a second created_at/id
|
err := query.Limit(limit).Offset(offset).Find(&products).Error
|
||||||
// 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
|
return products, total, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type PurchaseOrderRepositoryImpl struct {
|
type PurchaseOrderRepositoryImpl struct {
|
||||||
@@ -31,7 +30,6 @@ func (r *PurchaseOrderRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID)
|
|||||||
var po entities.PurchaseOrder
|
var po entities.PurchaseOrder
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Preload("Vendor").
|
Preload("Vendor").
|
||||||
Preload("TeamCategory").
|
|
||||||
Preload("Items.Ingredient").
|
Preload("Items.Ingredient").
|
||||||
Preload("Items.PurchaseCategory").
|
Preload("Items.PurchaseCategory").
|
||||||
Preload("Items.Unit").
|
Preload("Items.Unit").
|
||||||
@@ -47,7 +45,6 @@ func (r *PurchaseOrderRepositoryImpl) GetByIDAndOrganizationID(ctx context.Conte
|
|||||||
var po entities.PurchaseOrder
|
var po entities.PurchaseOrder
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Preload("Vendor").
|
Preload("Vendor").
|
||||||
Preload("TeamCategory").
|
|
||||||
Preload("Items.Ingredient").
|
Preload("Items.Ingredient").
|
||||||
Preload("Items.PurchaseCategory").
|
Preload("Items.PurchaseCategory").
|
||||||
Preload("Items.Unit").
|
Preload("Items.Unit").
|
||||||
@@ -61,10 +58,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByIDAndOrganizationID(ctx context.Conte
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *PurchaseOrderRepositoryImpl) Update(ctx context.Context, po *entities.PurchaseOrder) error {
|
func (r *PurchaseOrderRepositoryImpl) Update(ctx context.Context, po *entities.PurchaseOrder) error {
|
||||||
// Omit associations so preloaded relations are not upserted back. Items and
|
return r.db.WithContext(ctx).Save(po).Error
|
||||||
// attachments are rewritten explicitly by the processor, and without this a
|
|
||||||
// preloaded TeamCategory would be written over the category row itself.
|
|
||||||
return r.db.WithContext(ctx).Omit(clause.Associations).Save(po).Error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *PurchaseOrderRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
|
func (r *PurchaseOrderRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
|
||||||
@@ -93,18 +87,6 @@ func (r *PurchaseOrderRepositoryImpl) List(ctx context.Context, organizationID u
|
|||||||
if vendorID, ok := value.(uuid.UUID); ok {
|
if vendorID, ok := value.(uuid.UUID); ok {
|
||||||
query = query.Where("vendor_id = ?", vendorID)
|
query = query.Where("vendor_id = ?", vendorID)
|
||||||
}
|
}
|
||||||
case "team_scope":
|
|
||||||
if teamScope, ok := value.(string); ok && teamScope != "" {
|
|
||||||
query = query.Where("team_scope = ?", teamScope)
|
|
||||||
}
|
|
||||||
case "team_category_id":
|
|
||||||
if teamCategoryID, ok := value.(uuid.UUID); ok {
|
|
||||||
query = query.Where("team_category_id = ?", teamCategoryID)
|
|
||||||
}
|
|
||||||
case "team_unassigned":
|
|
||||||
if unassigned, ok := value.(bool); ok && unassigned {
|
|
||||||
query = query.Where("team_scope IS NULL")
|
|
||||||
}
|
|
||||||
case "start_date":
|
case "start_date":
|
||||||
if startDate, ok := value.(time.Time); ok {
|
if startDate, ok := value.(time.Time); ok {
|
||||||
query = query.Where("transaction_date >= ?", startDate)
|
query = query.Where("transaction_date >= ?", startDate)
|
||||||
@@ -124,7 +106,6 @@ func (r *PurchaseOrderRepositoryImpl) List(ctx context.Context, organizationID u
|
|||||||
|
|
||||||
err := query.
|
err := query.
|
||||||
Preload("Vendor").
|
Preload("Vendor").
|
||||||
Preload("TeamCategory").
|
|
||||||
Preload("Items.Ingredient").
|
Preload("Items.Ingredient").
|
||||||
Preload("Items.PurchaseCategory").
|
Preload("Items.PurchaseCategory").
|
||||||
Preload("Items.Unit").
|
Preload("Items.Unit").
|
||||||
@@ -156,18 +137,6 @@ func (r *PurchaseOrderRepositoryImpl) Count(ctx context.Context, organizationID
|
|||||||
if vendorID, ok := value.(uuid.UUID); ok {
|
if vendorID, ok := value.(uuid.UUID); ok {
|
||||||
query = query.Where("vendor_id = ?", vendorID)
|
query = query.Where("vendor_id = ?", vendorID)
|
||||||
}
|
}
|
||||||
case "team_scope":
|
|
||||||
if teamScope, ok := value.(string); ok && teamScope != "" {
|
|
||||||
query = query.Where("team_scope = ?", teamScope)
|
|
||||||
}
|
|
||||||
case "team_category_id":
|
|
||||||
if teamCategoryID, ok := value.(uuid.UUID); ok {
|
|
||||||
query = query.Where("team_category_id = ?", teamCategoryID)
|
|
||||||
}
|
|
||||||
case "team_unassigned":
|
|
||||||
if unassigned, ok := value.(bool); ok && unassigned {
|
|
||||||
query = query.Where("team_scope IS NULL")
|
|
||||||
}
|
|
||||||
case "start_date":
|
case "start_date":
|
||||||
if startDate, ok := value.(time.Time); ok {
|
if startDate, ok := value.(time.Time); ok {
|
||||||
query = query.Where("transaction_date >= ?", startDate)
|
query = query.Where("transaction_date >= ?", startDate)
|
||||||
@@ -201,7 +170,6 @@ func (r *PurchaseOrderRepositoryImpl) GetByStatus(ctx context.Context, organizat
|
|||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Where("organization_id = ? AND status = ?", organizationID, status).
|
Where("organization_id = ? AND status = ?", organizationID, status).
|
||||||
Preload("Vendor").
|
Preload("Vendor").
|
||||||
Preload("TeamCategory").
|
|
||||||
Preload("Items.Ingredient").
|
Preload("Items.Ingredient").
|
||||||
Preload("Items.PurchaseCategory").
|
Preload("Items.PurchaseCategory").
|
||||||
Preload("Items.Unit").
|
Preload("Items.Unit").
|
||||||
@@ -214,7 +182,6 @@ func (r *PurchaseOrderRepositoryImpl) GetOverdue(ctx context.Context, organizati
|
|||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Where("organization_id = ? AND due_date < ? AND status IN (?)", organizationID, time.Now(), []string{"draft", "sent", "approved"}).
|
Where("organization_id = ? AND due_date < ? AND status IN (?)", organizationID, time.Now(), []string{"draft", "sent", "approved"}).
|
||||||
Preload("Vendor").
|
Preload("Vendor").
|
||||||
Preload("TeamCategory").
|
|
||||||
Preload("Items.Ingredient").
|
Preload("Items.Ingredient").
|
||||||
Preload("Items.PurchaseCategory").
|
Preload("Items.PurchaseCategory").
|
||||||
Preload("Items.Unit").
|
Preload("Items.Unit").
|
||||||
|
|||||||
@@ -335,8 +335,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
analytics.GET("/purchasing", r.analyticsHandler.GetPurchasingAnalytics)
|
analytics.GET("/purchasing", r.analyticsHandler.GetPurchasingAnalytics)
|
||||||
analytics.GET("/products", r.analyticsHandler.GetProductAnalytics)
|
analytics.GET("/products", r.analyticsHandler.GetProductAnalytics)
|
||||||
analytics.GET("/categories", r.analyticsHandler.GetProductAnalyticsPerCategory)
|
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("/dashboard", r.analyticsHandler.GetDashboardAnalytics)
|
||||||
analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics)
|
analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics)
|
||||||
analytics.GET("/exclusive-summary/period", r.analyticsHandler.GetExclusiveSummaryPeriod)
|
analytics.GET("/exclusive-summary/period", r.analyticsHandler.GetExclusiveSummaryPeriod)
|
||||||
@@ -388,7 +386,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
purchaseOrders.GET("", r.purchaseOrderHandler.ListPurchaseOrders)
|
purchaseOrders.GET("", r.purchaseOrderHandler.ListPurchaseOrders)
|
||||||
purchaseOrders.GET("/status/:status", r.purchaseOrderHandler.GetPurchaseOrdersByStatus)
|
purchaseOrders.GET("/status/:status", r.purchaseOrderHandler.GetPurchaseOrdersByStatus)
|
||||||
purchaseOrders.GET("/overdue", r.purchaseOrderHandler.GetOverduePurchaseOrders)
|
purchaseOrders.GET("/overdue", r.purchaseOrderHandler.GetOverduePurchaseOrders)
|
||||||
purchaseOrders.GET("/teams", r.purchaseOrderHandler.ListPurchaseTeams)
|
|
||||||
purchaseOrders.GET("/:id", r.purchaseOrderHandler.GetPurchaseOrder)
|
purchaseOrders.GET("/:id", r.purchaseOrderHandler.GetPurchaseOrder)
|
||||||
purchaseOrders.PUT("/:id", r.purchaseOrderHandler.UpdatePurchaseOrder)
|
purchaseOrders.PUT("/:id", r.purchaseOrderHandler.UpdatePurchaseOrder)
|
||||||
purchaseOrders.PUT("/:id/status/:status", r.purchaseOrderHandler.UpdatePurchaseOrderStatus)
|
purchaseOrders.PUT("/:id/status/:status", r.purchaseOrderHandler.UpdatePurchaseOrderStatus)
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ type AnalyticsService interface {
|
|||||||
GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error)
|
GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error)
|
||||||
GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error)
|
GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error)
|
||||||
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, 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)
|
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
|
||||||
GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error)
|
GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error)
|
||||||
GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error)
|
GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error)
|
||||||
@@ -106,36 +104,6 @@ func (s *AnalyticsServiceImpl) GetProductAnalyticsPerCategory(ctx context.Contex
|
|||||||
return response, nil
|
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) {
|
func (s *AnalyticsServiceImpl) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) {
|
||||||
// Validate request
|
// Validate request
|
||||||
if err := s.validateDashboardAnalyticsRequest(req); err != nil {
|
if err := s.validateDashboardAnalyticsRequest(req); err != nil {
|
||||||
@@ -228,20 +196,17 @@ func (s *AnalyticsServiceImpl) validatePurchasingAnalyticsRequest(req *models.Pu
|
|||||||
|
|
||||||
if req.GroupBy != "" {
|
if req.GroupBy != "" {
|
||||||
validGroupBy := map[string]bool{
|
validGroupBy := map[string]bool{
|
||||||
"day": true,
|
"day": true,
|
||||||
"hour": true,
|
"hour": true,
|
||||||
"week": true,
|
"week": true,
|
||||||
"month": true,
|
"month": true,
|
||||||
|
"outlet_id": true,
|
||||||
}
|
}
|
||||||
if !validGroupBy[req.GroupBy] {
|
if !validGroupBy[req.GroupBy] {
|
||||||
return fmt.Errorf("invalid group_by value: %s", req.GroupBy)
|
return fmt.Errorf("invalid group_by value: %s", req.GroupBy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := models.ParsePurchaseTeamFilter(req.Team); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,50 +254,6 @@ func (s *AnalyticsServiceImpl) validateProductAnalyticsPerCategoryRequest(req *m
|
|||||||
return nil
|
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 {
|
func (s *AnalyticsServiceImpl) validateDashboardAnalyticsRequest(req *models.DashboardAnalyticsRequest) error {
|
||||||
if req.OrganizationID == uuid.Nil {
|
if req.OrganizationID == uuid.Nil {
|
||||||
return fmt.Errorf("organization ID is required")
|
return fmt.Errorf("organization ID is required")
|
||||||
|
|||||||
@@ -33,14 +33,6 @@ func (analyticsProcessorStub) GetProductAnalyticsPerCategory(context.Context, *m
|
|||||||
return nil, nil
|
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) {
|
func (analyticsProcessorStub) GetDashboardAnalytics(context.Context, *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -113,16 +105,6 @@ func TestAnalyticsServiceGetPurchasingAnalyticsValidation(t *testing.T) {
|
|||||||
},
|
},
|
||||||
wantErr: "invalid group_by value: quarter",
|
wantErr: "invalid group_by value: quarter",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "unknown team",
|
|
||||||
req: &models.PurchasingAnalyticsRequest{
|
|
||||||
OrganizationID: uuid.New(),
|
|
||||||
DateFrom: now,
|
|
||||||
DateTo: now,
|
|
||||||
Team: "marketing",
|
|
||||||
},
|
|
||||||
wantErr: "team must be one of",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -150,6 +132,21 @@ func TestAnalyticsServiceGetPurchasingAnalyticsAllowsEmptyGroupBy(t *testing.T)
|
|||||||
require.NotNil(t, resp)
|
require.NotNil(t, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAnalyticsServiceGetPurchasingAnalyticsAllowsOutletGroupBy(t *testing.T) {
|
||||||
|
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
|
||||||
|
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
resp, err := service.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
DateFrom: now,
|
||||||
|
DateTo: now,
|
||||||
|
GroupBy: "outlet_id",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAnalyticsServiceGetProfitLossAnalyticsValidation(t *testing.T) {
|
func TestAnalyticsServiceGetProfitLossAnalyticsValidation(t *testing.T) {
|
||||||
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
|
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
|
||||||
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
|||||||
@@ -91,12 +91,6 @@ func (s *CategoryServiceImpl) ListCategories(ctx context.Context, req *contract.
|
|||||||
if req.BusinessType != "" {
|
if req.BusinessType != "" {
|
||||||
filters["business_type"] = 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 != "" {
|
if req.Search != "" {
|
||||||
filters["search"] = req.Search
|
filters["search"] = req.Search
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ type PurchaseOrderService interface {
|
|||||||
GetPurchaseOrdersByStatus(ctx context.Context, apctx *appcontext.ContextInfo, status string) *contract.Response
|
GetPurchaseOrdersByStatus(ctx context.Context, apctx *appcontext.ContextInfo, status string) *contract.Response
|
||||||
GetOverduePurchaseOrders(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response
|
GetOverduePurchaseOrders(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response
|
||||||
UpdatePurchaseOrderStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, status string) *contract.Response
|
UpdatePurchaseOrderStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, status string) *contract.Response
|
||||||
ListPurchaseTeams(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PurchaseOrderServiceImpl struct {
|
type PurchaseOrderServiceImpl struct {
|
||||||
@@ -114,26 +113,6 @@ func (s *PurchaseOrderServiceImpl) ListPurchaseOrders(ctx context.Context, apctx
|
|||||||
if modelReq.VendorID != nil {
|
if modelReq.VendorID != nil {
|
||||||
filters["vendor_id"] = *modelReq.VendorID
|
filters["vendor_id"] = *modelReq.VendorID
|
||||||
}
|
}
|
||||||
if modelReq.TeamScope != "" {
|
|
||||||
filters["team_scope"] = modelReq.TeamScope
|
|
||||||
}
|
|
||||||
if modelReq.TeamCategoryID != nil {
|
|
||||||
filters["team_category_id"] = *modelReq.TeamCategoryID
|
|
||||||
}
|
|
||||||
// team spells out the same two filters in one value; the validator has already
|
|
||||||
// ruled out sending it together with them.
|
|
||||||
switch modelReq.Team {
|
|
||||||
case "":
|
|
||||||
case constants.PurchaseTeamNone:
|
|
||||||
filters["team_unassigned"] = true
|
|
||||||
case constants.PurchaseTeamScopeCentral:
|
|
||||||
filters["team_scope"] = constants.PurchaseTeamScopeCentral
|
|
||||||
default:
|
|
||||||
if teamCategoryID, err := uuid.Parse(modelReq.Team); err == nil {
|
|
||||||
filters["team_scope"] = constants.PurchaseTeamScopeCategory
|
|
||||||
filters["team_category_id"] = teamCategoryID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if modelReq.StartDate != nil {
|
if modelReq.StartDate != nil {
|
||||||
filters["start_date"] = *modelReq.StartDate
|
filters["start_date"] = *modelReq.StartDate
|
||||||
}
|
}
|
||||||
@@ -166,21 +145,6 @@ func (s *PurchaseOrderServiceImpl) ListPurchaseOrders(ctx context.Context, apctx
|
|||||||
return contract.BuildSuccessResponse(response)
|
return contract.BuildSuccessResponse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PurchaseOrderServiceImpl) ListPurchaseTeams(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response {
|
|
||||||
var outletID *uuid.UUID
|
|
||||||
if apctx.OutletID != uuid.Nil {
|
|
||||||
outletID = &apctx.OutletID
|
|
||||||
}
|
|
||||||
|
|
||||||
teams, err := s.purchaseOrderProcessor.ListPurchaseTeams(ctx, apctx.OrganizationID, outletID)
|
|
||||||
if err != nil {
|
|
||||||
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseOrderServiceEntity, err.Error())
|
|
||||||
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
|
||||||
}
|
|
||||||
|
|
||||||
return contract.BuildSuccessResponse(transformer.ListPurchaseTeamsModelResponseToResponse(teams))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PurchaseOrderServiceImpl) GetPurchaseOrdersByStatus(ctx context.Context, apctx *appcontext.ContextInfo, status string) *contract.Response {
|
func (s *PurchaseOrderServiceImpl) GetPurchaseOrdersByStatus(ctx context.Context, apctx *appcontext.ContextInfo, status string) *contract.Response {
|
||||||
poResponses, err := s.purchaseOrderProcessor.GetPurchaseOrdersByStatus(ctx, apctx.OrganizationID, status)
|
poResponses, err := s.purchaseOrderProcessor.GetPurchaseOrdersByStatus(ctx, apctx.OrganizationID, status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -156,7 +156,6 @@ func PurchasingAnalyticsContractToModel(req *contract.PurchasingAnalyticsRequest
|
|||||||
return &models.PurchasingAnalyticsRequest{
|
return &models.PurchasingAnalyticsRequest{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
OutletID: parseOutletID(req.OutletID),
|
OutletID: parseOutletID(req.OutletID),
|
||||||
Team: req.Team,
|
|
||||||
DateFrom: dateFrom,
|
DateFrom: dateFrom,
|
||||||
DateTo: dateTo,
|
DateTo: dateTo,
|
||||||
GroupBy: req.GroupBy,
|
GroupBy: req.GroupBy,
|
||||||
@@ -185,6 +184,23 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
outletData := make([]contract.PurchasingOutletData, len(resp.OutletData))
|
||||||
|
for i, item := range resp.OutletData {
|
||||||
|
outletData[i] = contract.PurchasingOutletData{
|
||||||
|
OutletID: item.OutletID,
|
||||||
|
OutletName: item.OutletName,
|
||||||
|
Purchases: item.Purchases,
|
||||||
|
RawMaterialPurchases: item.RawMaterialPurchases,
|
||||||
|
ExpensePurchases: item.ExpensePurchases,
|
||||||
|
PurchaseOrders: item.PurchaseOrders,
|
||||||
|
RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders,
|
||||||
|
ExpenseCount: item.ExpenseCount,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
Ingredients: item.Ingredients,
|
||||||
|
Vendors: item.Vendors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ingredientData := make([]contract.PurchasingIngredientData, len(resp.IngredientData))
|
ingredientData := make([]contract.PurchasingIngredientData, len(resp.IngredientData))
|
||||||
for i, item := range resp.IngredientData {
|
for i, item := range resp.IngredientData {
|
||||||
ingredientData[i] = contract.PurchasingIngredientData{
|
ingredientData[i] = contract.PurchasingIngredientData{
|
||||||
@@ -209,26 +225,10 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
teamData := make([]contract.PurchasingTeamData, len(resp.TeamData))
|
|
||||||
for i, item := range resp.TeamData {
|
|
||||||
teamData[i] = contract.PurchasingTeamData{
|
|
||||||
Scope: item.Scope,
|
|
||||||
CategoryID: item.CategoryID,
|
|
||||||
Name: item.Name,
|
|
||||||
TotalPurchases: item.TotalPurchases,
|
|
||||||
RawMaterialPurchases: item.RawMaterialPurchases,
|
|
||||||
ExpensePurchases: item.ExpensePurchases,
|
|
||||||
PurchaseOrderCount: item.PurchaseOrderCount,
|
|
||||||
Quantity: item.Quantity,
|
|
||||||
Percentage: item.Percentage,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &contract.PurchasingAnalyticsResponse{
|
return &contract.PurchasingAnalyticsResponse{
|
||||||
OrganizationID: resp.OrganizationID,
|
OrganizationID: resp.OrganizationID,
|
||||||
OutletID: resp.OutletID,
|
OutletID: resp.OutletID,
|
||||||
OutletName: resp.OutletName,
|
OutletName: resp.OutletName,
|
||||||
Team: resp.Team,
|
|
||||||
DateFrom: resp.DateFrom,
|
DateFrom: resp.DateFrom,
|
||||||
DateTo: resp.DateTo,
|
DateTo: resp.DateTo,
|
||||||
GroupBy: resp.GroupBy,
|
GroupBy: resp.GroupBy,
|
||||||
@@ -243,12 +243,11 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse
|
|||||||
AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue,
|
AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue,
|
||||||
TotalIngredients: resp.Summary.TotalIngredients,
|
TotalIngredients: resp.Summary.TotalIngredients,
|
||||||
TotalVendors: resp.Summary.TotalVendors,
|
TotalVendors: resp.Summary.TotalVendors,
|
||||||
TotalTeams: resp.Summary.TotalTeams,
|
|
||||||
},
|
},
|
||||||
Data: data,
|
Data: data,
|
||||||
|
OutletData: outletData,
|
||||||
IngredientData: ingredientData,
|
IngredientData: ingredientData,
|
||||||
VendorData: vendorData,
|
VendorData: vendorData,
|
||||||
TeamData: teamData,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,195 +365,6 @@ 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
|
// DashboardAnalyticsContractToModel converts contract request to model
|
||||||
func DashboardAnalyticsContractToModel(req *contract.DashboardAnalyticsRequest) *models.DashboardAnalyticsRequest {
|
func DashboardAnalyticsContractToModel(req *contract.DashboardAnalyticsRequest) *models.DashboardAnalyticsRequest {
|
||||||
var dateFrom, dateTo time.Time
|
var dateFrom, dateTo time.Time
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"apskel-pos-be/internal/constants"
|
|
||||||
"apskel-pos-be/internal/contract"
|
"apskel-pos-be/internal/contract"
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
|
|
||||||
@@ -96,49 +95,6 @@ func TestPurchasingAnalyticsModelToContractCopiesOutletName(t *testing.T) {
|
|||||||
require.Equal(t, float64(175), result.Data[0].ExpensePurchases)
|
require.Equal(t, float64(175), result.Data[0].ExpensePurchases)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPurchasingAnalyticsModelToContractCopiesTeamData(t *testing.T) {
|
|
||||||
categoryID := uuid.New()
|
|
||||||
|
|
||||||
result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{
|
|
||||||
OrganizationID: uuid.New(),
|
|
||||||
Team: categoryID.String(),
|
|
||||||
Summary: models.PurchasingSummary{TotalPurchases: 300, TotalTeams: 2},
|
|
||||||
TeamData: []models.PurchasingTeamData{
|
|
||||||
{
|
|
||||||
Scope: constants.PurchaseTeamScopeCategory,
|
|
||||||
CategoryID: &categoryID,
|
|
||||||
Name: "Kitchen",
|
|
||||||
TotalPurchases: 200,
|
|
||||||
RawMaterialPurchases: 150,
|
|
||||||
ExpensePurchases: 50,
|
|
||||||
PurchaseOrderCount: 2,
|
|
||||||
Quantity: 12,
|
|
||||||
Percentage: 66.67,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Scope: constants.PurchaseTeamNone,
|
|
||||||
Name: constants.PurchaseTeamNoneName,
|
|
||||||
TotalPurchases: 100,
|
|
||||||
PurchaseOrderCount: 1,
|
|
||||||
Percentage: 33.33,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
require.NotNil(t, result)
|
|
||||||
require.Equal(t, categoryID.String(), result.Team)
|
|
||||||
require.Equal(t, int64(2), result.Summary.TotalTeams)
|
|
||||||
require.Len(t, result.TeamData, 2)
|
|
||||||
require.Equal(t, constants.PurchaseTeamScopeCategory, result.TeamData[0].Scope)
|
|
||||||
require.Equal(t, &categoryID, result.TeamData[0].CategoryID)
|
|
||||||
require.Equal(t, "Kitchen", result.TeamData[0].Name)
|
|
||||||
require.Equal(t, float64(200), result.TeamData[0].TotalPurchases)
|
|
||||||
require.Equal(t, 66.67, result.TeamData[0].Percentage)
|
|
||||||
require.Equal(t, constants.PurchaseTeamNone, result.TeamData[1].Scope)
|
|
||||||
require.Nil(t, result.TeamData[1].CategoryID)
|
|
||||||
require.Equal(t, constants.PurchaseTeamNoneName, result.TeamData[1].Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) {
|
func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) {
|
||||||
result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{
|
result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{
|
||||||
OrganizationID: uuid.New(),
|
OrganizationID: uuid.New(),
|
||||||
@@ -149,6 +105,40 @@ func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) {
|
|||||||
require.NotContains(t, string(payload), "outlet_name")
|
require.NotContains(t, string(payload), "outlet_name")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPurchasingAnalyticsModelToContractCopiesOutletData(t *testing.T) {
|
||||||
|
outletID := uuid.New()
|
||||||
|
|
||||||
|
result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
GroupBy: "outlet_id",
|
||||||
|
OutletData: []models.PurchasingOutletData{
|
||||||
|
{
|
||||||
|
OutletID: &outletID,
|
||||||
|
OutletName: "Outlet A",
|
||||||
|
Purchases: 500,
|
||||||
|
RawMaterialPurchases: 350,
|
||||||
|
ExpensePurchases: 150,
|
||||||
|
PurchaseOrders: 4,
|
||||||
|
RawMaterialPurchaseOrders: 3,
|
||||||
|
ExpenseCount: 2,
|
||||||
|
Quantity: 10,
|
||||||
|
Ingredients: 5,
|
||||||
|
Vendors: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Equal(t, "outlet_id", result.GroupBy)
|
||||||
|
require.Len(t, result.OutletData, 1)
|
||||||
|
require.Equal(t, &outletID, result.OutletData[0].OutletID)
|
||||||
|
require.Equal(t, "Outlet A", result.OutletData[0].OutletName)
|
||||||
|
require.Equal(t, float64(500), result.OutletData[0].Purchases)
|
||||||
|
require.Equal(t, float64(350), result.OutletData[0].RawMaterialPurchases)
|
||||||
|
require.Equal(t, float64(150), result.OutletData[0].ExpensePurchases)
|
||||||
|
require.Equal(t, int64(4), result.OutletData[0].PurchaseOrders)
|
||||||
|
}
|
||||||
|
|
||||||
func TestProfitLossAnalyticsContractToModelParsesDateRange(t *testing.T) {
|
func TestProfitLossAnalyticsContractToModelParsesDateRange(t *testing.T) {
|
||||||
orgID := uuid.New()
|
orgID := uuid.New()
|
||||||
outletID := uuid.New().String()
|
outletID := uuid.New().String()
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ func CreateCategoryRequestToModel(apctx *appcontext.ContextInfo, req *contract.C
|
|||||||
return &models.CreateCategoryRequest{
|
return &models.CreateCategoryRequest{
|
||||||
OrganizationID: apctx.OrganizationID,
|
OrganizationID: apctx.OrganizationID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
ParentID: req.ParentID,
|
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
ImageURL: nil,
|
ImageURL: nil,
|
||||||
@@ -28,7 +27,6 @@ func UpdateCategoryRequestToModel(req *contract.UpdateCategoryRequest) *models.U
|
|||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
ImageURL: nil,
|
ImageURL: nil,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
ParentID: req.ParentID,
|
|
||||||
Order: req.Order,
|
Order: req.Order,
|
||||||
IsActive: nil,
|
IsActive: nil,
|
||||||
}
|
}
|
||||||
@@ -43,8 +41,6 @@ func CategoryModelResponseToResponse(cat *models.CategoryResponse) *contract.Cat
|
|||||||
ID: cat.ID,
|
ID: cat.ID,
|
||||||
OrganizationID: cat.OrganizationID,
|
OrganizationID: cat.OrganizationID,
|
||||||
OutletID: cat.OutletID,
|
OutletID: cat.OutletID,
|
||||||
ParentID: cat.ParentID,
|
|
||||||
ParentName: cat.ParentName,
|
|
||||||
Name: cat.Name,
|
Name: cat.Name,
|
||||||
Description: cat.Description,
|
Description: cat.Description,
|
||||||
BusinessType: "restaurant",
|
BusinessType: "restaurant",
|
||||||
|
|||||||
@@ -44,8 +44,6 @@ func CreatePurchaseOrderRequestToModel(req *contract.CreatePurchaseOrderRequest)
|
|||||||
Reference: req.Reference,
|
Reference: req.Reference,
|
||||||
Status: req.Status,
|
Status: req.Status,
|
||||||
Message: req.Message,
|
Message: req.Message,
|
||||||
TeamScope: req.TeamScope,
|
|
||||||
TeamCategoryID: req.TeamCategoryID,
|
|
||||||
Items: items,
|
Items: items,
|
||||||
AttachmentFileIDs: req.AttachmentFileIDs,
|
AttachmentFileIDs: req.AttachmentFileIDs,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -96,8 +94,6 @@ func UpdatePurchaseOrderRequestToModel(req *contract.UpdatePurchaseOrderRequest)
|
|||||||
Reference: req.Reference,
|
Reference: req.Reference,
|
||||||
Status: req.Status,
|
Status: req.Status,
|
||||||
Message: req.Message,
|
Message: req.Message,
|
||||||
TeamScope: req.TeamScope,
|
|
||||||
TeamCategoryID: req.TeamCategoryID,
|
|
||||||
Items: items,
|
Items: items,
|
||||||
AttachmentFileIDs: req.AttachmentFileIDs,
|
AttachmentFileIDs: req.AttachmentFileIDs,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -105,40 +101,16 @@ func UpdatePurchaseOrderRequestToModel(req *contract.UpdatePurchaseOrderRequest)
|
|||||||
|
|
||||||
func ListPurchaseOrdersRequestToModel(req *contract.ListPurchaseOrdersRequest) *models.ListPurchaseOrdersRequest {
|
func ListPurchaseOrdersRequestToModel(req *contract.ListPurchaseOrdersRequest) *models.ListPurchaseOrdersRequest {
|
||||||
return &models.ListPurchaseOrdersRequest{
|
return &models.ListPurchaseOrdersRequest{
|
||||||
Page: req.Page,
|
Page: req.Page,
|
||||||
Limit: req.Limit,
|
Limit: req.Limit,
|
||||||
Search: req.Search,
|
Search: req.Search,
|
||||||
Status: req.Status,
|
Status: req.Status,
|
||||||
VendorID: req.VendorID,
|
VendorID: req.VendorID,
|
||||||
Team: req.Team,
|
StartDate: req.StartDate,
|
||||||
TeamScope: req.TeamScope,
|
EndDate: req.EndDate,
|
||||||
TeamCategoryID: req.TeamCategoryID,
|
|
||||||
StartDate: req.StartDate,
|
|
||||||
EndDate: req.EndDate,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func PurchaseTeamModelToResponse(team *models.PurchaseTeam) *contract.PurchaseTeamResponse {
|
|
||||||
if team == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &contract.PurchaseTeamResponse{
|
|
||||||
Scope: team.Scope,
|
|
||||||
CategoryID: team.CategoryID,
|
|
||||||
Name: team.Name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ListPurchaseTeamsModelResponseToResponse(resp *models.ListPurchaseTeamsResponse) *contract.ListPurchaseTeamsResponse {
|
|
||||||
teams := make([]contract.PurchaseTeamResponse, len(resp.Teams))
|
|
||||||
for i, team := range resp.Teams {
|
|
||||||
teams[i] = *PurchaseTeamModelToResponse(&team)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &contract.ListPurchaseTeamsResponse{Teams: teams}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Model to Contract conversions
|
// Model to Contract conversions
|
||||||
func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *contract.PurchaseOrderResponse {
|
func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *contract.PurchaseOrderResponse {
|
||||||
if po == nil {
|
if po == nil {
|
||||||
@@ -157,11 +129,8 @@ func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *con
|
|||||||
Status: po.Status,
|
Status: po.Status,
|
||||||
Message: po.Message,
|
Message: po.Message,
|
||||||
TotalAmount: po.TotalAmount,
|
TotalAmount: po.TotalAmount,
|
||||||
TeamScope: po.TeamScope,
|
|
||||||
TeamCategoryID: po.TeamCategoryID,
|
|
||||||
CreatedAt: po.CreatedAt,
|
CreatedAt: po.CreatedAt,
|
||||||
UpdatedAt: po.UpdatedAt,
|
UpdatedAt: po.UpdatedAt,
|
||||||
Team: PurchaseTeamModelToResponse(po.Team),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map vendor if present
|
// Map vendor if present
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func (v *CategoryValidatorImpl) ValidateUpdateCategoryRequest(req *contract.Upda
|
|||||||
}
|
}
|
||||||
|
|
||||||
// At least one field should be provided for update
|
// At least one field should be provided for update
|
||||||
if req.Name == nil && req.Description == nil && req.BusinessType == nil && req.ParentID == nil && req.Metadata == nil {
|
if req.Name == nil && req.Description == nil && req.BusinessType == nil && req.Metadata == nil {
|
||||||
return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode
|
return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,9 +118,5 @@ 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, ""
|
return nil, ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,10 +76,6 @@ func (v *PurchaseOrderValidatorImpl) ValidateCreatePurchaseOrderRequest(req *con
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err, code := validatePurchaseTeamSelection(req.TeamScope, req.TeamCategoryID, false); err != nil {
|
|
||||||
return err, code
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(req.Items) == 0 {
|
if len(req.Items) == 0 {
|
||||||
return errors.New("at least one item is required"), constants.MissingFieldErrorCode
|
return errors.New("at least one item is required"), constants.MissingFieldErrorCode
|
||||||
}
|
}
|
||||||
@@ -143,10 +139,6 @@ func (v *PurchaseOrderValidatorImpl) ValidateUpdatePurchaseOrderRequest(req *con
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err, code := validatePurchaseTeamSelection(req.TeamScope, req.TeamCategoryID, true); err != nil {
|
|
||||||
return err, code
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate items if provided
|
// Validate items if provided
|
||||||
if req.Items != nil {
|
if req.Items != nil {
|
||||||
for i, item := range req.Items {
|
for i, item := range req.Items {
|
||||||
@@ -159,55 +151,6 @@ func (v *PurchaseOrderValidatorImpl) ValidateUpdatePurchaseOrderRequest(req *con
|
|||||||
return nil, ""
|
return nil, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// validatePurchaseTeamSelection keeps team_scope and team_category_id in step with
|
|
||||||
// the database check constraint: a category team needs a category, Pusat must not
|
|
||||||
// carry one. allowClear lets an update send an empty scope to drop the team.
|
|
||||||
func validatePurchaseTeamSelection(scope *string, categoryID *uuid.UUID, allowClear bool) (error, string) {
|
|
||||||
if scope == nil {
|
|
||||||
if categoryID != nil {
|
|
||||||
return errors.New("team_scope is required when team_category_id is provided"), constants.MissingFieldErrorCode
|
|
||||||
}
|
|
||||||
return nil, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
switch strings.TrimSpace(*scope) {
|
|
||||||
case "":
|
|
||||||
if !allowClear {
|
|
||||||
return errors.New("team_scope must be one of: category, central"), constants.MalformedFieldErrorCode
|
|
||||||
}
|
|
||||||
if categoryID != nil {
|
|
||||||
return errors.New("team_category_id must be empty when clearing the team"), constants.MalformedFieldErrorCode
|
|
||||||
}
|
|
||||||
case constants.PurchaseTeamScopeCategory:
|
|
||||||
if categoryID == nil || *categoryID == uuid.Nil {
|
|
||||||
return errors.New("team_category_id is required when team_scope is category"), constants.MissingFieldErrorCode
|
|
||||||
}
|
|
||||||
case constants.PurchaseTeamScopeCentral:
|
|
||||||
if categoryID != nil {
|
|
||||||
return errors.New("team_category_id must be empty when team_scope is central"), constants.MalformedFieldErrorCode
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return errors.New("team_scope must be one of: category, central"), constants.MalformedFieldErrorCode
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// validatePurchaseTeamFilter accepts the values the team picker hands back: Pusat,
|
|
||||||
// no team at all, or the id of the parent category a purchase is charged to.
|
|
||||||
func validatePurchaseTeamFilter(team string) (error, string) {
|
|
||||||
switch team {
|
|
||||||
case constants.PurchaseTeamScopeCentral, constants.PurchaseTeamNone:
|
|
||||||
return nil, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if categoryID, err := uuid.Parse(team); err != nil || categoryID == uuid.Nil {
|
|
||||||
return errors.New("team must be one of: central, none, or a category id"), constants.MalformedFieldErrorCode
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *contract.ListPurchaseOrdersRequest) (error, string) {
|
func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *contract.ListPurchaseOrdersRequest) (error, string) {
|
||||||
if req == nil {
|
if req == nil {
|
||||||
return errors.New("request body is required"), constants.MissingFieldErrorCode
|
return errors.New("request body is required"), constants.MissingFieldErrorCode
|
||||||
@@ -228,31 +171,6 @@ func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *cont
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Team != "" {
|
|
||||||
if req.TeamScope != "" || req.TeamCategoryID != nil {
|
|
||||||
return errors.New("team cannot be combined with team_scope or team_category_id"), constants.MalformedFieldErrorCode
|
|
||||||
}
|
|
||||||
|
|
||||||
if err, code := validatePurchaseTeamFilter(req.Team); err != nil {
|
|
||||||
return err, code
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.TeamScope != "" {
|
|
||||||
validScopes := []string{constants.PurchaseTeamScopeCategory, constants.PurchaseTeamScopeCentral}
|
|
||||||
if !contains(validScopes, req.TeamScope) {
|
|
||||||
return errors.New("team_scope must be one of: category, central"), constants.MalformedFieldErrorCode
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.TeamScope == constants.PurchaseTeamScopeCentral && req.TeamCategoryID != nil {
|
|
||||||
return errors.New("team_category_id must be empty when team_scope is central"), constants.MalformedFieldErrorCode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.TeamCategoryID != nil && *req.TeamCategoryID == uuid.Nil {
|
|
||||||
return errors.New("team_category_id cannot be empty"), constants.MalformedFieldErrorCode
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.StartDate != nil && req.EndDate != nil {
|
if req.StartDate != nil && req.EndDate != nil {
|
||||||
if req.EndDate.Before(*req.StartDate) {
|
if req.EndDate.Before(*req.StartDate) {
|
||||||
return errors.New("end_date must be after start_date"), constants.MalformedFieldErrorCode
|
return errors.New("end_date must be after start_date"), constants.MalformedFieldErrorCode
|
||||||
|
|||||||
@@ -90,164 +90,3 @@ func TestPurchaseOrderValidatorCreateRejectsDueDateBeforeTransactionDate(t *test
|
|||||||
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||||
require.Contains(t, err.Error(), "due_date must be after transaction_date")
|
require.Contains(t, err.Error(), "due_date must be after transaction_date")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPurchaseOrderValidatorCreateAllowsCentralTeam(t *testing.T) {
|
|
||||||
validator := NewPurchaseOrderValidator()
|
|
||||||
req := validCreatePurchaseOrderRequest()
|
|
||||||
scope := constants.PurchaseTeamScopeCentral
|
|
||||||
req.TeamScope = &scope
|
|
||||||
|
|
||||||
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Empty(t, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPurchaseOrderValidatorCreateRejectsCentralTeamWithCategory(t *testing.T) {
|
|
||||||
validator := NewPurchaseOrderValidator()
|
|
||||||
req := validCreatePurchaseOrderRequest()
|
|
||||||
scope := constants.PurchaseTeamScopeCentral
|
|
||||||
categoryID := uuid.New()
|
|
||||||
req.TeamScope = &scope
|
|
||||||
req.TeamCategoryID = &categoryID
|
|
||||||
|
|
||||||
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
|
||||||
require.Contains(t, err.Error(), "team_category_id must be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPurchaseOrderValidatorCreateRejectsCategoryTeamWithoutCategory(t *testing.T) {
|
|
||||||
validator := NewPurchaseOrderValidator()
|
|
||||||
req := validCreatePurchaseOrderRequest()
|
|
||||||
scope := constants.PurchaseTeamScopeCategory
|
|
||||||
req.TeamScope = &scope
|
|
||||||
|
|
||||||
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Equal(t, constants.MissingFieldErrorCode, code)
|
|
||||||
require.Contains(t, err.Error(), "team_category_id is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPurchaseOrderValidatorCreateRejectsCategoryWithoutScope(t *testing.T) {
|
|
||||||
validator := NewPurchaseOrderValidator()
|
|
||||||
req := validCreatePurchaseOrderRequest()
|
|
||||||
categoryID := uuid.New()
|
|
||||||
req.TeamCategoryID = &categoryID
|
|
||||||
|
|
||||||
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Equal(t, constants.MissingFieldErrorCode, code)
|
|
||||||
require.Contains(t, err.Error(), "team_scope is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPurchaseOrderValidatorCreateRejectsUnknownTeamScope(t *testing.T) {
|
|
||||||
validator := NewPurchaseOrderValidator()
|
|
||||||
req := validCreatePurchaseOrderRequest()
|
|
||||||
scope := "outlet"
|
|
||||||
req.TeamScope = &scope
|
|
||||||
|
|
||||||
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
|
||||||
require.Contains(t, err.Error(), "team_scope must be one of")
|
|
||||||
}
|
|
||||||
|
|
||||||
// An update may clear the team with an empty scope; a create may not, because
|
|
||||||
// leaving the field out already means "no team".
|
|
||||||
func TestPurchaseOrderValidatorUpdateAllowsClearingTeam(t *testing.T) {
|
|
||||||
validator := NewPurchaseOrderValidator()
|
|
||||||
scope := ""
|
|
||||||
|
|
||||||
err, code := validator.ValidateUpdatePurchaseOrderRequest(&contract.UpdatePurchaseOrderRequest{TeamScope: &scope})
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Empty(t, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPurchaseOrderValidatorCreateRejectsEmptyTeamScope(t *testing.T) {
|
|
||||||
validator := NewPurchaseOrderValidator()
|
|
||||||
req := validCreatePurchaseOrderRequest()
|
|
||||||
scope := ""
|
|
||||||
req.TeamScope = &scope
|
|
||||||
|
|
||||||
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPurchaseOrderValidatorUpdateRejectsClearingTeamWithCategory(t *testing.T) {
|
|
||||||
validator := NewPurchaseOrderValidator()
|
|
||||||
scope := ""
|
|
||||||
categoryID := uuid.New()
|
|
||||||
|
|
||||||
err, code := validator.ValidateUpdatePurchaseOrderRequest(&contract.UpdatePurchaseOrderRequest{
|
|
||||||
TeamScope: &scope,
|
|
||||||
TeamCategoryID: &categoryID,
|
|
||||||
})
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPurchaseOrderValidatorListAcceptsTeamFilter(t *testing.T) {
|
|
||||||
validator := NewPurchaseOrderValidator()
|
|
||||||
|
|
||||||
for _, team := range []string{constants.PurchaseTeamScopeCentral, constants.PurchaseTeamNone, uuid.New().String()} {
|
|
||||||
err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{
|
|
||||||
Page: 1,
|
|
||||||
Limit: 10,
|
|
||||||
Team: team,
|
|
||||||
})
|
|
||||||
|
|
||||||
require.NoError(t, err, team)
|
|
||||||
require.Empty(t, code, team)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPurchaseOrderValidatorListRejectsUnknownTeamFilter(t *testing.T) {
|
|
||||||
validator := NewPurchaseOrderValidator()
|
|
||||||
|
|
||||||
err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{
|
|
||||||
Page: 1,
|
|
||||||
Limit: 10,
|
|
||||||
Team: "marketing",
|
|
||||||
})
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPurchaseOrderValidatorListRejectsTeamWithScope(t *testing.T) {
|
|
||||||
validator := NewPurchaseOrderValidator()
|
|
||||||
|
|
||||||
err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{
|
|
||||||
Page: 1,
|
|
||||||
Limit: 10,
|
|
||||||
Team: constants.PurchaseTeamNone,
|
|
||||||
TeamScope: constants.PurchaseTeamScopeCentral,
|
|
||||||
})
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPurchaseOrderValidatorListRejectsCentralScopeWithCategory(t *testing.T) {
|
|
||||||
validator := NewPurchaseOrderValidator()
|
|
||||||
categoryID := uuid.New()
|
|
||||||
|
|
||||||
err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{
|
|
||||||
Page: 1,
|
|
||||||
Limit: 10,
|
|
||||||
TeamScope: constants.PurchaseTeamScopeCentral,
|
|
||||||
TeamCategoryID: &categoryID,
|
|
||||||
})
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
DROP INDEX IF EXISTS idx_purchase_orders_team_scope;
|
|
||||||
DROP INDEX IF EXISTS idx_purchase_orders_team_category_id;
|
|
||||||
|
|
||||||
ALTER TABLE purchase_orders
|
|
||||||
DROP CONSTRAINT IF EXISTS chk_purchase_orders_team;
|
|
||||||
|
|
||||||
ALTER TABLE purchase_orders
|
|
||||||
DROP CONSTRAINT IF EXISTS fk_purchase_orders_team_category;
|
|
||||||
|
|
||||||
ALTER TABLE purchase_orders
|
|
||||||
DROP COLUMN IF EXISTS team_category_id,
|
|
||||||
DROP COLUMN IF EXISTS team_scope;
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
-- A purchase is charged either to a team (a parent product category) or to Pusat.
|
|
||||||
-- Pusat has no category of its own, so it is stored as a scope rather than a row;
|
|
||||||
-- which outlet's Pusat it is comes from purchase_orders.outlet_id.
|
|
||||||
-- team_scope IS NULL means the team was never chosen, which is deliberately
|
|
||||||
-- distinct from a purchase that belongs to Pusat.
|
|
||||||
ALTER TABLE purchase_orders
|
|
||||||
ADD COLUMN IF NOT EXISTS team_scope VARCHAR(20),
|
|
||||||
ADD COLUMN IF NOT EXISTS team_category_id UUID;
|
|
||||||
|
|
||||||
ALTER TABLE purchase_orders
|
|
||||||
ADD CONSTRAINT fk_purchase_orders_team_category
|
|
||||||
FOREIGN KEY (team_category_id) REFERENCES categories(id) ON DELETE RESTRICT;
|
|
||||||
|
|
||||||
-- Deleting a category that is still charged on a purchase order must fail rather
|
|
||||||
-- than silently drop the attribution, hence RESTRICT above and this pairing check.
|
|
||||||
-- Written as a CASE because an OR chain would evaluate to NULL when team_scope is
|
|
||||||
-- NULL, and a CHECK only rejects FALSE — a stray team_category_id would slip past.
|
|
||||||
ALTER TABLE purchase_orders
|
|
||||||
ADD CONSTRAINT chk_purchase_orders_team
|
|
||||||
CHECK (
|
|
||||||
CASE
|
|
||||||
WHEN team_scope IS NULL THEN team_category_id IS NULL
|
|
||||||
WHEN team_scope = 'category' THEN team_category_id IS NOT NULL
|
|
||||||
WHEN team_scope = 'central' THEN team_category_id IS NULL
|
|
||||||
ELSE false
|
|
||||||
END
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_purchase_orders_team_category_id
|
|
||||||
ON purchase_orders(team_category_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_purchase_orders_team_scope
|
|
||||||
ON purchase_orders(team_scope);
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
-- Restoring NOT NULL fails if any ingredient still has a NULL unit_id. Assign a
|
|
||||||
-- unit to those rows first:
|
|
||||||
-- SELECT id, name FROM ingredients WHERE unit_id IS NULL;
|
|
||||||
COMMENT ON COLUMN ingredients.unit_id IS NULL;
|
|
||||||
|
|
||||||
ALTER TABLE ingredients ALTER COLUMN unit_id SET NOT NULL;
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
-- An ingredient can be registered before its unit has been decided, so unit_id
|
|
||||||
-- is optional. Existing rows are untouched: they already have a unit.
|
|
||||||
ALTER TABLE ingredients ALTER COLUMN unit_id DROP NOT NULL;
|
|
||||||
|
|
||||||
COMMENT ON COLUMN ingredients.unit_id IS 'Base unit of the ingredient. NULL means no unit has been assigned yet.';
|
|
||||||
Reference in New Issue
Block a user