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"
|
||||||
@@ -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"
|
|
||||||
@@ -90,7 +90,7 @@ type PurchasingAnalyticsRequest struct {
|
|||||||
OutletID *string `form:"outlet_id,omitempty"`
|
OutletID *string `form:"outlet_id,omitempty"`
|
||||||
DateFrom string `form:"date_from" validate:"required"`
|
DateFrom string `form:"date_from" validate:"required"`
|
||||||
DateTo string `form:"date_to" validate:"required"`
|
DateTo string `form:"date_to" validate:"required"`
|
||||||
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
|
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month outlet_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PurchasingAnalyticsResponse struct {
|
type PurchasingAnalyticsResponse struct {
|
||||||
@@ -102,6 +102,7 @@ type PurchasingAnalyticsResponse struct {
|
|||||||
GroupBy string `json:"group_by"`
|
GroupBy string `json:"group_by"`
|
||||||
Summary PurchasingSummary `json:"summary"`
|
Summary PurchasingSummary `json:"summary"`
|
||||||
Data []PurchasingAnalyticsData `json:"data"`
|
Data []PurchasingAnalyticsData `json:"data"`
|
||||||
|
OutletData []PurchasingOutletData `json:"outlet_data,omitempty"`
|
||||||
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
||||||
VendorData []PurchasingVendorData `json:"vendor_data"`
|
VendorData []PurchasingVendorData `json:"vendor_data"`
|
||||||
}
|
}
|
||||||
@@ -132,6 +133,20 @@ type PurchasingAnalyticsData struct {
|
|||||||
Vendors int64 `json:"vendors"`
|
Vendors int64 `json:"vendors"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PurchasingOutletData struct {
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName string `json:"outlet_name"`
|
||||||
|
Purchases float64 `json:"purchases"`
|
||||||
|
RawMaterialPurchases float64 `json:"raw_material_purchases"`
|
||||||
|
ExpensePurchases float64 `json:"expense_purchases"`
|
||||||
|
PurchaseOrders int64 `json:"purchase_orders"`
|
||||||
|
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
|
||||||
|
ExpenseCount int64 `json:"expense_count"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Ingredients int64 `json:"ingredients"`
|
||||||
|
Vendors int64 `json:"vendors"`
|
||||||
|
}
|
||||||
|
|
||||||
type PurchasingIngredientData struct {
|
type PurchasingIngredientData struct {
|
||||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||||
IngredientName string `json:"ingredient_name"`
|
IngredientName string `json:"ingredient_name"`
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type PurchasingAnalytics struct {
|
|||||||
OutletName *string `json:"outlet_name,omitempty"`
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
Summary PurchasingSummary `json:"summary"`
|
Summary PurchasingSummary `json:"summary"`
|
||||||
Data []PurchasingAnalyticsData `json:"data"`
|
Data []PurchasingAnalyticsData `json:"data"`
|
||||||
|
OutletData []PurchasingOutletData `json:"outlet_data,omitempty"`
|
||||||
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
||||||
VendorData []PurchasingVendorData `json:"vendor_data"`
|
VendorData []PurchasingVendorData `json:"vendor_data"`
|
||||||
}
|
}
|
||||||
@@ -62,6 +63,20 @@ type PurchasingAnalyticsData struct {
|
|||||||
Vendors int64 `json:"vendors"`
|
Vendors int64 `json:"vendors"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PurchasingOutletData struct {
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName string `json:"outlet_name"`
|
||||||
|
Purchases float64 `json:"purchases"`
|
||||||
|
RawMaterialPurchases float64 `json:"raw_material_purchases"`
|
||||||
|
ExpensePurchases float64 `json:"expense_purchases"`
|
||||||
|
PurchaseOrders int64 `json:"purchase_orders"`
|
||||||
|
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
|
||||||
|
ExpenseCount int64 `json:"expense_count"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Ingredients int64 `json:"ingredients"`
|
||||||
|
Vendors int64 `json:"vendors"`
|
||||||
|
}
|
||||||
|
|
||||||
type PurchasingIngredientData struct {
|
type PurchasingIngredientData struct {
|
||||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||||
IngredientName string `json:"ingredient_name"`
|
IngredientName string `json:"ingredient_name"`
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ type PurchasingAnalyticsRequest struct {
|
|||||||
OutletID *uuid.UUID `validate:"omitempty"`
|
OutletID *uuid.UUID `validate:"omitempty"`
|
||||||
DateFrom time.Time `validate:"required"`
|
DateFrom time.Time `validate:"required"`
|
||||||
DateTo time.Time `validate:"required"`
|
DateTo time.Time `validate:"required"`
|
||||||
GroupBy string `validate:"omitempty,oneof=day hour week month"`
|
GroupBy string `validate:"omitempty,oneof=day hour week month outlet_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PurchasingAnalyticsResponse represents the response for purchasing analytics
|
// PurchasingAnalyticsResponse represents the response for purchasing analytics
|
||||||
@@ -108,6 +108,7 @@ type PurchasingAnalyticsResponse struct {
|
|||||||
GroupBy string `json:"group_by"`
|
GroupBy string `json:"group_by"`
|
||||||
Summary PurchasingSummary `json:"summary"`
|
Summary PurchasingSummary `json:"summary"`
|
||||||
Data []PurchasingAnalyticsData `json:"data"`
|
Data []PurchasingAnalyticsData `json:"data"`
|
||||||
|
OutletData []PurchasingOutletData `json:"outlet_data,omitempty"`
|
||||||
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
||||||
VendorData []PurchasingVendorData `json:"vendor_data"`
|
VendorData []PurchasingVendorData `json:"vendor_data"`
|
||||||
}
|
}
|
||||||
@@ -140,6 +141,20 @@ type PurchasingAnalyticsData struct {
|
|||||||
Vendors int64 `json:"vendors"`
|
Vendors int64 `json:"vendors"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PurchasingOutletData struct {
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName string `json:"outlet_name"`
|
||||||
|
Purchases float64 `json:"purchases"`
|
||||||
|
RawMaterialPurchases float64 `json:"raw_material_purchases"`
|
||||||
|
ExpensePurchases float64 `json:"expense_purchases"`
|
||||||
|
PurchaseOrders int64 `json:"purchase_orders"`
|
||||||
|
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
|
||||||
|
ExpenseCount int64 `json:"expense_count"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Ingredients int64 `json:"ingredients"`
|
||||||
|
Vendors int64 `json:"vendors"`
|
||||||
|
}
|
||||||
|
|
||||||
// PurchasingIngredientData represents purchasing analytics for an ingredient
|
// PurchasingIngredientData represents purchasing analytics for an ingredient
|
||||||
type PurchasingIngredientData struct {
|
type PurchasingIngredientData struct {
|
||||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||||
|
|||||||
@@ -218,6 +218,23 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
outletData := make([]models.PurchasingOutletData, len(result.OutletData))
|
||||||
|
for i, item := range result.OutletData {
|
||||||
|
outletData[i] = models.PurchasingOutletData{
|
||||||
|
OutletID: item.OutletID,
|
||||||
|
OutletName: item.OutletName,
|
||||||
|
Purchases: item.Purchases,
|
||||||
|
RawMaterialPurchases: item.RawMaterialPurchases,
|
||||||
|
ExpensePurchases: item.ExpensePurchases,
|
||||||
|
PurchaseOrders: item.PurchaseOrders,
|
||||||
|
RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders,
|
||||||
|
ExpenseCount: item.ExpenseCount,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
Ingredients: item.Ingredients,
|
||||||
|
Vendors: item.Vendors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ingredientData := make([]models.PurchasingIngredientData, len(result.IngredientData))
|
ingredientData := make([]models.PurchasingIngredientData, len(result.IngredientData))
|
||||||
for i, item := range result.IngredientData {
|
for i, item := range result.IngredientData {
|
||||||
ingredientData[i] = models.PurchasingIngredientData{
|
ingredientData[i] = models.PurchasingIngredientData{
|
||||||
@@ -262,6 +279,7 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
|
|||||||
TotalVendors: result.Summary.TotalVendors,
|
TotalVendors: result.Summary.TotalVendors,
|
||||||
},
|
},
|
||||||
Data: data,
|
Data: data,
|
||||||
|
OutletData: outletData,
|
||||||
IngredientData: ingredientData,
|
IngredientData: ingredientData,
|
||||||
VendorData: vendorData,
|
VendorData: vendorData,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -145,6 +145,52 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T)
|
|||||||
require.Equal(t, float64(175), result.Data[0].ExpensePurchases)
|
require.Equal(t, float64(175), result.Data[0].ExpensePurchases)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAnalyticsProcessorGetPurchasingAnalyticsMapsOutletData(t *testing.T) {
|
||||||
|
outletID := uuid.New()
|
||||||
|
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
|
||||||
|
purchasingResult: &entities.PurchasingAnalytics{
|
||||||
|
Summary: entities.PurchasingSummary{
|
||||||
|
TotalPurchases: 500,
|
||||||
|
},
|
||||||
|
OutletData: []entities.PurchasingOutletData{
|
||||||
|
{
|
||||||
|
OutletID: &outletID,
|
||||||
|
OutletName: "Outlet A",
|
||||||
|
Purchases: 500,
|
||||||
|
RawMaterialPurchases: 350,
|
||||||
|
ExpensePurchases: 150,
|
||||||
|
PurchaseOrders: 4,
|
||||||
|
RawMaterialPurchaseOrders: 3,
|
||||||
|
ExpenseCount: 2,
|
||||||
|
Quantity: 10,
|
||||||
|
Ingredients: 5,
|
||||||
|
Vendors: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, expenseRepositoryStub{})
|
||||||
|
|
||||||
|
result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
DateFrom: now,
|
||||||
|
DateTo: now,
|
||||||
|
GroupBy: "outlet_id",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Equal(t, "outlet_id", result.GroupBy)
|
||||||
|
require.Empty(t, result.Data)
|
||||||
|
require.Len(t, result.OutletData, 1)
|
||||||
|
require.Equal(t, &outletID, result.OutletData[0].OutletID)
|
||||||
|
require.Equal(t, "Outlet A", result.OutletData[0].OutletName)
|
||||||
|
require.Equal(t, float64(500), result.OutletData[0].Purchases)
|
||||||
|
require.Equal(t, float64(350), result.OutletData[0].RawMaterialPurchases)
|
||||||
|
require.Equal(t, float64(150), result.OutletData[0].ExpensePurchases)
|
||||||
|
require.Equal(t, int64(4), result.OutletData[0].PurchaseOrders)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) {
|
func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) {
|
||||||
productID := uuid.New()
|
productID := uuid.New()
|
||||||
categoryID := uuid.New()
|
categoryID := uuid.New()
|
||||||
|
|||||||
@@ -224,6 +224,39 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
|
|||||||
}
|
}
|
||||||
|
|
||||||
var data []entities.PurchasingAnalyticsData
|
var data []entities.PurchasingAnalyticsData
|
||||||
|
var outletData []entities.PurchasingOutletData
|
||||||
|
if groupBy == "outlet_id" {
|
||||||
|
outletQuery := r.db.WithContext(ctx).
|
||||||
|
Table("purchase_orders po").
|
||||||
|
Select(`
|
||||||
|
po.outlet_id as outlet_id,
|
||||||
|
COALESCE(o.name, 'No Outlet') as outlet_name,
|
||||||
|
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 outlets o ON po.outlet_id = o.id").
|
||||||
|
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("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
|
||||||
|
Group("po.outlet_id, COALESCE(o.name, 'No Outlet')").
|
||||||
|
Order("outlet_name ASC")
|
||||||
|
outletQuery = r.applyPurchaseOrderItemOutletFilter(outletQuery, outletID)
|
||||||
|
|
||||||
|
if err := outletQuery.Scan(&outletData).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
dataQuery := r.db.WithContext(ctx).
|
dataQuery := r.db.WithContext(ctx).
|
||||||
Table("purchase_orders po").
|
Table("purchase_orders po").
|
||||||
Select(`
|
Select(`
|
||||||
@@ -253,6 +286,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
|
|||||||
if err := dataQuery.Scan(&data).Error; err != nil {
|
if err := dataQuery.Scan(&data).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var ingredientData []entities.PurchasingIngredientData
|
var ingredientData []entities.PurchasingIngredientData
|
||||||
ingredientQuery := r.db.WithContext(ctx).
|
ingredientQuery := r.db.WithContext(ctx).
|
||||||
@@ -315,6 +349,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
|
|||||||
OutletName: outletName,
|
OutletName: outletName,
|
||||||
Summary: summary,
|
Summary: summary,
|
||||||
Data: data,
|
Data: data,
|
||||||
|
OutletData: outletData,
|
||||||
IngredientData: ingredientData,
|
IngredientData: ingredientData,
|
||||||
VendorData: vendorData,
|
VendorData: vendorData,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ func (s *AnalyticsServiceImpl) validatePurchasingAnalyticsRequest(req *models.Pu
|
|||||||
"hour": true,
|
"hour": true,
|
||||||
"week": true,
|
"week": true,
|
||||||
"month": true,
|
"month": true,
|
||||||
|
"outlet_id": true,
|
||||||
}
|
}
|
||||||
if !validGroupBy[req.GroupBy] {
|
if !validGroupBy[req.GroupBy] {
|
||||||
return fmt.Errorf("invalid group_by value: %s", req.GroupBy)
|
return fmt.Errorf("invalid group_by value: %s", req.GroupBy)
|
||||||
|
|||||||
@@ -132,6 +132,21 @@ func TestAnalyticsServiceGetPurchasingAnalyticsAllowsEmptyGroupBy(t *testing.T)
|
|||||||
require.NotNil(t, resp)
|
require.NotNil(t, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAnalyticsServiceGetPurchasingAnalyticsAllowsOutletGroupBy(t *testing.T) {
|
||||||
|
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
|
||||||
|
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
resp, err := service.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
DateFrom: now,
|
||||||
|
DateTo: now,
|
||||||
|
GroupBy: "outlet_id",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAnalyticsServiceGetProfitLossAnalyticsValidation(t *testing.T) {
|
func TestAnalyticsServiceGetProfitLossAnalyticsValidation(t *testing.T) {
|
||||||
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
|
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
|
||||||
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
|||||||
@@ -184,6 +184,23 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
outletData := make([]contract.PurchasingOutletData, len(resp.OutletData))
|
||||||
|
for i, item := range resp.OutletData {
|
||||||
|
outletData[i] = contract.PurchasingOutletData{
|
||||||
|
OutletID: item.OutletID,
|
||||||
|
OutletName: item.OutletName,
|
||||||
|
Purchases: item.Purchases,
|
||||||
|
RawMaterialPurchases: item.RawMaterialPurchases,
|
||||||
|
ExpensePurchases: item.ExpensePurchases,
|
||||||
|
PurchaseOrders: item.PurchaseOrders,
|
||||||
|
RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders,
|
||||||
|
ExpenseCount: item.ExpenseCount,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
Ingredients: item.Ingredients,
|
||||||
|
Vendors: item.Vendors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ingredientData := make([]contract.PurchasingIngredientData, len(resp.IngredientData))
|
ingredientData := make([]contract.PurchasingIngredientData, len(resp.IngredientData))
|
||||||
for i, item := range resp.IngredientData {
|
for i, item := range resp.IngredientData {
|
||||||
ingredientData[i] = contract.PurchasingIngredientData{
|
ingredientData[i] = contract.PurchasingIngredientData{
|
||||||
@@ -228,6 +245,7 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse
|
|||||||
TotalVendors: resp.Summary.TotalVendors,
|
TotalVendors: resp.Summary.TotalVendors,
|
||||||
},
|
},
|
||||||
Data: data,
|
Data: data,
|
||||||
|
OutletData: outletData,
|
||||||
IngredientData: ingredientData,
|
IngredientData: ingredientData,
|
||||||
VendorData: vendorData,
|
VendorData: vendorData,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,40 @@ func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) {
|
|||||||
require.NotContains(t, string(payload), "outlet_name")
|
require.NotContains(t, string(payload), "outlet_name")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPurchasingAnalyticsModelToContractCopiesOutletData(t *testing.T) {
|
||||||
|
outletID := uuid.New()
|
||||||
|
|
||||||
|
result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
GroupBy: "outlet_id",
|
||||||
|
OutletData: []models.PurchasingOutletData{
|
||||||
|
{
|
||||||
|
OutletID: &outletID,
|
||||||
|
OutletName: "Outlet A",
|
||||||
|
Purchases: 500,
|
||||||
|
RawMaterialPurchases: 350,
|
||||||
|
ExpensePurchases: 150,
|
||||||
|
PurchaseOrders: 4,
|
||||||
|
RawMaterialPurchaseOrders: 3,
|
||||||
|
ExpenseCount: 2,
|
||||||
|
Quantity: 10,
|
||||||
|
Ingredients: 5,
|
||||||
|
Vendors: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Equal(t, "outlet_id", result.GroupBy)
|
||||||
|
require.Len(t, result.OutletData, 1)
|
||||||
|
require.Equal(t, &outletID, result.OutletData[0].OutletID)
|
||||||
|
require.Equal(t, "Outlet A", result.OutletData[0].OutletName)
|
||||||
|
require.Equal(t, float64(500), result.OutletData[0].Purchases)
|
||||||
|
require.Equal(t, float64(350), result.OutletData[0].RawMaterialPurchases)
|
||||||
|
require.Equal(t, float64(150), result.OutletData[0].ExpensePurchases)
|
||||||
|
require.Equal(t, int64(4), result.OutletData[0].PurchaseOrders)
|
||||||
|
}
|
||||||
|
|
||||||
func TestProfitLossAnalyticsContractToModelParsesDateRange(t *testing.T) {
|
func TestProfitLossAnalyticsContractToModelParsesDateRange(t *testing.T) {
|
||||||
orgID := uuid.New()
|
orgID := uuid.New()
|
||||||
outletID := uuid.New().String()
|
outletID := uuid.New().String()
|
||||||
|
|||||||
Reference in New Issue
Block a user