Compare commits
92
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b42d141927 | ||
|
|
e6078e3c0b | ||
|
|
dbc143954c | ||
|
|
0726fcecf0 | ||
|
|
9ae5be2c33 | ||
|
|
a230199ce0 | ||
|
|
4f7e774043 | ||
|
|
793ef10ce8 | ||
|
|
a7c2d6cbb3 | ||
|
|
1d412959d7 | ||
|
|
b9ac97178f | ||
|
|
7b46da7007 | ||
|
|
2b80c92caa | ||
|
|
f7dd0bd5e8 | ||
|
|
1533914e4d | ||
|
|
bfce4b865b | ||
|
|
581e4a5453 | ||
|
|
9b0fc9a63b | ||
|
|
793919cf10 | ||
|
|
25024c210a | ||
|
|
3977370079 | ||
|
|
37bcb90ab0 | ||
|
|
e345aeee97 | ||
|
|
486d94335b | ||
|
|
7d5acb33e8 | ||
|
|
2138b44c53 | ||
|
|
503fb5734f | ||
|
|
ac06a4bbe9 | ||
|
|
87540fa1b7 | ||
|
|
66d4c9f0af | ||
|
|
55119b3e91 | ||
|
|
67a5c076e7 | ||
|
|
c1d859ebdd | ||
|
|
7a2060efdc | ||
|
|
2ad9e2f85f | ||
|
|
a8d62bc5e8 | ||
|
|
8816e4addc | ||
|
|
2921631ac3 | ||
|
|
0db838e2c4 | ||
|
|
4b6cbb69c1 | ||
|
|
9e0ba0ce56 | ||
|
|
6c19876a47 | ||
|
|
b2db56f855 | ||
|
|
8c4d9c69d0 | ||
|
|
657a201fc0 | ||
|
|
7a7ac25dcf | ||
|
|
d0a548f44e | ||
|
|
f4172fcea7 | ||
|
|
d5216e7994 | ||
|
|
1718c5adab | ||
|
|
d0c090a657 | ||
|
|
c3db919531 | ||
|
|
e09feff36d | ||
|
|
e7dd9660da | ||
|
|
c57620beeb | ||
|
|
29aeb58fc0 | ||
|
|
69d8c8ce5e | ||
|
|
6e3fc43d86 | ||
|
|
021ec152e9 | ||
|
|
ea9dceb333 | ||
|
|
afa1aa5b75 | ||
|
|
328336ea5a | ||
|
|
094e8b2a47 | ||
|
|
b90a3cde4a | ||
|
|
7c8c7fb7db | ||
|
|
343aa25230 | ||
|
|
47fa21d739 | ||
|
|
dc13bb5f93 | ||
|
|
d26f5c5354 | ||
|
|
1b7bec4f81 | ||
|
|
f7399fd0e7 | ||
|
|
cd61ad0eb9 | ||
|
|
84222fc7f4 | ||
|
|
23ac572e3f | ||
|
|
66a8126da0 | ||
|
|
a55a3f4ee2 | ||
|
|
024d9ee637 | ||
|
|
957c1ae53d | ||
|
|
b8be29e110 | ||
|
|
da87d659df | ||
|
|
d0378b5ac4 | ||
|
|
91960f0e57 | ||
|
|
72f67cb519 | ||
|
|
35c4cf2f2f | ||
|
|
c9ef90f5ea | ||
|
|
d9b51a7616 | ||
|
|
b27e40b531 | ||
|
|
44aca7641f | ||
|
|
a89ff00d94 | ||
|
|
227f11359c | ||
|
|
7a737d7f83 | ||
|
|
312ea94e62 |
@@ -9,3 +9,7 @@ vendor
|
||||
|
||||
# Firebase service account credentials
|
||||
infra/firebase-service-account.json
|
||||
|
||||
# Config files containing secrets (manage manually on each server)
|
||||
# infra/production.yaml
|
||||
# infra/staging.yaml
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -1,9 +1,21 @@
|
||||
#PROJECT_NAME = "enaklo-pos-backend"
|
||||
DB_USERNAME :=apskel
|
||||
DB_PASSWORD :=7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk
|
||||
DB_HOST :=62.72.45.250
|
||||
DB_PORT :=5433
|
||||
DB_NAME :=apskel_pos
|
||||
|
||||
# ─── Environment (default: staging) ──────────────────────────────────────────
|
||||
ENV ?= staging
|
||||
|
||||
ifeq ($(ENV),production)
|
||||
DB_USERNAME :=apskel
|
||||
DB_PASSWORD :=7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk
|
||||
DB_HOST :=62.72.45.250
|
||||
DB_PORT :=5433
|
||||
DB_NAME :=apskel_pos
|
||||
else
|
||||
DB_USERNAME :=apskel
|
||||
DB_PASSWORD :=7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk
|
||||
DB_HOST :=62.72.45.250
|
||||
DB_PORT :=5433
|
||||
DB_NAME :=apskel_pos_staging
|
||||
endif
|
||||
|
||||
DB_URL = postgres://$(DB_USERNAME):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=disable
|
||||
|
||||
@@ -16,15 +28,19 @@ endif
|
||||
.SILENT: help
|
||||
help:
|
||||
@echo
|
||||
@echo "Usage: make [command]"
|
||||
@echo "Usage: make [command] [ENV=staging|production]"
|
||||
@echo
|
||||
@echo "Commands:"
|
||||
@echo " run Run server (default: staging)"
|
||||
@echo " run ENV=production Run server with production config"
|
||||
@echo
|
||||
@echo " rename-project name={name} Rename project"
|
||||
@echo
|
||||
@echo " build-http Build http server"
|
||||
@echo
|
||||
@echo " migration-create name={name} Create migration"
|
||||
@echo " migration-up Up migrations"
|
||||
@echo " migration-up ENV=production Up migrations (production DB)"
|
||||
@echo " migration-down Down last migration"
|
||||
@echo
|
||||
@echo " docker-up Up docker services"
|
||||
@@ -83,6 +99,12 @@ migration-up:
|
||||
migration-down:
|
||||
@migrate -database $(DB_URL) -path ./migrations down 1
|
||||
|
||||
# Force migration to specific version
|
||||
|
||||
.SILENT: migration-force
|
||||
migration-force:
|
||||
@migrate -database $(DB_URL) -path ./migrations force $(version)
|
||||
|
||||
.SILENT: seeder-create
|
||||
seeder-create:
|
||||
@migrate create -ext sql -dir ./seeders -seq $(name)
|
||||
@@ -108,7 +130,11 @@ fmt:
|
||||
@go fmt ./...
|
||||
|
||||
start:
|
||||
go run main.go --env-path .env
|
||||
ENV_MODE=$(ENV) go run cmd/server/main.go
|
||||
|
||||
.SILENT: run
|
||||
run:
|
||||
ENV_MODE=$(ENV) go run cmd/server/main.go
|
||||
|
||||
# Default
|
||||
|
||||
|
||||
+2
-1
@@ -12,13 +12,14 @@ import (
|
||||
const (
|
||||
YAML_PATH = "infra/%s"
|
||||
ENV_MODE = "ENV_MODE"
|
||||
DEFAULT_ENV_MODE = "development"
|
||||
DEFAULT_ENV_MODE = "staging"
|
||||
)
|
||||
|
||||
var (
|
||||
validEnvMode = map[string]struct{}{
|
||||
"local": {},
|
||||
"development": {},
|
||||
"staging": {},
|
||||
"production": {},
|
||||
}
|
||||
)
|
||||
|
||||
+46
-8
@@ -2,23 +2,61 @@
|
||||
set -euo pipefail
|
||||
|
||||
APP_NAME="apskel-pos"
|
||||
PORT="4000"
|
||||
|
||||
# ─── Deteksi environment dari branch aktif ───────────────────────────────────
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
case "$CURRENT_BRANCH" in
|
||||
main)
|
||||
ENV_MODE="production"
|
||||
PORT="4000"
|
||||
;;
|
||||
staging)
|
||||
ENV_MODE="staging"
|
||||
PORT="4001"
|
||||
;;
|
||||
*)
|
||||
echo "❌ Branch '$CURRENT_BRANCH' tidak dikenali untuk deployment."
|
||||
echo " Gunakan branch 'main' (production) atau 'staging' (staging)."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
CONTAINER_NAME="$APP_NAME"
|
||||
IMAGE_NAME="$APP_NAME:$ENV_MODE"
|
||||
|
||||
echo "📦 Environment : $ENV_MODE"
|
||||
echo "🌿 Branch : $CURRENT_BRANCH"
|
||||
echo "🐳 Container : $CONTAINER_NAME"
|
||||
echo "🔌 Port : $PORT"
|
||||
echo ""
|
||||
|
||||
# ─── Pastikan config file ada ─────────────────────────────────────────────────
|
||||
CONFIG_FILE="infra/$ENV_MODE.yaml"
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo "❌ Config file '$CONFIG_FILE' tidak ditemukan."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🔄 Pulling latest code..."
|
||||
git pull
|
||||
|
||||
echo "🐳 Building Docker image (production target)..."
|
||||
docker build --target production -t $APP_NAME:latest .
|
||||
echo "🐳 Building Docker image ($ENV_MODE)..."
|
||||
docker build --target production -t "$IMAGE_NAME" .
|
||||
|
||||
echo "🛑 Stopping and removing old container..."
|
||||
docker rm -f $APP_NAME 2>/dev/null || true
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
|
||||
echo "🚀 Running new container..."
|
||||
docker run -d --name $APP_NAME \
|
||||
-p $PORT:$PORT \
|
||||
docker run -d --name "$CONTAINER_NAME" \
|
||||
-p "$PORT:4000" \
|
||||
-e TZ=Asia/Jakarta \
|
||||
-e ENV_MODE="$ENV_MODE" \
|
||||
-v "$(pwd)/infra":/infra:ro \
|
||||
-v "$(pwd)/templates":/templates:ro \
|
||||
$APP_NAME:latest
|
||||
"$IMAGE_NAME"
|
||||
|
||||
echo "✅ Deployment complete."
|
||||
echo ""
|
||||
echo "✅ Deployment $ENV_MODE complete."
|
||||
echo " Container : $CONTAINER_NAME"
|
||||
echo " Port : $PORT"
|
||||
|
||||
@@ -351,6 +351,8 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
|
||||
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
@@ -380,7 +382,6 @@ go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
|
||||
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
server:
|
||||
base-url:
|
||||
local-url:
|
||||
self-order-url: http://localhost:5173
|
||||
self-order-url:
|
||||
port: 4000
|
||||
|
||||
jwt:
|
||||
@@ -9,7 +9,7 @@ jwt:
|
||||
expires-ttl: 144000
|
||||
secret: "5Lm25V3Qd7aut8dr4QUxm5PZUrSFs"
|
||||
refresh_token:
|
||||
expires-ttl: 7776000 # 3 months in minutes (90 days * 24 hours * 60 minutes)
|
||||
expires-ttl: 7776000
|
||||
secret: "R3fr3sh_T0k3n_S3cr3t_K3y_2024_P0S"
|
||||
customer:
|
||||
expires-ttl: 7776000
|
||||
@@ -21,7 +21,7 @@ postgresql:
|
||||
driver: postgres
|
||||
db: apskel_pos
|
||||
username: apskel
|
||||
password: '7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk'
|
||||
password: "7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk"
|
||||
ssl-mode: disable
|
||||
max-idle-connections-in-second: 600
|
||||
max-open-connections-in-second: 600
|
||||
@@ -45,11 +45,11 @@ s3:
|
||||
endpoint: sin1.contabostorage.com
|
||||
bucket_name: enaklo
|
||||
log_level: Error
|
||||
host_url: 'https://sin1.contabostorage.com/fda98c2228f246f29a7e466b86b3b9e7:'
|
||||
host_url: "https://sin1.contabostorage.com/fda98c2228f246f29a7e466b86b3b9e7:"
|
||||
|
||||
log:
|
||||
log_format: 'json'
|
||||
log_level: 'debug'
|
||||
log_format: "json"
|
||||
log_level: "info"
|
||||
|
||||
fonnte:
|
||||
api_url: "https://api.fonnte.com/send"
|
||||
@@ -58,4 +58,4 @@ fonnte:
|
||||
|
||||
fcm:
|
||||
credentials_file: "infra/firebase-service-account.json"
|
||||
project_id: "apskel-pos-v2"
|
||||
project_id: "apskel-pos-v2"
|
||||
@@ -0,0 +1,61 @@
|
||||
server:
|
||||
base-url:
|
||||
local-url:
|
||||
self-order-url:
|
||||
port: 4000
|
||||
|
||||
jwt:
|
||||
token:
|
||||
expires-ttl: 144000
|
||||
secret: "eZ7LAZJuSOGSHxb1ZYaZCkrBo5YBvc"
|
||||
refresh_token:
|
||||
expires-ttl: 7776000
|
||||
secret: "EMx2DKPtMp0jQNpLvzzCsZkoUHe0d9"
|
||||
customer:
|
||||
expires-ttl: 7776000
|
||||
secret: "layCV2rne0X57acWzSS3NxENmYJs7B"
|
||||
|
||||
postgresql:
|
||||
host: 62.72.45.250
|
||||
port: 5433
|
||||
driver: postgres
|
||||
db: apskel_pos_staging
|
||||
username: apskel
|
||||
password: "7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk"
|
||||
ssl-mode: disable
|
||||
max-idle-connections-in-second: 600
|
||||
max-open-connections-in-second: 600
|
||||
connection-max-life-time-in-second: 600
|
||||
debug: false
|
||||
|
||||
redis:
|
||||
host: 62.72.45.250
|
||||
port: 6380
|
||||
password: "CmICdmnX1EZPhVBYzQPEGw==U"
|
||||
db: 1
|
||||
dial_timeout: 5s
|
||||
read_timeout: 3s
|
||||
write_timeout: 3s
|
||||
pool_size: 10
|
||||
min_idle_connections: 5
|
||||
|
||||
s3:
|
||||
access_key_id: cf9a475e18bc7626cbdbf09709d82a64
|
||||
access_key_secret: 91f3321294d3e23035427a0ecb893ada
|
||||
endpoint: sin1.contabostorage.com
|
||||
bucket_name: enaklo
|
||||
log_level: Error
|
||||
host_url: "https://sin1.contabostorage.com/fda98c2228f246f29a7e466b86b3b9e7:"
|
||||
|
||||
log:
|
||||
log_format: "json"
|
||||
log_level: "info"
|
||||
|
||||
fonnte:
|
||||
api_url: "https://api.fonnte.com/send"
|
||||
token: "bADQrf9NTXfLZQCK2wGg"
|
||||
timeout: 30
|
||||
|
||||
fcm:
|
||||
credentials_file: "infra/firebase-service-account.json"
|
||||
project_id: "apskel-pos-v2"
|
||||
+27
-4
@@ -48,6 +48,7 @@ func (a *App) Initialize(cfg *config.Config) error {
|
||||
// Initialize omset milestone scheduler
|
||||
a.omsetScheduler = service.NewOmsetMilestoneScheduler(
|
||||
repos.organizationRepo,
|
||||
repos.outletRepo,
|
||||
repos.userRepo,
|
||||
processors.notificationProcessor,
|
||||
)
|
||||
@@ -107,6 +108,8 @@ func (a *App) Initialize(cfg *config.Config) error {
|
||||
validators.vendorValidator,
|
||||
services.purchaseOrderService,
|
||||
validators.purchaseOrderValidator,
|
||||
services.purchaseCategoryService,
|
||||
validators.purchaseCategoryValidator,
|
||||
services.unitConverterService,
|
||||
validators.unitConverterValidator,
|
||||
services.chartOfAccountTypeService,
|
||||
@@ -135,15 +138,18 @@ func (a *App) Initialize(cfg *config.Config) error {
|
||||
services.productOutletPriceService,
|
||||
validators.productOutletPriceValidator,
|
||||
selfOrderHandler,
|
||||
services.expenseService,
|
||||
validators.expenseValidator,
|
||||
a.redisClient,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) Start(port string) error {
|
||||
// Start the omset milestone scheduler (checks every hour)
|
||||
// Start the omset milestone scheduler (checks every 5 minutes for daily omset milestones)
|
||||
if a.omsetScheduler != nil {
|
||||
a.omsetScheduler.Start(1 * time.Hour)
|
||||
a.omsetScheduler.Start(5 * time.Minute)
|
||||
}
|
||||
|
||||
engine := a.router.Init()
|
||||
@@ -212,6 +218,7 @@ type repositories struct {
|
||||
productRecipeRepo *repository.ProductRecipeRepository
|
||||
vendorRepo *repository.VendorRepositoryImpl
|
||||
purchaseOrderRepo *repository.PurchaseOrderRepositoryImpl
|
||||
purchaseCategoryRepo *repository.PurchaseCategoryRepositoryImpl
|
||||
unitConverterRepo *repository.IngredientUnitConverterRepositoryImpl
|
||||
chartOfAccountTypeRepo *repository.ChartOfAccountTypeRepositoryImpl
|
||||
chartOfAccountRepo *repository.ChartOfAccountRepositoryImpl
|
||||
@@ -236,6 +243,7 @@ type repositories struct {
|
||||
notificationReceiverRepo *repository.NotificationReceiverRepositoryImpl
|
||||
notificationDeliveryRepo *repository.NotificationDeliveryRepositoryImpl
|
||||
productOutletPriceRepo *repository.ProductOutletPriceRepositoryImpl
|
||||
expenseRepo *repository.ExpenseRepositoryImpl
|
||||
}
|
||||
|
||||
func (a *App) initRepositories() *repositories {
|
||||
@@ -264,6 +272,7 @@ func (a *App) initRepositories() *repositories {
|
||||
productRecipeRepo: repository.NewProductRecipeRepository(a.db),
|
||||
vendorRepo: repository.NewVendorRepositoryImpl(a.db),
|
||||
purchaseOrderRepo: repository.NewPurchaseOrderRepositoryImpl(a.db),
|
||||
purchaseCategoryRepo: repository.NewPurchaseCategoryRepositoryImpl(a.db),
|
||||
unitConverterRepo: repository.NewIngredientUnitConverterRepositoryImpl(a.db).(*repository.IngredientUnitConverterRepositoryImpl),
|
||||
chartOfAccountTypeRepo: repository.NewChartOfAccountTypeRepositoryImpl(a.db),
|
||||
chartOfAccountRepo: repository.NewChartOfAccountRepositoryImpl(a.db),
|
||||
@@ -288,6 +297,7 @@ func (a *App) initRepositories() *repositories {
|
||||
notificationReceiverRepo: repository.NewNotificationReceiverRepository(a.db),
|
||||
notificationDeliveryRepo: repository.NewNotificationDeliveryRepository(a.db),
|
||||
productOutletPriceRepo: repository.NewProductOutletPriceRepositoryImpl(a.db),
|
||||
expenseRepo: repository.NewExpenseRepositoryImpl(a.db),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,6 +321,7 @@ type processors struct {
|
||||
productRecipeProcessor *processor.ProductRecipeProcessorImpl
|
||||
vendorProcessor *processor.VendorProcessorImpl
|
||||
purchaseOrderProcessor *processor.PurchaseOrderProcessorImpl
|
||||
purchaseCategoryProcessor *processor.PurchaseCategoryProcessorImpl
|
||||
unitConverterProcessor *processor.IngredientUnitConverterProcessorImpl
|
||||
chartOfAccountTypeProcessor *processor.ChartOfAccountTypeProcessorImpl
|
||||
chartOfAccountProcessor *processor.ChartOfAccountProcessorImpl
|
||||
@@ -333,6 +344,7 @@ type processors struct {
|
||||
userDeviceProcessor *processor.UserDeviceProcessorImpl
|
||||
notificationProcessor *processor.NotificationProcessorImpl
|
||||
productOutletPriceProcessor processor.ProductOutletPriceProcessor
|
||||
expenseProcessor *processor.ExpenseProcessorImpl
|
||||
}
|
||||
|
||||
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
|
||||
@@ -354,13 +366,14 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo),
|
||||
fileProcessor: processor.NewFileProcessorImpl(repos.fileRepo, fileClient),
|
||||
customerProcessor: processor.NewCustomerProcessor(repos.customerRepo),
|
||||
analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo),
|
||||
analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo, repos.expenseRepo),
|
||||
tableProcessor: processor.NewTableProcessor(repos.tableRepo, repos.orderRepo),
|
||||
unitProcessor: processor.NewUnitProcessor(repos.unitRepo),
|
||||
ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo),
|
||||
productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo),
|
||||
vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo),
|
||||
purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo),
|
||||
purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.purchaseCategoryRepo, repos.categoryRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo),
|
||||
purchaseCategoryProcessor: processor.NewPurchaseCategoryProcessorImpl(repos.purchaseCategoryRepo),
|
||||
unitConverterProcessor: processor.NewIngredientUnitConverterProcessorImpl(repos.unitConverterRepo, repos.ingredientRepo, repos.unitRepo),
|
||||
chartOfAccountTypeProcessor: processor.NewChartOfAccountTypeProcessorImpl(repos.chartOfAccountTypeRepo),
|
||||
chartOfAccountProcessor: processor.NewChartOfAccountProcessorImpl(repos.chartOfAccountRepo, repos.chartOfAccountTypeRepo),
|
||||
@@ -383,6 +396,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
userDeviceProcessor: processor.NewUserDeviceProcessorImpl(repos.userDeviceRepo),
|
||||
notificationProcessor: buildNotificationProcessor(cfg, repos),
|
||||
productOutletPriceProcessor: processor.NewProductOutletPriceProcessorImpl(repos.productOutletPriceRepo, repos.productRepo, repos.outletRepo),
|
||||
expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,6 +422,7 @@ type services struct {
|
||||
productRecipeService *service.ProductRecipeServiceImpl
|
||||
vendorService *service.VendorServiceImpl
|
||||
purchaseOrderService *service.PurchaseOrderServiceImpl
|
||||
purchaseCategoryService service.PurchaseCategoryService
|
||||
unitConverterService *service.IngredientUnitConverterServiceImpl
|
||||
chartOfAccountTypeService service.ChartOfAccountTypeService
|
||||
chartOfAccountService service.ChartOfAccountService
|
||||
@@ -422,6 +437,7 @@ type services struct {
|
||||
userDeviceService service.UserDeviceService
|
||||
notificationService service.NotificationService
|
||||
productOutletPriceService service.ProductOutletPriceService
|
||||
expenseService *service.ExpenseServiceImpl
|
||||
}
|
||||
|
||||
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
|
||||
@@ -446,6 +462,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
||||
productRecipeService := service.NewProductRecipeService(processors.productRecipeProcessor)
|
||||
vendorService := service.NewVendorService(processors.vendorProcessor)
|
||||
purchaseOrderService := service.NewPurchaseOrderService(processors.purchaseOrderProcessor)
|
||||
purchaseCategoryService := service.NewPurchaseCategoryService(processors.purchaseCategoryProcessor)
|
||||
unitConverterService := service.NewIngredientUnitConverterService(processors.unitConverterProcessor)
|
||||
chartOfAccountTypeService := service.NewChartOfAccountTypeService(processors.chartOfAccountTypeProcessor)
|
||||
chartOfAccountService := service.NewChartOfAccountService(processors.chartOfAccountProcessor)
|
||||
@@ -485,6 +502,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
||||
productRecipeService: productRecipeService,
|
||||
vendorService: vendorService,
|
||||
purchaseOrderService: purchaseOrderService,
|
||||
purchaseCategoryService: purchaseCategoryService,
|
||||
unitConverterService: unitConverterService,
|
||||
chartOfAccountTypeService: chartOfAccountTypeService,
|
||||
chartOfAccountService: chartOfAccountService,
|
||||
@@ -499,6 +517,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
||||
userDeviceService: userDeviceService,
|
||||
notificationService: notificationService,
|
||||
productOutletPriceService: service.NewProductOutletPriceService(processors.productOutletPriceProcessor),
|
||||
expenseService: service.NewExpenseService(processors.expenseProcessor),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,6 +548,7 @@ type validators struct {
|
||||
tableValidator *validator.TableValidator
|
||||
vendorValidator *validator.VendorValidatorImpl
|
||||
purchaseOrderValidator *validator.PurchaseOrderValidatorImpl
|
||||
purchaseCategoryValidator *validator.PurchaseCategoryValidatorImpl
|
||||
unitConverterValidator *validator.IngredientUnitConverterValidatorImpl
|
||||
chartOfAccountTypeValidator *validator.ChartOfAccountTypeValidatorImpl
|
||||
chartOfAccountValidator *validator.ChartOfAccountValidatorImpl
|
||||
@@ -541,6 +561,7 @@ type validators struct {
|
||||
userDeviceValidator *validator.UserDeviceValidatorImpl
|
||||
notificationValidator *validator.NotificationValidatorImpl
|
||||
productOutletPriceValidator *validator.ProductOutletPriceValidatorImpl
|
||||
expenseValidator *validator.ExpenseValidatorImpl
|
||||
}
|
||||
|
||||
func (a *App) initValidators() *validators {
|
||||
@@ -559,6 +580,7 @@ func (a *App) initValidators() *validators {
|
||||
tableValidator: validator.NewTableValidator(),
|
||||
vendorValidator: validator.NewVendorValidator(),
|
||||
purchaseOrderValidator: validator.NewPurchaseOrderValidator(),
|
||||
purchaseCategoryValidator: validator.NewPurchaseCategoryValidator(),
|
||||
unitConverterValidator: validator.NewIngredientUnitConverterValidator().(*validator.IngredientUnitConverterValidatorImpl),
|
||||
chartOfAccountTypeValidator: validator.NewChartOfAccountTypeValidator().(*validator.ChartOfAccountTypeValidatorImpl),
|
||||
chartOfAccountValidator: validator.NewChartOfAccountValidator().(*validator.ChartOfAccountValidatorImpl),
|
||||
@@ -571,6 +593,7 @@ func (a *App) initValidators() *validators {
|
||||
userDeviceValidator: validator.NewUserDeviceValidator(),
|
||||
notificationValidator: validator.NewNotificationValidator(),
|
||||
productOutletPriceValidator: validator.NewProductOutletPriceValidator(),
|
||||
expenseValidator: validator.NewExpenseValidator(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package constants
|
||||
|
||||
// Budget allocation of revenue used by the parent category cut-off report.
|
||||
// The three shares are expected to add up to 100.
|
||||
const (
|
||||
BudgetLimitPurchasePercent = 60.0
|
||||
BudgetLimitOwnerPercent = 20.0
|
||||
BudgetLimitTeamPercent = 20.0
|
||||
)
|
||||
@@ -40,6 +40,7 @@ const (
|
||||
OutletServiceEntity = "outlet_service"
|
||||
VendorServiceEntity = "vendor_service"
|
||||
PurchaseOrderServiceEntity = "purchase_order_service"
|
||||
PurchaseCategoryServiceEntity = "purchase_category_service"
|
||||
IngredientUnitConverterServiceEntity = "ingredient_unit_converter_service"
|
||||
IngredientCompositionServiceEntity = "ingredient_composition_service"
|
||||
TableEntity = "table"
|
||||
@@ -60,6 +61,7 @@ const (
|
||||
NotificationServiceEntity = "notification_service"
|
||||
NotificationHandlerEntity = "notification_handler"
|
||||
ProductOutletPriceServiceEntity = "product_outlet_price_service"
|
||||
ExpenseServiceEntity = "expense_service"
|
||||
)
|
||||
|
||||
var HttpErrorMap = map[string]int{
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package constants
|
||||
|
||||
type ExpenseStatus string
|
||||
|
||||
const (
|
||||
ExpenseStatusDraft ExpenseStatus = "draft"
|
||||
ExpenseStatusSent ExpenseStatus = "sent"
|
||||
ExpenseStatusApproved ExpenseStatus = "approved"
|
||||
ExpenseStatusCancel ExpenseStatus = "cancel"
|
||||
)
|
||||
|
||||
func GetAllExpenseStatuses() []ExpenseStatus {
|
||||
return []ExpenseStatus{
|
||||
ExpenseStatusDraft,
|
||||
ExpenseStatusSent,
|
||||
ExpenseStatusApproved,
|
||||
ExpenseStatusCancel,
|
||||
}
|
||||
}
|
||||
|
||||
func IsValidExpenseStatus(status ExpenseStatus) bool {
|
||||
for _, validStatus := range GetAllExpenseStatuses() {
|
||||
if status == validStatus {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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"
|
||||
)
|
||||
@@ -3,11 +3,12 @@ package constants
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
RoleAdmin UserRole = "admin"
|
||||
RoleManager UserRole = "manager"
|
||||
RoleCashier UserRole = "cashier"
|
||||
RoleWaiter UserRole = "waiter"
|
||||
RoleOwner UserRole = "owner"
|
||||
RoleAdmin UserRole = "admin"
|
||||
RoleManager UserRole = "manager"
|
||||
RoleCashier UserRole = "cashier"
|
||||
RoleWaiter UserRole = "waiter"
|
||||
RoleOwner UserRole = "owner"
|
||||
RolePurchasing UserRole = "purchasing"
|
||||
)
|
||||
|
||||
func GetAllUserRoles() []UserRole {
|
||||
@@ -17,6 +18,7 @@ func GetAllUserRoles() []UserRole {
|
||||
RoleCashier,
|
||||
RoleWaiter,
|
||||
RoleOwner,
|
||||
RolePurchasing,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@ import (
|
||||
)
|
||||
|
||||
type CreateAccountRequest struct {
|
||||
ChartOfAccountID uuid.UUID `json:"chart_of_account_id" validate:"required"`
|
||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||
Number string `json:"number" validate:"required,min=1,max=50"`
|
||||
AccountType string `json:"account_type" validate:"required,oneof=cash wallet bank credit debit asset liability equity revenue expense"`
|
||||
OpeningBalance float64 `json:"opening_balance"`
|
||||
Description *string `json:"description"`
|
||||
ChartOfAccountID uuid.UUID `json:"chart_of_account_id" validate:"required"`
|
||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||
Number string `json:"number" validate:"required,min=1,max=50"`
|
||||
AccountType string `json:"account_type" validate:"required,oneof=cash wallet bank credit debit asset liability equity revenue expense"`
|
||||
OpeningBalance float64 `json:"opening_balance"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
type UpdateAccountRequest struct {
|
||||
@@ -24,21 +24,21 @@ type UpdateAccountRequest struct {
|
||||
}
|
||||
|
||||
type AccountResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
|
||||
Name string `json:"name"`
|
||||
Number string `json:"number"`
|
||||
AccountType string `json:"account_type"`
|
||||
OpeningBalance float64 `json:"opening_balance"`
|
||||
CurrentBalance float64 `json:"current_balance"`
|
||||
Description *string `json:"description"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IsSystem bool `json:"is_system"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ChartOfAccount *ChartOfAccountResponse `json:"chart_of_account,omitempty"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
|
||||
Name string `json:"name"`
|
||||
Number string `json:"number"`
|
||||
AccountType string `json:"account_type"`
|
||||
OpeningBalance float64 `json:"opening_balance"`
|
||||
CurrentBalance float64 `json:"current_balance"`
|
||||
Description *string `json:"description"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IsSystem bool `json:"is_system"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ChartOfAccount *ChartOfAccountResponse `json:"chart_of_account,omitempty"`
|
||||
}
|
||||
|
||||
type ListAccountsRequest struct {
|
||||
|
||||
@@ -18,6 +18,7 @@ type PaymentMethodAnalyticsRequest struct {
|
||||
type PaymentMethodAnalyticsResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
GroupBy string `json:"group_by"`
|
||||
@@ -54,6 +55,7 @@ type SalesAnalyticsRequest struct {
|
||||
type SalesAnalyticsResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
GroupBy string `json:"group_by"`
|
||||
@@ -86,15 +88,19 @@ type SalesAnalyticsData struct {
|
||||
type PurchasingAnalyticsRequest struct {
|
||||
OrganizationID uuid.UUID
|
||||
OutletID *string `form:"outlet_id,omitempty"`
|
||||
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"`
|
||||
// Team narrows the report to one team: a parent category id, "central" for
|
||||
// Pusat, or "none" for purchases charged to no team. Empty covers all teams.
|
||||
Team string `form:"team,omitempty"`
|
||||
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 {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
Team string `json:"team,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
GroupBy string `json:"group_by"`
|
||||
@@ -102,24 +108,48 @@ type PurchasingAnalyticsResponse struct {
|
||||
Data []PurchasingAnalyticsData `json:"data"`
|
||||
IngredientData []PurchasingIngredientData `json:"ingredient_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 {
|
||||
TotalPurchases float64 `json:"total_purchases"`
|
||||
RawMaterialPurchases float64 `json:"raw_material_purchases"`
|
||||
ExpensePurchases float64 `json:"expense_purchases"`
|
||||
TotalPurchaseOrders int64 `json:"total_purchase_orders"`
|
||||
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
|
||||
ExpenseCount int64 `json:"expense_count"`
|
||||
TotalQuantity float64 `json:"total_quantity"`
|
||||
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
|
||||
TotalIngredients int64 `json:"total_ingredients"`
|
||||
TotalVendors int64 `json:"total_vendors"`
|
||||
TotalTeams int64 `json:"total_teams"`
|
||||
}
|
||||
|
||||
type PurchasingAnalyticsData struct {
|
||||
Date time.Time `json:"date"`
|
||||
Purchases float64 `json:"purchases"`
|
||||
PurchaseOrders int64 `json:"purchase_orders"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Ingredients int64 `json:"ingredients"`
|
||||
Vendors int64 `json:"vendors"`
|
||||
Date time.Time `json:"date"`
|
||||
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 {
|
||||
@@ -132,12 +162,12 @@ type PurchasingIngredientData struct {
|
||||
}
|
||||
|
||||
type PurchasingVendorData struct {
|
||||
VendorID uuid.UUID `json:"vendor_id"`
|
||||
VendorName string `json:"vendor_name"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||
IngredientCount int64 `json:"ingredient_count"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
VendorID *uuid.UUID `json:"vendor_id"`
|
||||
VendorName string `json:"vendor_name"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||
IngredientCount int64 `json:"ingredient_count"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
// ProductAnalyticsRequest represents the request for product analytics
|
||||
@@ -153,6 +183,7 @@ type ProductAnalyticsRequest struct {
|
||||
type ProductAnalyticsResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
Data []ProductAnalyticsData `json:"data"`
|
||||
@@ -162,6 +193,7 @@ type ProductAnalyticsData struct {
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
ProductSku string `json:"product_sku"`
|
||||
ProductPrice float64 `json:"product_price"`
|
||||
CategoryID uuid.UUID `json:"category_id"`
|
||||
CategoryName string `json:"category_name"`
|
||||
CategoryOrder int `json:"category_order"`
|
||||
@@ -189,6 +221,7 @@ type ProductAnalyticsPerCategoryRequest struct {
|
||||
type ProductAnalyticsPerCategoryResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
Data []ProductAnalyticsPerCategoryData `json:"data"`
|
||||
@@ -206,6 +239,135 @@ type ProductAnalyticsPerCategoryData struct {
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
}
|
||||
|
||||
// ProductAnalyticsPerParentCategoryRequest represents the request for product analytics per parent category
|
||||
type ProductAnalyticsPerParentCategoryRequest struct {
|
||||
OrganizationID uuid.UUID
|
||||
OutletID *string `form:"outlet_id,omitempty"`
|
||||
DateFrom string `form:"date_from" validate:"required"`
|
||||
DateTo string `form:"date_to" validate:"required"`
|
||||
}
|
||||
|
||||
// ProductAnalyticsPerParentCategoryResponse represents the response for product analytics per parent category
|
||||
type ProductAnalyticsPerParentCategoryResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
Data []ProductAnalyticsPerParentCategoryData `json:"data"`
|
||||
Budget BudgetCutOff `json:"budget"`
|
||||
}
|
||||
|
||||
type ProductAnalyticsPerParentCategoryData struct {
|
||||
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||
ParentCategoryName string `json:"parent_category_name"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
CategoryCount int64 `json:"category_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
}
|
||||
|
||||
// ParentCategoryAnalyticsDetailRequest represents the request for the drill-down of one parent category
|
||||
type ParentCategoryAnalyticsDetailRequest struct {
|
||||
OrganizationID uuid.UUID
|
||||
ParentCategoryID string
|
||||
OutletID *string `form:"outlet_id,omitempty"`
|
||||
DateFrom string `form:"date_from" validate:"required"`
|
||||
DateTo string `form:"date_to" validate:"required"`
|
||||
}
|
||||
|
||||
// ParentCategoryAnalyticsDetailResponse represents the drill-down of one parent category
|
||||
type ParentCategoryAnalyticsDetailResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||
ParentCategoryName string `json:"parent_category_name"`
|
||||
Summary ParentCategoryAnalyticsDetailSummary `json:"summary"`
|
||||
Categories []ParentCategoryAnalyticsDetailData `json:"categories"`
|
||||
Budget BudgetCutOff `json:"budget"`
|
||||
}
|
||||
|
||||
type ParentCategoryAnalyticsDetailSummary struct {
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
CategoryCount int64 `json:"category_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
}
|
||||
|
||||
type ParentCategoryAnalyticsDetailData struct {
|
||||
CategoryID uuid.UUID `json:"category_id"`
|
||||
CategoryName string `json:"category_name"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
Products []ParentCategoryAnalyticsProductData `json:"products"`
|
||||
}
|
||||
|
||||
type ParentCategoryAnalyticsProductData struct {
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
ProductSku string `json:"product_sku"`
|
||||
ProductPrice float64 `json:"product_price"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
AveragePrice float64 `json:"average_price"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
StandardHppPerUnit float64 `json:"standard_hpp_per_unit"`
|
||||
StandardHppTotal float64 `json:"standard_hpp_total"`
|
||||
FifoHppPerUnit float64 `json:"fifo_hpp_per_unit"`
|
||||
FifoHppTotal float64 `json:"fifo_hpp_total"`
|
||||
MovingAverageHppPerUnit float64 `json:"moving_average_hpp_per_unit"`
|
||||
MovingAverageHppTotal float64 `json:"moving_average_hpp_total"`
|
||||
}
|
||||
|
||||
// BudgetCutOff is the Monday-to-Sunday spending limit breakdown attached to the
|
||||
// parent category reports.
|
||||
type BudgetCutOff struct {
|
||||
Percentages BudgetPercentages `json:"percentages"`
|
||||
CutOffFrom time.Time `json:"cut_off_from"`
|
||||
CutOffTo time.Time `json:"cut_off_to"`
|
||||
Total BudgetPeriod `json:"total"`
|
||||
Weekly []BudgetPeriod `json:"weekly"`
|
||||
Monthly []BudgetMonthPeriod `json:"monthly"`
|
||||
}
|
||||
|
||||
type BudgetPercentages struct {
|
||||
Purchase float64 `json:"purchase"`
|
||||
Owner float64 `json:"owner"`
|
||||
Team float64 `json:"team"`
|
||||
}
|
||||
|
||||
type BudgetPeriod struct {
|
||||
PeriodStart time.Time `json:"period_start"`
|
||||
PeriodEnd time.Time `json:"period_end"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
LimitPurchase float64 `json:"limit_purchase"`
|
||||
LimitOwner float64 `json:"limit_owner"`
|
||||
LimitTeam float64 `json:"limit_team"`
|
||||
}
|
||||
|
||||
type BudgetMonthPeriod struct {
|
||||
Month string `json:"month"`
|
||||
WeekCount int `json:"week_count"`
|
||||
BudgetPeriod
|
||||
}
|
||||
|
||||
// DashboardAnalyticsRequest represents the request for dashboard analytics
|
||||
type DashboardAnalyticsRequest struct {
|
||||
OrganizationID uuid.UUID
|
||||
@@ -218,6 +380,7 @@ type DashboardAnalyticsRequest struct {
|
||||
type DashboardAnalyticsResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
Overview DashboardOverview `json:"overview"`
|
||||
@@ -228,15 +391,17 @@ type DashboardAnalyticsResponse struct {
|
||||
|
||||
// DashboardOverview represents the overview data for dashboard
|
||||
type DashboardOverview struct {
|
||||
TotalSales float64 `json:"total_sales"`
|
||||
TotalOrders int64 `json:"total_orders"`
|
||||
AverageOrderValue float64 `json:"average_order_value"`
|
||||
TotalCustomers int64 `json:"total_customers"`
|
||||
VoidedOrders int64 `json:"voided_orders"`
|
||||
RefundedOrders int64 `json:"refunded_orders"`
|
||||
TotalSales float64 `json:"total_sales"`
|
||||
TotalOrders int64 `json:"total_orders"`
|
||||
AverageOrderValue float64 `json:"average_order_value"`
|
||||
TotalCustomers int64 `json:"total_customers"`
|
||||
VoidedOrders int64 `json:"voided_orders"`
|
||||
RefundedOrders int64 `json:"refunded_orders"`
|
||||
TotalItemSold int64 `json:"total_item_sold"`
|
||||
TotalLowStock int64 `json:"total_low_stock"`
|
||||
TotalProductActive int64 `json:"total_product_active"`
|
||||
}
|
||||
|
||||
// ProfitLossAnalyticsRequest represents the request for profit and loss analytics
|
||||
type ProfitLossAnalyticsRequest struct {
|
||||
OrganizationID uuid.UUID
|
||||
OutletID *string `form:"outlet_id,omitempty"`
|
||||
@@ -245,19 +410,39 @@ type ProfitLossAnalyticsRequest struct {
|
||||
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
|
||||
}
|
||||
|
||||
// ProfitLossAnalyticsResponse represents the response for profit and loss analytics
|
||||
type ProfitLossAnalyticsResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
GroupBy string `json:"group_by"`
|
||||
Summary ProfitLossSummary `json:"summary"`
|
||||
Data []ProfitLossData `json:"data"`
|
||||
ProductData []ProductProfitData `json:"product_data"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
GroupBy string `json:"group_by"`
|
||||
Summary ProfitLossSummary `json:"summary"`
|
||||
Data []ProfitLossData `json:"data"`
|
||||
ProductData []ProductProfitData `json:"product_data"`
|
||||
MainSummary []ProfitLossSummaryRow `json:"main_summary"`
|
||||
Purchasing ProfitLossPurchasing `json:"purchasing"`
|
||||
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
|
||||
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
||||
}
|
||||
|
||||
type ProfitLossPurchasing struct {
|
||||
TodayTotal float64 `json:"today_total"`
|
||||
MtdTotal float64 `json:"mtd_total"`
|
||||
TodayRawMaterial float64 `json:"today_raw_material"`
|
||||
MtdRawMaterial float64 `json:"mtd_raw_material"`
|
||||
TodayExpense float64 `json:"today_expense"`
|
||||
MtdExpense float64 `json:"mtd_expense"`
|
||||
Items []ProfitLossPurchasingItem `json:"items"`
|
||||
}
|
||||
|
||||
type ProfitLossPurchasingItem struct {
|
||||
Date time.Time `json:"date"`
|
||||
Item string `json:"item"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Nominal float64 `json:"nominal"`
|
||||
}
|
||||
|
||||
// ProfitLossSummary represents the summary of profit and loss analytics
|
||||
type ProfitLossSummary struct {
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
@@ -272,7 +457,6 @@ type ProfitLossSummary struct {
|
||||
ProfitabilityRatio float64 `json:"profitability_ratio"`
|
||||
}
|
||||
|
||||
// ProfitLossData represents individual profit and loss data point by time period
|
||||
type ProfitLossData struct {
|
||||
Date time.Time `json:"date"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
@@ -286,7 +470,6 @@ type ProfitLossData struct {
|
||||
Orders int64 `json:"orders"`
|
||||
}
|
||||
|
||||
// ProductProfitData represents profit data for individual products
|
||||
type ProductProfitData struct {
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
@@ -301,3 +484,139 @@ type ProductProfitData struct {
|
||||
AverageCost float64 `json:"average_cost"`
|
||||
ProfitPerUnit float64 `json:"profit_per_unit"`
|
||||
}
|
||||
|
||||
type ProfitLossSummaryRow struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
IsBold bool `json:"is_bold"`
|
||||
TodayNominal float64 `json:"today_nominal"`
|
||||
TodayPct float64 `json:"today_pct"`
|
||||
MtdNominal float64 `json:"mtd_nominal"`
|
||||
MtdPct float64 `json:"mtd_pct"`
|
||||
SubItems []ProfitLossSummaryRow `json:"sub_items,omitempty"`
|
||||
}
|
||||
|
||||
type OperationalExpenseItem struct {
|
||||
Item string `json:"item"`
|
||||
Nominal float64 `json:"nominal"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryPeriodRequest struct {
|
||||
OrganizationID uuid.UUID
|
||||
OutletID *string `form:"outlet_id,omitempty"`
|
||||
DateFrom string `form:"date_from" validate:"required"`
|
||||
DateTo string `form:"date_to" validate:"required"`
|
||||
ExcludeGajiStaffFromReimburse bool `form:"exclude_gaji_staff_from_reimburse"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryMonthlyRequest struct {
|
||||
OrganizationID uuid.UUID
|
||||
OutletID *string `form:"outlet_id,omitempty"`
|
||||
Month string `form:"month" validate:"required"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryMTDRequest struct {
|
||||
OrganizationID uuid.UUID
|
||||
OutletID *string `form:"outlet_id,omitempty"`
|
||||
DateTo string `form:"date_to" validate:"required"`
|
||||
ExcludeGajiStaffFromReimburse bool `form:"exclude_gaji_staff_from_reimburse"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryPeriodResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
Period ExclusiveSummaryPeriodRange `json:"period"`
|
||||
Summary ExclusiveSummaryPeriodSummary `json:"summary"`
|
||||
Reimburse ExclusiveSummaryReimburse `json:"reimburse"`
|
||||
HPPBreakdown []ExclusiveSummaryCategoryBreakdown `json:"hpp_breakdown"`
|
||||
OperationalExpenseBreakdown []ExclusiveSummaryCategoryBreakdown `json:"operational_expense_breakdown"`
|
||||
DailySummary []ExclusiveSummaryDailySummary `json:"daily_summary"`
|
||||
DailyTransactions []ExclusiveSummaryDailyTransaction `json:"daily_transactions"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryPeriodRange struct {
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryPeriodSummary struct {
|
||||
Sales float64 `json:"sales"`
|
||||
HPP float64 `json:"hpp"`
|
||||
GrossProfit float64 `json:"gross_profit"`
|
||||
SalaryTotal float64 `json:"salary_total"`
|
||||
SalaryDW float64 `json:"salary_dw"`
|
||||
SalaryStaff float64 `json:"salary_staff"`
|
||||
SalaryOther float64 `json:"salary_other"`
|
||||
OtherOperationalExpenses float64 `json:"other_operational_expenses"`
|
||||
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
NetProfit float64 `json:"net_profit"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryReimburse struct {
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
ExcludedSalaryStaff float64 `json:"excluded_salary_staff"`
|
||||
TotalReimburse float64 `json:"total_reimburse"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryCategoryBreakdown struct {
|
||||
CategoryCode string `json:"category_code"`
|
||||
CategoryName string `json:"category_name"`
|
||||
Amount float64 `json:"amount"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryDailySummary struct {
|
||||
Date time.Time `json:"date"`
|
||||
TransactionCount int64 `json:"transaction_count"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryDailyTransaction struct {
|
||||
Date time.Time `json:"date"`
|
||||
CategoryCode string `json:"category_code"`
|
||||
CategoryName string `json:"category_name"`
|
||||
Description string `json:"description"`
|
||||
Amount float64 `json:"amount"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryMonthlyResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
Month string `json:"month"`
|
||||
Summary ExclusiveSummaryMonthlySummary `json:"summary"`
|
||||
Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"`
|
||||
BankBalance []ExclusiveSummaryBankBalance `json:"bank_balance"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryMonthlySummary struct {
|
||||
TotalSales float64 `json:"total_sales"`
|
||||
HPP float64 `json:"hpp"`
|
||||
GrossProfit float64 `json:"gross_profit"`
|
||||
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
NetProfit float64 `json:"net_profit"`
|
||||
NetProfitMargin float64 `json:"net_profit_margin"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryMonthlyPeriod struct {
|
||||
Label string `json:"label"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
Sales float64 `json:"sales"`
|
||||
HPP float64 `json:"hpp"`
|
||||
GrossProfit float64 `json:"gross_profit"`
|
||||
GrossMargin float64 `json:"gross_margin"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryBankBalance struct {
|
||||
Bank string `json:"bank"`
|
||||
OpeningBalance *float64 `json:"opening_balance"`
|
||||
IncomingMutation *float64 `json:"incoming_mutation"`
|
||||
OutgoingMutation *float64 `json:"outgoing_mutation"`
|
||||
ClosingBalance *float64 `json:"closing_balance"`
|
||||
Notes *string `json:"notes"`
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@ type CreateCategoryRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
BusinessType *string `json:"business_type,omitempty"`
|
||||
Order *int `json:"order,omitempty"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
Order *int `json:"order,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
@@ -18,12 +20,17 @@ type UpdateCategoryRequest struct {
|
||||
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
BusinessType *string `json:"business_type,omitempty"`
|
||||
Order *int `json:"order,omitempty"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
Order *int `json:"order,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type ListCategoriesRequest struct {
|
||||
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
Type string `json:"type,omitempty" validate:"omitempty,oneof=parent child"`
|
||||
BusinessType string `json:"business_type,omitempty"`
|
||||
Search string `json:"search,omitempty"`
|
||||
Page int `json:"page" validate:"required,min=1"`
|
||||
@@ -34,10 +41,13 @@ type ListCategoriesRequest struct {
|
||||
type CategoryResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
ParentName *string `json:"parent_name,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
BusinessType string `json:"business_type"`
|
||||
Order int `json:"order"`
|
||||
Order int `json:"order"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CreateExpenseRequest struct {
|
||||
Receiver string `json:"receiver" validate:"required"`
|
||||
TransactionDate string `json:"transaction_date" validate:"required"`
|
||||
CodeNumber string `json:"code_number" validate:"required"`
|
||||
OutletID string `json:"outlet_id" validate:"required"`
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Tax float64 `json:"tax"`
|
||||
Total float64 `json:"total" validate:"required"`
|
||||
Items []CreateExpenseItemRequest `json:"items" validate:"required"`
|
||||
}
|
||||
|
||||
type CreateExpenseItemRequest struct {
|
||||
ChartOfAccountID string `json:"chart_of_account_id" validate:"required"`
|
||||
PurchaseCategoryID string `json:"purchase_category_id" validate:"required"`
|
||||
Item string `json:"item" validate:"required"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Amount float64 `json:"amount" validate:"required"`
|
||||
}
|
||||
|
||||
type UpdateExpenseRequest struct {
|
||||
Receiver *string `json:"receiver,omitempty"`
|
||||
TransactionDate *string `json:"transaction_date,omitempty"`
|
||||
CodeNumber *string `json:"code_number,omitempty"`
|
||||
OutletID *string `json:"outlet_id,omitempty"`
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Tax *float64 `json:"tax,omitempty"`
|
||||
Total *float64 `json:"total,omitempty"`
|
||||
Reserved1 *string `json:"reserved1,omitempty"`
|
||||
Items []UpdateExpenseItemRequest `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateExpenseItemRequest struct {
|
||||
ChartOfAccountID *string `json:"chart_of_account_id,omitempty"`
|
||||
PurchaseCategoryID *string `json:"purchase_category_id,omitempty"`
|
||||
Item *string `json:"item,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Amount *float64 `json:"amount,omitempty"`
|
||||
}
|
||||
|
||||
type ExpenseResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID uuid.UUID `json:"outlet_id"`
|
||||
Receiver string `json:"receiver"`
|
||||
TransactionDate time.Time `json:"transaction_date"`
|
||||
CodeNumber string `json:"code_number"`
|
||||
Status string `json:"status"`
|
||||
Description *string `json:"description"`
|
||||
Tax float64 `json:"tax"`
|
||||
Total float64 `json:"total"`
|
||||
Reserved1 *string `json:"reserved1,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Items []ExpenseItemResponse `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
type ExpenseItemResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ExpenseID uuid.UUID `json:"expense_id"`
|
||||
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
|
||||
ChartOfAccountName string `json:"chart_of_account_name,omitempty"`
|
||||
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
|
||||
PurchaseCategoryName string `json:"purchase_category_name,omitempty"`
|
||||
PurchaseCategoryType string `json:"purchase_category_type,omitempty"`
|
||||
PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
|
||||
Item string `json:"item"`
|
||||
Description *string `json:"description"`
|
||||
Amount float64 `json:"amount"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListExpenseRequest struct {
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
Search string `json:"search,omitempty"`
|
||||
OutletID string `json:"outlet_id,omitempty"`
|
||||
Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"`
|
||||
StartDate string `json:"start_date,omitempty"`
|
||||
EndDate string `json:"end_date,omitempty"`
|
||||
}
|
||||
|
||||
type ListExpenseResponse struct {
|
||||
Expenses []ExpenseResponse `json:"expenses"`
|
||||
TotalCount int `json:"total_count"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsRequest struct {
|
||||
OutletID *string `form:"outlet_id,omitempty"`
|
||||
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 ExpenseAnalyticsResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
GroupBy string `json:"group_by"`
|
||||
Summary ExpenseAnalyticsSummary `json:"summary"`
|
||||
Data []ExpenseAnalyticsData `json:"data"`
|
||||
CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"`
|
||||
ChartOfAccountData []ExpenseAnalyticsChartOfAccountData `json:"chart_of_account_data"`
|
||||
ItemData []ExpenseAnalyticsItemData `json:"item_data"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsSummary struct {
|
||||
TotalExpenses float64 `json:"total_expenses"`
|
||||
TotalExpenseCount int64 `json:"total_expense_count"`
|
||||
TotalTax float64 `json:"total_tax"`
|
||||
AverageExpenseValue float64 `json:"average_expense_value"`
|
||||
TotalCategories int64 `json:"total_categories"`
|
||||
TotalItems int64 `json:"total_items"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsData struct {
|
||||
Date time.Time `json:"date"`
|
||||
Expenses float64 `json:"expenses"`
|
||||
ExpenseCount int64 `json:"expense_count"`
|
||||
Tax float64 `json:"tax"`
|
||||
Items int64 `json:"items"`
|
||||
Categories int64 `json:"categories"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsCategoryData struct {
|
||||
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
|
||||
PurchaseCategoryName string `json:"purchase_category_name"`
|
||||
PurchaseCategoryType string `json:"purchase_category_type"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
ExpenseCount int64 `json:"expense_count"`
|
||||
ItemCount int64 `json:"item_count"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsChartOfAccountData struct {
|
||||
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
|
||||
ChartOfAccountName string `json:"chart_of_account_name"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
ExpenseCount int64 `json:"expense_count"`
|
||||
ItemCount int64 `json:"item_count"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsItemData struct {
|
||||
Item string `json:"item"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
ExpenseCount int64 `json:"expense_count"`
|
||||
ItemCount int64 `json:"item_count"`
|
||||
}
|
||||
@@ -77,8 +77,7 @@ type ListIngredientUnitConvertersResponse struct {
|
||||
type IngredientUnitsResponse struct {
|
||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||
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"`
|
||||
Units []*UnitResponse `json:"units"`
|
||||
}
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ type AdjustInventoryRequest struct {
|
||||
}
|
||||
|
||||
type RestockInventoryRequest struct {
|
||||
OutletID uuid.UUID `json:"outlet_id" validate:"required"`
|
||||
OutletID uuid.UUID `json:"outlet_id" validate:"required"`
|
||||
Items []RestockItem `json:"items" validate:"required,min=1,dive"`
|
||||
Reason string `json:"reason" validate:"required,min=1,max=255"`
|
||||
Reason string `json:"reason" validate:"required,min=1,max=255"`
|
||||
}
|
||||
|
||||
type RestockItem struct {
|
||||
@@ -82,10 +82,10 @@ type InventoryAdjustmentResponse struct {
|
||||
}
|
||||
|
||||
type RestockInventoryResponse struct {
|
||||
OutletID uuid.UUID `json:"outlet_id"`
|
||||
Items []RestockItemResult `json:"items"`
|
||||
Reason string `json:"reason"`
|
||||
RestockedAt time.Time `json:"restocked_at"`
|
||||
OutletID uuid.UUID `json:"outlet_id"`
|
||||
Items []RestockItemResult `json:"items"`
|
||||
Reason string `json:"reason"`
|
||||
RestockedAt time.Time `json:"restocked_at"`
|
||||
}
|
||||
|
||||
type RestockItemResult struct {
|
||||
|
||||
@@ -98,6 +98,8 @@ type OrderItemResponse struct {
|
||||
ProductName string `json:"product_name"`
|
||||
ProductVariantID *uuid.UUID `json:"product_variant_id"`
|
||||
ProductVariantName *string `json:"product_variant_name,omitempty"`
|
||||
CategoryID *uuid.UUID `json:"category_id,omitempty"`
|
||||
CategoryName *string `json:"category_name,omitempty"`
|
||||
Quantity int `json:"quantity"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
TotalPrice float64 `json:"total_price"`
|
||||
@@ -108,6 +110,7 @@ type OrderItemResponse struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
PrinterType string `json:"printer_type"`
|
||||
PrintToChecker bool `json:"print_to_checker"`
|
||||
PaidQuantity int `json:"paid_quantity"`
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
type CreateProductRequest struct {
|
||||
CategoryID uuid.UUID `json:"category_id" validate:"required"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
SKU *string `json:"sku,omitempty"`
|
||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
@@ -16,28 +17,30 @@ type CreateProductRequest struct {
|
||||
BusinessType *string `json:"business_type,omitempty"`
|
||||
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
||||
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
|
||||
PrintToChecker *bool `json:"print_to_checker,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
Variants []CreateProductVariantRequest `json:"variants,omitempty"`
|
||||
InitialStock *int `json:"initial_stock,omitempty" validate:"omitempty,min=0"` // Initial stock quantity for all outlets
|
||||
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"` // Reorder level for all outlets
|
||||
CreateInventory bool `json:"create_inventory,omitempty"` // Whether to create inventory records for all outlets
|
||||
InitialStock *int `json:"initial_stock,omitempty" validate:"omitempty,min=0"`
|
||||
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"`
|
||||
CreateInventory bool `json:"create_inventory,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateProductRequest struct {
|
||||
CategoryID *uuid.UUID `json:"category_id,omitempty"`
|
||||
SKU *string `json:"sku,omitempty"`
|
||||
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Price *float64 `json:"price,omitempty" validate:"omitempty,min=0"`
|
||||
Cost *float64 `json:"cost,omitempty" validate:"omitempty,min=0"`
|
||||
BusinessType *string `json:"business_type,omitempty"`
|
||||
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
||||
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
// Stock management fields
|
||||
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"` // Update reorder level for all existing inventory records
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
CategoryID *uuid.UUID `json:"category_id,omitempty"`
|
||||
SKU *string `json:"sku,omitempty"`
|
||||
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Price *float64 `json:"price,omitempty" validate:"omitempty,min=0"`
|
||||
Cost *float64 `json:"cost,omitempty" validate:"omitempty,min=0"`
|
||||
BusinessType *string `json:"business_type,omitempty"`
|
||||
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
||||
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
|
||||
PrintToChecker *bool `json:"print_to_checker,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"`
|
||||
}
|
||||
|
||||
type CreateProductVariantRequest struct {
|
||||
@@ -70,6 +73,7 @@ type ProductResponse struct {
|
||||
BusinessType string `json:"business_type"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
PrinterType string `json:"printer_type"`
|
||||
PrintToChecker bool `json:"print_to_checker"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
@@ -7,23 +7,26 @@ import (
|
||||
)
|
||||
|
||||
type CreateProductOutletPriceRequest struct {
|
||||
ProductID uuid.UUID `json:"product_id" validate:"required"`
|
||||
OutletID uuid.UUID `json:"outlet_id" validate:"required"`
|
||||
Price float64 `json:"price" validate:"required,min=0"`
|
||||
ProductID uuid.UUID `json:"product_id" validate:"required"`
|
||||
OutletID uuid.UUID `json:"outlet_id" validate:"required"`
|
||||
Price float64 `json:"price" validate:"required,min=0"`
|
||||
PrintToChecker bool `json:"print_to_checker"`
|
||||
}
|
||||
|
||||
type UpdateProductOutletPriceRequest struct {
|
||||
Price float64 `json:"price" validate:"required,min=0"`
|
||||
Price float64 `json:"price" validate:"required,min=0"`
|
||||
PrintToChecker *bool `json:"print_to_checker"`
|
||||
}
|
||||
|
||||
type ProductOutletPriceResponse struct {
|
||||
ID uuid.UUID `json:"id,omitempty"`
|
||||
ProductID uuid.UUID `json:"product_id,omitempty"`
|
||||
OutletID uuid.UUID `json:"outlet_id"`
|
||||
OutletName string `json:"outlet_name,omitempty"`
|
||||
Price float64 `json:"price"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
ID uuid.UUID `json:"id,omitempty"`
|
||||
ProductID uuid.UUID `json:"product_id,omitempty"`
|
||||
OutletID uuid.UUID `json:"outlet_id"`
|
||||
OutletName string `json:"outlet_name,omitempty"`
|
||||
Price float64 `json:"price"`
|
||||
PrintToChecker bool `json:"print_to_checker"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type ListProductOutletPricesResponse struct {
|
||||
@@ -37,6 +40,7 @@ type BulkCreateProductOutletPriceRequest struct {
|
||||
}
|
||||
|
||||
type CreateProductOutletPricePerOutletRequest struct {
|
||||
OutletID uuid.UUID `json:"outlet_id" validate:"required"`
|
||||
Price float64 `json:"price" validate:"required,min=0"`
|
||||
OutletID uuid.UUID `json:"outlet_id" validate:"required"`
|
||||
Price float64 `json:"price" validate:"required,min=0"`
|
||||
PrintToChecker bool `json:"print_to_checker"`
|
||||
}
|
||||
|
||||
@@ -34,34 +34,34 @@ type BulkCreateProductRecipeRequest struct {
|
||||
|
||||
// Response structures
|
||||
type ProductRecipeResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
VariantID *uuid.UUID `json:"variant_id"`
|
||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
WastePercentage float64 `json:"waste_percentage"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Product *ProductResponse `json:"product,omitempty"`
|
||||
ProductVariant *ProductVariantResponse `json:"product_variant,omitempty"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
VariantID *uuid.UUID `json:"variant_id"`
|
||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
WastePercentage float64 `json:"waste_percentage"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Product *ProductResponse `json:"product,omitempty"`
|
||||
ProductVariant *ProductVariantResponse `json:"product_variant,omitempty"`
|
||||
Ingredient *ProductRecipeIngredientResponse `json:"ingredient,omitempty"`
|
||||
}
|
||||
|
||||
type ProductRecipeIngredientResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
Name string `json:"name"`
|
||||
UnitID uuid.UUID `json:"unit_id"`
|
||||
Cost float64 `json:"cost"`
|
||||
Stock float64 `json:"stock"`
|
||||
IsSemiFinished bool `json:"is_semi_finished"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
Name string `json:"name"`
|
||||
UnitID *uuid.UUID `json:"unit_id"`
|
||||
Cost float64 `json:"cost"`
|
||||
Stock float64 `json:"stock"`
|
||||
IsSemiFinished bool `json:"is_semi_finished"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Unit *ProductRecipeUnitResponse `json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
@@ -71,4 +71,4 @@ type ProductRecipeUnitResponse struct {
|
||||
Symbol string `json:"symbol"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CreatePurchaseCategoryRequest struct {
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
Code *string `json:"code,omitempty"`
|
||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||
Type string `json:"type" validate:"required,oneof=raw_material expense"`
|
||||
SortOrder *int `json:"sort_order,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
}
|
||||
|
||||
type UpdatePurchaseCategoryRequest struct {
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
Code *string `json:"code,omitempty"`
|
||||
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
Type *string `json:"type,omitempty" validate:"omitempty,oneof=raw_material expense"`
|
||||
SortOrder *int `json:"sort_order,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
}
|
||||
|
||||
type ListPurchaseCategoriesRequest struct {
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
Type string `json:"type,omitempty" validate:"omitempty,oneof=raw_material expense"`
|
||||
Search string `json:"search,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
Page int `json:"page" validate:"required,min=1"`
|
||||
Limit int `json:"limit" validate:"required,min=1,max=100"`
|
||||
}
|
||||
|
||||
type PurchaseCategoryResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
PresetID *uuid.UUID `json:"preset_id"`
|
||||
ParentID *uuid.UUID `json:"parent_id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
IsSystem bool `json:"is_system"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListPurchaseCategoriesResponse struct {
|
||||
PurchaseCategories []PurchaseCategoryResponse `json:"purchase_categories"`
|
||||
TotalCount int `json:"total_count"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
@@ -7,76 +7,102 @@ import (
|
||||
)
|
||||
|
||||
type CreatePurchaseOrderRequest struct {
|
||||
VendorID uuid.UUID `json:"vendor_id" validate:"required"`
|
||||
VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"`
|
||||
PONumber string `json:"po_number" validate:"required,min=1,max=50"`
|
||||
TransactionDate string `json:"transaction_date" validate:"required"` // Format: YYYY-MM-DD
|
||||
DueDate string `json:"due_date" validate:"required"` // Format: YYYY-MM-DD
|
||||
TransactionDate string `json:"transaction_date" validate:"required"` // 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"`
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||
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"`
|
||||
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
||||
}
|
||||
|
||||
type CreatePurchaseOrderItemRequest struct {
|
||||
IngredientID uuid.UUID `json:"ingredient_id" validate:"required"`
|
||||
Description *string `json:"description,omitempty" validate:"omitempty"`
|
||||
Quantity float64 `json:"quantity" validate:"required,gt=0"`
|
||||
UnitID uuid.UUID `json:"unit_id" validate:"required"`
|
||||
Amount float64 `json:"amount" validate:"required,gte=0"`
|
||||
IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"`
|
||||
PurchaseCategoryID uuid.UUID `json:"purchase_category_id" validate:"required"`
|
||||
Description *string `json:"description,omitempty" validate:"omitempty"`
|
||||
Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"`
|
||||
Amount float64 `json:"amount" validate:"required,gte=0"`
|
||||
}
|
||||
|
||||
type UpdatePurchaseOrderRequest struct {
|
||||
VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"`
|
||||
PONumber *string `json:"po_number,omitempty" validate:"omitempty,min=1,max=50"`
|
||||
TransactionDate *string `json:"transaction_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"`
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||
Message *string `json:"message,omitempty" validate:"omitempty"`
|
||||
VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"`
|
||||
PONumber *string `json:"po_number,omitempty" validate:"omitempty,min=1,max=50"`
|
||||
TransactionDate *string `json:"transaction_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"`
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||
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"`
|
||||
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
||||
}
|
||||
|
||||
type UpdatePurchaseOrderItemRequest struct {
|
||||
ID *uuid.UUID `json:"id,omitempty"` // For existing items
|
||||
IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"`
|
||||
Description *string `json:"description,omitempty" validate:"omitempty"`
|
||||
Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"`
|
||||
Amount *float64 `json:"amount,omitempty" validate:"omitempty,gte=0"`
|
||||
ID *uuid.UUID `json:"id,omitempty"` // For existing items
|
||||
IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"`
|
||||
PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty" validate:"omitempty"`
|
||||
Description *string `json:"description,omitempty" validate:"omitempty"`
|
||||
Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"`
|
||||
Amount *float64 `json:"amount,omitempty" validate:"omitempty,gte=0"`
|
||||
}
|
||||
|
||||
type PurchaseOrderResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
VendorID uuid.UUID `json:"vendor_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
VendorID *uuid.UUID `json:"vendor_id"`
|
||||
PONumber string `json:"po_number"`
|
||||
TransactionDate time.Time `json:"transaction_date"`
|
||||
DueDate time.Time `json:"due_date"`
|
||||
DueDate *time.Time `json:"due_date"`
|
||||
Reference *string `json:"reference"`
|
||||
Status string `json:"status"`
|
||||
Message *string `json:"message"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
TeamScope *string `json:"team_scope"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Team *PurchaseTeamResponse `json:"team,omitempty"`
|
||||
Vendor *VendorResponse `json:"vendor,omitempty"`
|
||||
Items []PurchaseOrderItemResponse `json:"items,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 {
|
||||
ID uuid.UUID `json:"id"`
|
||||
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
|
||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||
Description *string `json:"description"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
UnitID uuid.UUID `json:"unit_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Ingredient *IngredientResponse `json:"ingredient,omitempty"`
|
||||
Unit *UnitResponse `json:"unit,omitempty"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
|
||||
IngredientID *uuid.UUID `json:"ingredient_id"`
|
||||
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
|
||||
Description *string `json:"description"`
|
||||
Quantity *float64 `json:"quantity"`
|
||||
UnitID *uuid.UUID `json:"unit_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Ingredient *IngredientResponse `json:"ingredient,omitempty"`
|
||||
PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
|
||||
Unit *UnitResponse `json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
type PurchaseOrderAttachmentResponse struct {
|
||||
@@ -88,13 +114,20 @@ type PurchaseOrderAttachmentResponse struct {
|
||||
}
|
||||
|
||||
type ListPurchaseOrdersRequest struct {
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
Search string `json:"search,omitempty"`
|
||||
Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||
VendorID *uuid.UUID `json:"vendor_id,omitempty"`
|
||||
StartDate *time.Time `json:"start_date,omitempty"`
|
||||
EndDate *time.Time `json:"end_date,omitempty"`
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
Search string `json:"search,omitempty"`
|
||||
Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||
VendorID *uuid.UUID `json:"vendor_id,omitempty"`
|
||||
// Team is the single-value form of the two filters below, so the team picker
|
||||
// can send back what it was given: a parent category id, "central" for Pusat,
|
||||
// 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 {
|
||||
|
||||
@@ -12,14 +12,14 @@ type CreateUserRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Password string `json:"password" validate:"required,min=6"`
|
||||
Role string `json:"role" validate:"required,oneof=admin manager cashier waiter"`
|
||||
Role string `json:"role" validate:"required,oneof=admin manager cashier waiter owner purchasing"`
|
||||
Permissions map[string]interface{} `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
Email *string `json:"email,omitempty" validate:"omitempty,email"`
|
||||
Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter"`
|
||||
Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter owner purchasing"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
Permissions *map[string]interface{} `json:"permissions,omitempty"`
|
||||
|
||||
+188
-52
@@ -27,6 +27,14 @@ type SalesAnalytics struct {
|
||||
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
|
||||
type PurchasingAnalytics struct {
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
@@ -34,24 +42,49 @@ type PurchasingAnalytics struct {
|
||||
Data []PurchasingAnalyticsData `json:"data"`
|
||||
IngredientData []PurchasingIngredientData `json:"ingredient_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 {
|
||||
TotalPurchases float64 `json:"total_purchases"`
|
||||
RawMaterialPurchases float64 `json:"raw_material_purchases"`
|
||||
ExpensePurchases float64 `json:"expense_purchases"`
|
||||
TotalPurchaseOrders int64 `json:"total_purchase_orders"`
|
||||
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
|
||||
ExpenseCount int64 `json:"expense_count"`
|
||||
TotalQuantity float64 `json:"total_quantity"`
|
||||
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
|
||||
TotalIngredients int64 `json:"total_ingredients"`
|
||||
TotalVendors int64 `json:"total_vendors"`
|
||||
TotalTeams int64 `json:"total_teams"`
|
||||
}
|
||||
|
||||
type PurchasingAnalyticsData struct {
|
||||
Date time.Time `json:"date"`
|
||||
Purchases float64 `json:"purchases"`
|
||||
PurchaseOrders int64 `json:"purchase_orders"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Ingredients int64 `json:"ingredients"`
|
||||
Vendors int64 `json:"vendors"`
|
||||
Date time.Time `json:"date"`
|
||||
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 {
|
||||
@@ -64,18 +97,19 @@ type PurchasingIngredientData struct {
|
||||
}
|
||||
|
||||
type PurchasingVendorData struct {
|
||||
VendorID uuid.UUID `json:"vendor_id"`
|
||||
VendorName string `json:"vendor_name"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||
IngredientCount int64 `json:"ingredient_count"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
VendorID *uuid.UUID `json:"vendor_id"`
|
||||
VendorName string `json:"vendor_name"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||
IngredientCount int64 `json:"ingredient_count"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
type ProductAnalytics struct {
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
ProductSku string `json:"product_sku"`
|
||||
ProductPrice float64 `json:"product_price"`
|
||||
CategoryID uuid.UUID `json:"category_id"`
|
||||
CategoryName string `json:"category_name"`
|
||||
CategoryOrder int `json:"category_order"`
|
||||
@@ -103,6 +137,39 @@ type ProductAnalyticsPerCategory struct {
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
}
|
||||
|
||||
// ProductAnalyticsPerParentCategory rolls the per-category figures up to the
|
||||
// top-level category. A category without a parent is its own group.
|
||||
type ProductAnalyticsPerParentCategory struct {
|
||||
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||
ParentCategoryName string `json:"parent_category_name"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
CategoryCount int64 `json:"category_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
}
|
||||
|
||||
// ParentCategoryAnalyticsDetail is the drill-down for a single parent category:
|
||||
// its own totals, the sub-categories underneath it, and the products in each.
|
||||
type ParentCategoryAnalyticsDetail struct {
|
||||
ParentCategoryID uuid.UUID
|
||||
ParentCategoryName string
|
||||
Summary *ProductAnalyticsPerParentCategory
|
||||
Categories []*ProductAnalyticsPerCategory
|
||||
Products []*ProductAnalytics
|
||||
}
|
||||
|
||||
// BudgetCutOffWeek is one Monday-to-Sunday bucket of revenue, used to derive the
|
||||
// weekly spending limits.
|
||||
type BudgetCutOffWeek struct {
|
||||
WeekStart time.Time `json:"week_start"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
}
|
||||
|
||||
// DashboardOverview represents dashboard overview data
|
||||
type DashboardOverview struct {
|
||||
TotalSales float64 `json:"total_sales"`
|
||||
@@ -111,56 +178,125 @@ type DashboardOverview struct {
|
||||
TotalCustomers int64 `json:"total_customers"`
|
||||
VoidedOrders int64 `json:"voided_orders"`
|
||||
RefundedOrders int64 `json:"refunded_orders"`
|
||||
TotalItemSold int64 `json:"total_item_sold"`
|
||||
TotalLowStock int64 `json:"total_low_stock"`
|
||||
TotalProductActive int64 `json:"total_product_active"`
|
||||
}
|
||||
|
||||
// ProfitLossAnalytics represents profit and loss analytics data
|
||||
type ProfitLossAnalytics struct {
|
||||
Summary ProfitLossSummary `json:"summary"`
|
||||
Data []ProfitLossData `json:"data"`
|
||||
ProductData []ProductProfitData `json:"product_data"`
|
||||
Summary ProfitLossSummary
|
||||
Data []ProfitLossData
|
||||
ProductData []ProductProfitData
|
||||
TodayRevenue float64
|
||||
TodayCost float64
|
||||
MtdRevenue float64
|
||||
MtdCost float64
|
||||
TodayPurchasing float64
|
||||
MtdPurchasing float64
|
||||
TodayPurchasingRawMaterial float64
|
||||
MtdPurchasingRawMaterial float64
|
||||
TodayPurchasingExpense float64
|
||||
MtdPurchasingExpense float64
|
||||
PurchasingItems []PurchasingItemDetail
|
||||
TodayExpenseByCategory []ExpenseCategoryTotal
|
||||
MtdExpenseByCategory []ExpenseCategoryTotal
|
||||
OperationalExpenseItems []OperationalExpenseItem
|
||||
}
|
||||
|
||||
type PurchasingItemDetail struct {
|
||||
Date time.Time
|
||||
Item string
|
||||
Quantity float64
|
||||
Amount float64
|
||||
}
|
||||
|
||||
// ProfitLossSummary represents profit and loss summary data
|
||||
type ProfitLossSummary struct {
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
GrossProfit float64 `json:"gross_profit"`
|
||||
GrossProfitMargin float64 `json:"gross_profit_margin"`
|
||||
TotalTax float64 `json:"total_tax"`
|
||||
TotalDiscount float64 `json:"total_discount"`
|
||||
NetProfit float64 `json:"net_profit"`
|
||||
NetProfitMargin float64 `json:"net_profit_margin"`
|
||||
TotalOrders int64 `json:"total_orders"`
|
||||
AverageProfit float64 `json:"average_profit"`
|
||||
ProfitabilityRatio float64 `json:"profitability_ratio"`
|
||||
TotalRevenue float64
|
||||
TotalCost float64
|
||||
GrossProfit float64
|
||||
GrossProfitMargin float64
|
||||
TotalTax float64
|
||||
TotalDiscount float64
|
||||
NetProfit float64
|
||||
NetProfitMargin float64
|
||||
TotalOrders int64
|
||||
AverageProfit float64
|
||||
ProfitabilityRatio float64
|
||||
}
|
||||
|
||||
// ProfitLossData represents profit and loss data by time period
|
||||
type ProfitLossData struct {
|
||||
Date time.Time `json:"date"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
Cost float64 `json:"cost"`
|
||||
GrossProfit float64 `json:"gross_profit"`
|
||||
GrossProfitMargin float64 `json:"gross_profit_margin"`
|
||||
Tax float64 `json:"tax"`
|
||||
Discount float64 `json:"discount"`
|
||||
NetProfit float64 `json:"net_profit"`
|
||||
NetProfitMargin float64 `json:"net_profit_margin"`
|
||||
Orders int64 `json:"orders"`
|
||||
Date time.Time
|
||||
Revenue float64
|
||||
Cost float64
|
||||
GrossProfit float64
|
||||
GrossProfitMargin float64
|
||||
Tax float64
|
||||
Discount float64
|
||||
NetProfit float64
|
||||
NetProfitMargin float64
|
||||
Orders int64
|
||||
}
|
||||
|
||||
// ProductProfitData represents profit data for individual products
|
||||
type ProductProfitData struct {
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
CategoryID uuid.UUID `json:"category_id"`
|
||||
CategoryName string `json:"category_name"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
Cost float64 `json:"cost"`
|
||||
GrossProfit float64 `json:"gross_profit"`
|
||||
GrossProfitMargin float64 `json:"gross_profit_margin"`
|
||||
AveragePrice float64 `json:"average_price"`
|
||||
AverageCost float64 `json:"average_cost"`
|
||||
ProfitPerUnit float64 `json:"profit_per_unit"`
|
||||
ProductID uuid.UUID
|
||||
ProductName string
|
||||
CategoryID uuid.UUID
|
||||
CategoryName string
|
||||
QuantitySold int64
|
||||
Revenue float64
|
||||
Cost float64
|
||||
GrossProfit float64
|
||||
GrossProfitMargin float64
|
||||
AveragePrice float64
|
||||
AverageCost float64
|
||||
ProfitPerUnit float64
|
||||
}
|
||||
|
||||
type ExpenseCategoryTotal struct {
|
||||
CategoryName string
|
||||
Amount float64
|
||||
}
|
||||
|
||||
type OperationalExpenseItem struct {
|
||||
Item string
|
||||
Amount float64
|
||||
}
|
||||
|
||||
type ExclusiveSummaryAnalytics struct {
|
||||
SalesTotal float64
|
||||
SalesCount int64
|
||||
HPPBreakdown []ExclusiveSummaryCategoryTotal
|
||||
OperationalExpenseBreakdown []ExclusiveSummaryCategoryTotal
|
||||
DailySummary []ExclusiveSummaryDailySummary
|
||||
DailyTransactions []ExclusiveSummaryDailyTransaction
|
||||
}
|
||||
|
||||
type ExclusiveSummaryCategoryTotal struct {
|
||||
CategoryCode string
|
||||
CategoryName string
|
||||
Amount float64
|
||||
}
|
||||
|
||||
type ExclusiveSummaryDailySummary struct {
|
||||
Date time.Time
|
||||
TransactionCount int64
|
||||
TotalCost float64
|
||||
}
|
||||
|
||||
type ExclusiveSummaryDailyTransaction struct {
|
||||
Date time.Time
|
||||
CategoryCode string
|
||||
CategoryName string
|
||||
Description string
|
||||
Amount float64
|
||||
Source string
|
||||
}
|
||||
|
||||
type ExclusiveSummaryBankBalance struct {
|
||||
Bank string
|
||||
OpeningBalance *float64
|
||||
IncomingMutation *float64
|
||||
OutgoingMutation *float64
|
||||
ClosingBalance *float64
|
||||
Notes *string
|
||||
}
|
||||
|
||||
@@ -31,15 +31,18 @@ func (m *Metadata) Scan(value interface{}) error {
|
||||
}
|
||||
|
||||
type Category struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
|
||||
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
|
||||
Description *string `gorm:"type:text" json:"description"`
|
||||
Order int `gorm:"default:0" json:"order"`
|
||||
BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
|
||||
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"`
|
||||
ParentID *uuid.UUID `gorm:"type:uuid;index" json:"parent_id"`
|
||||
Parent *Category `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
|
||||
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
|
||||
Description *string `gorm:"type:text" json:"description"`
|
||||
Order int `gorm:"default:0" json:"order"`
|
||||
BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||
Products []Product `gorm:"foreignKey:CategoryID" json:"products,omitempty"`
|
||||
|
||||
@@ -42,6 +42,7 @@ func GetAllEntities() []interface{} {
|
||||
&NotificationReceiver{},
|
||||
&NotificationDelivery{},
|
||||
&ProductOutletPrice{},
|
||||
&Expense{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Expense struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"`
|
||||
OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id"`
|
||||
Receiver string `gorm:"not null;size:255" json:"receiver"`
|
||||
TransactionDate time.Time `gorm:"type:date;not null" json:"transaction_date"`
|
||||
CodeNumber string `gorm:"not null;size:50" json:"code_number"`
|
||||
Status string `gorm:"not null;size:20;default:'draft'" json:"status"`
|
||||
Description *string `gorm:"type:text" json:"description"`
|
||||
Tax float64 `gorm:"type:decimal(15,2);not null;default:0" json:"tax"`
|
||||
Total float64 `gorm:"type:decimal(15,2);not null;default:0" json:"total"`
|
||||
Reserved1 *string `gorm:"type:text" json:"reserved1"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
|
||||
Items []ExpenseItem `gorm:"foreignKey:ExpenseID" json:"items,omitempty"`
|
||||
}
|
||||
|
||||
type ExpenseAnalytics struct {
|
||||
Summary ExpenseAnalyticsSummary
|
||||
Data []ExpenseAnalyticsData
|
||||
CategoryData []ExpenseAnalyticsCategoryData
|
||||
ChartOfAccountData []ExpenseAnalyticsChartOfAccountData
|
||||
ItemData []ExpenseAnalyticsItemData
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsSummary struct {
|
||||
TotalExpenses float64
|
||||
TotalExpenseCount int64
|
||||
TotalTax float64
|
||||
AverageExpenseValue float64
|
||||
TotalCategories int64
|
||||
TotalItems int64
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsData struct {
|
||||
Date time.Time
|
||||
Expenses float64
|
||||
ExpenseCount int64
|
||||
Tax float64
|
||||
Items int64
|
||||
Categories int64
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsCategoryData struct {
|
||||
PurchaseCategoryID uuid.UUID
|
||||
PurchaseCategoryName string
|
||||
PurchaseCategoryType string
|
||||
TotalAmount float64
|
||||
ExpenseCount int64
|
||||
ItemCount int64
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsChartOfAccountData struct {
|
||||
ChartOfAccountID uuid.UUID
|
||||
ChartOfAccountName string
|
||||
TotalAmount float64
|
||||
ExpenseCount int64
|
||||
ItemCount int64
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsItemData struct {
|
||||
Item string
|
||||
TotalAmount float64
|
||||
ExpenseCount int64
|
||||
ItemCount int64
|
||||
}
|
||||
|
||||
func (e *Expense) BeforeCreate(tx *gorm.DB) error {
|
||||
if e.ID == uuid.Nil {
|
||||
e.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (Expense) TableName() string {
|
||||
return "expenses"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ExpenseItem struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
ExpenseID uuid.UUID `gorm:"type:uuid;not null;index" json:"expense_id"`
|
||||
ChartOfAccountID uuid.UUID `gorm:"type:uuid;not null;index" json:"chart_of_account_id"`
|
||||
PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id"`
|
||||
Item string `gorm:"not null;size:255" json:"item"`
|
||||
Description *string `gorm:"type:text" json:"description"`
|
||||
Amount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"amount"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
Expense *Expense `gorm:"foreignKey:ExpenseID" json:"expense,omitempty"`
|
||||
ChartOfAccount *ChartOfAccount `gorm:"foreignKey:ChartOfAccountID" json:"chart_of_account,omitempty"`
|
||||
PurchaseCategory *PurchaseCategory `gorm:"foreignKey:PurchaseCategoryID" json:"purchase_category,omitempty"`
|
||||
}
|
||||
|
||||
func (e *ExpenseItem) BeforeCreate(tx *gorm.DB) error {
|
||||
if e.ID == uuid.Nil {
|
||||
e.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ExpenseItem) TableName() string {
|
||||
return "expense_items"
|
||||
}
|
||||
@@ -11,7 +11,7 @@ type Ingredient struct {
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"`
|
||||
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"`
|
||||
Name string `gorm:"not null;size:255" json:"name"`
|
||||
UnitID uuid.UUID `gorm:"type:uuid;not null;index" json:"unit_id"`
|
||||
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"`
|
||||
Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost"`
|
||||
Stock float64 `gorm:"type:decimal(10,2);default:0.00" json:"stock"`
|
||||
IsSemiFinished bool `gorm:"default:false" json:"is_semi_finished"`
|
||||
|
||||
@@ -39,4 +39,3 @@ func (iuc *IngredientUnitConverter) BeforeCreate() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -36,34 +36,36 @@ const (
|
||||
)
|
||||
|
||||
type InventoryMovement struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
|
||||
OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id" validate:"required"`
|
||||
ItemID uuid.UUID `gorm:"type:uuid;not null;index" json:"item_id" validate:"required"`
|
||||
ItemType string `gorm:"not null;size:20" json:"item_type" validate:"required"` // "PRODUCT" or "INGREDIENT"
|
||||
MovementType InventoryMovementType `gorm:"not null;size:50" json:"movement_type" validate:"required"`
|
||||
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity" validate:"required"`
|
||||
PreviousQuantity float64 `gorm:"type:decimal(12,3)" json:"previous_quantity"`
|
||||
NewQuantity float64 `gorm:"type:decimal(12,3)" json:"new_quantity"`
|
||||
UnitCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"unit_cost"`
|
||||
TotalCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"total_cost"`
|
||||
ReferenceType *InventoryMovementReferenceType `gorm:"size:50" json:"reference_type"`
|
||||
ReferenceID *uuid.UUID `gorm:"type:uuid;index" json:"reference_id"`
|
||||
OrderID *uuid.UUID `gorm:"type:uuid;index" json:"order_id"`
|
||||
PaymentID *uuid.UUID `gorm:"type:uuid;index" json:"payment_id"`
|
||||
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id" validate:"required"`
|
||||
Reason *string `gorm:"size:255" json:"reason"`
|
||||
Notes *string `gorm:"type:text" json:"notes"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
|
||||
OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id" validate:"required"`
|
||||
ItemID uuid.UUID `gorm:"type:uuid;not null;index" json:"item_id" validate:"required"`
|
||||
ItemType string `gorm:"not null;size:20" json:"item_type" validate:"required"` // "PRODUCT" or "INGREDIENT"
|
||||
MovementType InventoryMovementType `gorm:"not null;size:50" json:"movement_type" validate:"required"`
|
||||
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity" validate:"required"`
|
||||
PreviousQuantity float64 `gorm:"type:decimal(12,3)" json:"previous_quantity"`
|
||||
NewQuantity float64 `gorm:"type:decimal(12,3)" json:"new_quantity"`
|
||||
UnitCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"unit_cost"`
|
||||
TotalCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"total_cost"`
|
||||
ReferenceType *InventoryMovementReferenceType `gorm:"size:50" json:"reference_type"`
|
||||
ReferenceID *uuid.UUID `gorm:"type:uuid;index" json:"reference_id"`
|
||||
PurchaseOrderItemID *uuid.UUID `gorm:"type:uuid;index" json:"purchase_order_item_id"`
|
||||
OrderID *uuid.UUID `gorm:"type:uuid;index" json:"order_id"`
|
||||
PaymentID *uuid.UUID `gorm:"type:uuid;index" json:"payment_id"`
|
||||
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id" validate:"required"`
|
||||
Reason *string `gorm:"size:255" json:"reason"`
|
||||
Notes *string `gorm:"type:text" json:"notes"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
|
||||
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||
Outlet Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
|
||||
Product *Product `gorm:"foreignKey:ItemID" json:"product,omitempty"`
|
||||
Ingredient *Ingredient `gorm:"foreignKey:ItemID" json:"ingredient,omitempty"`
|
||||
Order *Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
|
||||
Payment *Payment `gorm:"foreignKey:PaymentID" json:"payment,omitempty"`
|
||||
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||
Outlet Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
|
||||
Product *Product `gorm:"foreignKey:ItemID" json:"product,omitempty"`
|
||||
Ingredient *Ingredient `gorm:"foreignKey:ItemID" json:"ingredient,omitempty"`
|
||||
PurchaseOrderItem *PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderItemID" json:"purchase_order_item,omitempty"`
|
||||
Order *Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
|
||||
Payment *Payment `gorm:"foreignKey:PaymentID" json:"payment,omitempty"`
|
||||
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
}
|
||||
|
||||
func (im *InventoryMovement) BeforeCreate(tx *gorm.DB) error {
|
||||
|
||||
@@ -26,14 +26,14 @@ type OrderIngredientTransaction struct {
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
// Relations
|
||||
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
|
||||
Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
|
||||
OrderItem *OrderItem `gorm:"foreignKey:OrderItemID" json:"order_item,omitempty"`
|
||||
Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
||||
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
|
||||
Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
|
||||
OrderItem *OrderItem `gorm:"foreignKey:OrderItemID" json:"order_item,omitempty"`
|
||||
Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
||||
ProductVariant *ProductVariant `gorm:"foreignKey:ProductVariantID" json:"product_variant,omitempty"`
|
||||
Ingredient Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"`
|
||||
CreatedByUser User `gorm:"foreignKey:CreatedBy" json:"created_by_user,omitempty"`
|
||||
Ingredient Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"`
|
||||
CreatedByUser User `gorm:"foreignKey:CreatedBy" json:"created_by_user,omitempty"`
|
||||
}
|
||||
|
||||
func (oit *OrderIngredientTransaction) BeforeCreate(tx *gorm.DB) error {
|
||||
|
||||
@@ -26,13 +26,14 @@ type Product struct {
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||
Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
||||
Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"`
|
||||
ProductVariants []ProductVariant `gorm:"foreignKey:ProductID" json:"variants,omitempty"`
|
||||
ProductRecipes []ProductRecipe `gorm:"foreignKey:ProductID" json:"product_recipes,omitempty"`
|
||||
Inventory []Inventory `gorm:"foreignKey:ProductID" json:"inventory,omitempty"`
|
||||
OrderItems []OrderItem `gorm:"foreignKey:ProductID" json:"order_items,omitempty"`
|
||||
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||
Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
||||
Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"`
|
||||
ProductVariants []ProductVariant `gorm:"foreignKey:ProductID" json:"variants,omitempty"`
|
||||
ProductRecipes []ProductRecipe `gorm:"foreignKey:ProductID" json:"product_recipes,omitempty"`
|
||||
Inventory []Inventory `gorm:"foreignKey:ProductID" json:"inventory,omitempty"`
|
||||
OrderItems []OrderItem `gorm:"foreignKey:ProductID" json:"order_items,omitempty"`
|
||||
ProductOutletPrices []ProductOutletPrice `gorm:"foreignKey:ProductID" json:"product_outlet_prices,omitempty"`
|
||||
}
|
||||
|
||||
func (p *Product) BeforeCreate(tx *gorm.DB) error {
|
||||
|
||||
@@ -7,15 +7,15 @@ import (
|
||||
)
|
||||
|
||||
type ProductIngredient struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id" db:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id" db:"outlet_id"`
|
||||
ProductID uuid.UUID `json:"product_id" db:"product_id"`
|
||||
IngredientID uuid.UUID `json:"ingredient_id" db:"ingredient_id"`
|
||||
Quantity float64 `json:"quantity" db:"quantity"`
|
||||
WastePercentage float64 `json:"waste_percentage" db:"waste_percentage"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id" db:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id" db:"outlet_id"`
|
||||
ProductID uuid.UUID `json:"product_id" db:"product_id"`
|
||||
IngredientID uuid.UUID `json:"ingredient_id" db:"ingredient_id"`
|
||||
Quantity float64 `json:"quantity" db:"quantity"`
|
||||
WastePercentage float64 `json:"waste_percentage" db:"waste_percentage"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
|
||||
// Relations
|
||||
Product *Product `json:"product,omitempty"`
|
||||
|
||||
@@ -8,12 +8,13 @@ import (
|
||||
)
|
||||
|
||||
type ProductOutletPrice struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id"`
|
||||
OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id"`
|
||||
Price float64 `gorm:"type:decimal(10,2);not null" json:"price"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id"`
|
||||
OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id"`
|
||||
Price float64 `gorm:"type:decimal(10,2);not null" json:"price"`
|
||||
PrintToChecker bool `gorm:"not null;default:true" json:"print_to_checker"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
||||
Outlet Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
|
||||
|
||||
@@ -34,4 +34,4 @@ func (pr *ProductRecipe) BeforeCreate(tx *gorm.DB) error {
|
||||
|
||||
func (ProductRecipe) TableName() string {
|
||||
return "product_recipes"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PurchaseCategoryType string
|
||||
|
||||
const (
|
||||
PurchaseCategoryTypeRawMaterial PurchaseCategoryType = "raw_material"
|
||||
PurchaseCategoryTypeExpense PurchaseCategoryType = "expense"
|
||||
)
|
||||
|
||||
type PurchaseCategoryPreset struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
ParentID *uuid.UUID `gorm:"type:uuid;index" json:"parent_id"`
|
||||
Code string `gorm:"not null;unique;size:100" json:"code"`
|
||||
Name string `gorm:"not null;size:255" json:"name"`
|
||||
Type PurchaseCategoryType `gorm:"not null;size:20" json:"type"`
|
||||
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
|
||||
IsActive bool `gorm:"not null;default:true" json:"is_active"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
Parent *PurchaseCategoryPreset `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
|
||||
}
|
||||
|
||||
func (p *PurchaseCategoryPreset) BeforeCreate(tx *gorm.DB) error {
|
||||
if p.ID == uuid.Nil {
|
||||
p.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (PurchaseCategoryPreset) TableName() string {
|
||||
return "purchase_category_presets"
|
||||
}
|
||||
|
||||
type PurchaseCategory struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"`
|
||||
PresetID *uuid.UUID `gorm:"type:uuid;index" json:"preset_id"`
|
||||
ParentID *uuid.UUID `gorm:"type:uuid;index" json:"parent_id"`
|
||||
Code string `gorm:"not null;size:100" json:"code"`
|
||||
Name string `gorm:"not null;size:255" json:"name"`
|
||||
Type PurchaseCategoryType `gorm:"not null;size:20" json:"type"`
|
||||
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
|
||||
IsSystem bool `gorm:"not null;default:false" json:"is_system"`
|
||||
IsActive bool `gorm:"not null;default:true" json:"is_active"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||
Preset *PurchaseCategoryPreset `gorm:"foreignKey:PresetID" json:"preset,omitempty"`
|
||||
Parent *PurchaseCategory `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
|
||||
Children []PurchaseCategory `gorm:"foreignKey:ParentID" json:"children,omitempty"`
|
||||
}
|
||||
|
||||
func (c *PurchaseCategory) BeforeCreate(tx *gorm.DB) error {
|
||||
if c.ID == uuid.Nil {
|
||||
c.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (PurchaseCategory) TableName() string {
|
||||
return "purchase_categories"
|
||||
}
|
||||
@@ -9,21 +9,28 @@ import (
|
||||
)
|
||||
|
||||
type PurchaseOrder struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id" validate:"required"`
|
||||
VendorID uuid.UUID `gorm:"type:uuid;not null" json:"vendor_id" validate:"required"`
|
||||
PONumber string `gorm:"not null;size:50" json:"po_number" validate:"required,min=1,max=50"`
|
||||
TransactionDate time.Time `gorm:"type:date;not null" json:"transaction_date" validate:"required"`
|
||||
DueDate time.Time `gorm:"type:date;not null" json:"due_date" validate:"required"`
|
||||
Reference *string `gorm:"size:100" json:"reference" validate:"omitempty,max=100"`
|
||||
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"`
|
||||
TotalAmount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"total_amount"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id" validate:"required"`
|
||||
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id" validate:"omitempty"`
|
||||
VendorID *uuid.UUID `gorm:"type:uuid" json:"vendor_id" validate:"omitempty"`
|
||||
PONumber string `gorm:"not null;size:50" json:"po_number" validate:"required,min=1,max=50"`
|
||||
TransactionDate time.Time `gorm:"type:date;not null" json:"transaction_date" validate:"required"`
|
||||
DueDate *time.Time `gorm:"type:date" json:"due_date" validate:"omitempty"`
|
||||
Reference *string `gorm:"size:100" json:"reference" validate:"omitempty,max=100"`
|
||||
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"`
|
||||
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
|
||||
// 'central' for Pusat. Nil means no team was chosen, which is not the same as Pusat.
|
||||
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"`
|
||||
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,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"`
|
||||
Attachments []PurchaseOrderAttachment `gorm:"foreignKey:PurchaseOrderID" json:"attachments,omitempty"`
|
||||
}
|
||||
@@ -41,19 +48,21 @@ func (PurchaseOrder) TableName() string {
|
||||
}
|
||||
|
||||
type PurchaseOrderItem struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"`
|
||||
IngredientID uuid.UUID `gorm:"type:uuid;not null" json:"ingredient_id" validate:"required"`
|
||||
Description *string `gorm:"type:text" json:"description" validate:"omitempty"`
|
||||
Quantity float64 `gorm:"type:decimal(10,3);not null" json:"quantity" validate:"required,gt=0"`
|
||||
UnitID uuid.UUID `gorm:"type:uuid;not null" json:"unit_id" validate:"required"`
|
||||
Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"`
|
||||
IngredientID *uuid.UUID `gorm:"type:uuid" json:"ingredient_id" validate:"omitempty"`
|
||||
PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id" validate:"required"`
|
||||
Description *string `gorm:"type:text" json:"description" validate:"omitempty"`
|
||||
Quantity *float64 `gorm:"type:decimal(10,3)" json:"quantity" validate:"omitempty,gt=0"`
|
||||
UnitID *uuid.UUID `gorm:"type:uuid" json:"unit_id" validate:"omitempty"`
|
||||
Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
PurchaseOrder *PurchaseOrder `gorm:"foreignKey:PurchaseOrderID" json:"purchase_order,omitempty"`
|
||||
Ingredient *Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"`
|
||||
Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"`
|
||||
PurchaseOrder *PurchaseOrder `gorm:"foreignKey:PurchaseOrderID" json:"purchase_order,omitempty"`
|
||||
Ingredient *Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"`
|
||||
PurchaseCategory *PurchaseCategory `gorm:"foreignKey:PurchaseCategoryID" json:"purchase_category,omitempty"`
|
||||
Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
func (poi *PurchaseOrderItem) BeforeCreate(tx *gorm.DB) error {
|
||||
|
||||
@@ -13,10 +13,12 @@ import (
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
RoleAdmin UserRole = "admin"
|
||||
RoleManager UserRole = "manager"
|
||||
RoleCashier UserRole = "cashier"
|
||||
RoleWaiter UserRole = "waiter"
|
||||
RoleAdmin UserRole = "admin"
|
||||
RoleManager UserRole = "manager"
|
||||
RoleCashier UserRole = "cashier"
|
||||
RoleWaiter UserRole = "waiter"
|
||||
RoleOwner UserRole = "owner"
|
||||
RolePurchasing UserRole = "purchasing"
|
||||
)
|
||||
|
||||
type Permissions map[string]interface{}
|
||||
@@ -46,7 +48,7 @@ type User struct {
|
||||
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
|
||||
Email string `gorm:"uniqueIndex;not null;size:255" json:"email" validate:"required,email"`
|
||||
PasswordHash string `gorm:"not null;size:255" json:"-"`
|
||||
Role UserRole `gorm:"not null;size:50" json:"role" validate:"required,oneof=admin manager cashier waiter"`
|
||||
Role UserRole `gorm:"not null;size:50" json:"role" validate:"required,oneof=admin manager cashier waiter owner purchasing"`
|
||||
Permissions Permissions `gorm:"type:jsonb;default:'{}'" json:"permissions"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
|
||||
@@ -157,6 +157,55 @@ func (h *AnalyticsHandler) GetProductAnalyticsPerCategory(c *gin.Context) {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetProductAnalyticsPerCategory")
|
||||
}
|
||||
|
||||
func (h *AnalyticsHandler) GetProductAnalyticsPerParentCategory(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
var req contract.ProductAnalyticsPerParentCategoryRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetProductAnalyticsPerParentCategory", err.Error())}), "AnalyticsHandler::GetProductAnalyticsPerParentCategory")
|
||||
return
|
||||
}
|
||||
|
||||
req.OrganizationID = contextInfo.OrganizationID
|
||||
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
|
||||
modelReq := transformer.ProductAnalyticsPerParentCategoryContractToModel(&req)
|
||||
|
||||
response, err := h.analyticsService.GetProductAnalyticsPerParentCategory(ctx, modelReq)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetProductAnalyticsPerParentCategory", err.Error())}), "AnalyticsHandler::GetProductAnalyticsPerParentCategory")
|
||||
return
|
||||
}
|
||||
|
||||
contractResp := transformer.ProductAnalyticsPerParentCategoryModelToContract(response)
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetProductAnalyticsPerParentCategory")
|
||||
}
|
||||
|
||||
func (h *AnalyticsHandler) GetParentCategoryAnalyticsDetail(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
var req contract.ParentCategoryAnalyticsDetailRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetParentCategoryAnalyticsDetail", err.Error())}), "AnalyticsHandler::GetParentCategoryAnalyticsDetail")
|
||||
return
|
||||
}
|
||||
|
||||
req.OrganizationID = contextInfo.OrganizationID
|
||||
req.ParentCategoryID = c.Param("parent_category_id")
|
||||
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
|
||||
modelReq := transformer.ParentCategoryAnalyticsDetailContractToModel(&req)
|
||||
|
||||
response, err := h.analyticsService.GetParentCategoryAnalyticsDetail(ctx, modelReq)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetParentCategoryAnalyticsDetail", err.Error())}), "AnalyticsHandler::GetParentCategoryAnalyticsDetail")
|
||||
return
|
||||
}
|
||||
|
||||
contractResp := transformer.ParentCategoryAnalyticsDetailModelToContract(response)
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetParentCategoryAnalyticsDetail")
|
||||
}
|
||||
|
||||
func (h *AnalyticsHandler) GetDashboardAnalytics(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
@@ -210,3 +259,87 @@ func (h *AnalyticsHandler) GetProfitLossAnalytics(c *gin.Context) {
|
||||
contractResp := transformer.ProfitLossAnalyticsModelToContract(response)
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetProfitLossAnalytics")
|
||||
}
|
||||
|
||||
func (h *AnalyticsHandler) GetExclusiveSummaryPeriod(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
var req contract.ExclusiveSummaryPeriodRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryPeriod", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryPeriod")
|
||||
return
|
||||
}
|
||||
|
||||
req.OrganizationID = contextInfo.OrganizationID
|
||||
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
|
||||
modelReq, err := transformer.ExclusiveSummaryPeriodContractToModel(&req)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryPeriod", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryPeriod")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.analyticsService.GetExclusiveSummaryPeriod(ctx, modelReq)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetExclusiveSummaryPeriod", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryPeriod")
|
||||
return
|
||||
}
|
||||
|
||||
contractResp := transformer.ExclusiveSummaryPeriodModelToContract(response)
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryPeriod")
|
||||
}
|
||||
|
||||
func (h *AnalyticsHandler) GetExclusiveSummaryMonthly(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
var req contract.ExclusiveSummaryMonthlyRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMonthly", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMonthly")
|
||||
return
|
||||
}
|
||||
|
||||
req.OrganizationID = contextInfo.OrganizationID
|
||||
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
|
||||
modelReq, err := transformer.ExclusiveSummaryMonthlyContractToModel(&req)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMonthly", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMonthly")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.analyticsService.GetExclusiveSummaryMonthly(ctx, modelReq)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetExclusiveSummaryMonthly", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMonthly")
|
||||
return
|
||||
}
|
||||
|
||||
contractResp := transformer.ExclusiveSummaryMonthlyModelToContract(response)
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryMonthly")
|
||||
}
|
||||
|
||||
func (h *AnalyticsHandler) GetExclusiveSummaryMTD(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
var req contract.ExclusiveSummaryMTDRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMTD", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMTD")
|
||||
return
|
||||
}
|
||||
|
||||
req.OrganizationID = contextInfo.OrganizationID
|
||||
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
|
||||
modelReq, err := transformer.ExclusiveSummaryMTDContractToModel(&req)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMTD", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMTD")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.analyticsService.GetExclusiveSummaryMTD(ctx, modelReq)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetExclusiveSummaryMTD", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMTD")
|
||||
return
|
||||
}
|
||||
|
||||
contractResp := transformer.ExclusiveSummaryPeriodModelToContract(response)
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryMTD")
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func (h *CategoryHandler) CreateCategory(c *gin.Context) {
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
var req contract.CreateCategoryRequest
|
||||
fmt.Printf("CategoryHandler::CreateCategory -> Request: %+v\n", req)
|
||||
fmt.Printf("CategoryHandler::CreateCategory -> Request: %+v\n", req)
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("CategoryHandler::CreateCategory -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
@@ -44,6 +44,11 @@ func (h *CategoryHandler) CreateCategory(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Inject outlet_id from context if user has one and request doesn't provide it
|
||||
if req.OutletID == nil && contextInfo.OutletID != uuid.Nil {
|
||||
req.OutletID = &contextInfo.OutletID
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.categoryValidator.ValidateCreateCategoryRequest(&req)
|
||||
if validationError != nil {
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
@@ -149,6 +154,11 @@ func (h *CategoryHandler) ListCategories(c *gin.Context) {
|
||||
OrganizationID: &contextInfo.OrganizationID,
|
||||
}
|
||||
|
||||
// Inject outlet_id from context if user has one
|
||||
if contextInfo.OutletID != uuid.Nil {
|
||||
req.OutletID = &contextInfo.OutletID
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
if pageStr := c.Query("page"); pageStr != "" {
|
||||
if page, err := strconv.Atoi(pageStr); err == nil {
|
||||
@@ -176,6 +186,23 @@ func (h *CategoryHandler) ListCategories(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if outletIDStr := c.Query("outlet_id"); outletIDStr != "" {
|
||||
if outletID, err := uuid.Parse(outletIDStr); err == nil {
|
||||
req.OutletID = &outletID
|
||||
}
|
||||
}
|
||||
if parentIDStr := c.Query("parent_id"); parentIDStr != "" {
|
||||
if parentID, err := uuid.Parse(parentIDStr); err == nil {
|
||||
req.ParentID = &parentID
|
||||
}
|
||||
}
|
||||
|
||||
// type=parent -> top level categories only
|
||||
// type=child -> leaf categories (sub categories + top level ones without children)
|
||||
if categoryType := c.Query("type"); categoryType != "" {
|
||||
req.Type = categoryType
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.categoryValidator.ValidateListCategoriesRequest(req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(ctx).WithError(validationError).Error("CategoryHandler::ListCategories -> request validation failed")
|
||||
|
||||
@@ -99,7 +99,7 @@ func (h *ChartOfAccountTypeHandler) DeleteChartOfAccountType(c *gin.Context) {
|
||||
func (h *ChartOfAccountTypeHandler) ListChartOfAccountTypes(c *gin.Context) {
|
||||
// Parse query parameters
|
||||
filters := make(map[string]interface{})
|
||||
|
||||
|
||||
if isActive := c.Query("is_active"); isActive != "" {
|
||||
if isActiveBool, err := strconv.ParseBool(isActive); err == nil {
|
||||
filters["is_active"] = isActiveBool
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/logger"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
@@ -47,7 +49,7 @@ func (m *CommonMiddleware) Recovery(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
|
||||
logger.FromContext(r.Context()).Error("Recovery", fmt.Sprintf("panic recovered: %v", err))
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/util"
|
||||
"strconv"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/validator"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ExpenseHandler struct {
|
||||
expenseService service.ExpenseService
|
||||
expenseValidator validator.ExpenseValidator
|
||||
}
|
||||
|
||||
func NewExpenseHandler(
|
||||
expenseService service.ExpenseService,
|
||||
expenseValidator validator.ExpenseValidator,
|
||||
) *ExpenseHandler {
|
||||
return &ExpenseHandler{
|
||||
expenseService: expenseService,
|
||||
expenseValidator: expenseValidator,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ExpenseHandler) CreateExpense(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
var req contract.CreateExpenseRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::CreateExpense -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::CreateExpense")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.expenseValidator.ValidateCreateExpenseRequest(&req)
|
||||
if validationError != nil {
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::CreateExpense")
|
||||
return
|
||||
}
|
||||
|
||||
expenseResponse := h.expenseService.CreateExpense(ctx, contextInfo, &req)
|
||||
if expenseResponse.HasErrors() {
|
||||
errorResp := expenseResponse.GetErrors()[0]
|
||||
logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::CreateExpense -> Failed to create expense from service")
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::CreateExpense")
|
||||
}
|
||||
|
||||
func (h *ExpenseHandler) UpdateExpense(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
expenseIDStr := c.Param("id")
|
||||
expenseID, err := uuid.Parse(expenseIDStr)
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::UpdateExpense -> Invalid expense ID")
|
||||
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid expense ID")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::UpdateExpense")
|
||||
return
|
||||
}
|
||||
|
||||
var req contract.UpdateExpenseRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::UpdateExpense -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "Invalid request body")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::UpdateExpense")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.expenseValidator.ValidateUpdateExpenseRequest(&req)
|
||||
if validationError != nil {
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::UpdateExpense")
|
||||
return
|
||||
}
|
||||
|
||||
expenseResponse := h.expenseService.UpdateExpense(ctx, contextInfo, expenseID, &req)
|
||||
if expenseResponse.HasErrors() {
|
||||
errorResp := expenseResponse.GetErrors()[0]
|
||||
logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::UpdateExpense -> Failed to update expense from service")
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::UpdateExpense")
|
||||
}
|
||||
|
||||
func (h *ExpenseHandler) DeleteExpense(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
expenseIDStr := c.Param("id")
|
||||
expenseID, err := uuid.Parse(expenseIDStr)
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::DeleteExpense -> Invalid expense ID")
|
||||
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid expense ID")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::DeleteExpense")
|
||||
return
|
||||
}
|
||||
|
||||
expenseResponse := h.expenseService.DeleteExpense(ctx, contextInfo, expenseID)
|
||||
if expenseResponse.HasErrors() {
|
||||
errorResp := expenseResponse.GetErrors()[0]
|
||||
logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::DeleteExpense -> Failed to delete expense from service")
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::DeleteExpense")
|
||||
}
|
||||
|
||||
func (h *ExpenseHandler) GetExpense(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
expenseIDStr := c.Param("id")
|
||||
expenseID, err := uuid.Parse(expenseIDStr)
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::GetExpense -> Invalid expense ID")
|
||||
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid expense ID")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::GetExpense")
|
||||
return
|
||||
}
|
||||
|
||||
expenseResponse := h.expenseService.GetExpenseByID(ctx, contextInfo, expenseID)
|
||||
if expenseResponse.HasErrors() {
|
||||
errorResp := expenseResponse.GetErrors()[0]
|
||||
logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::GetExpense -> Failed to get expense from service")
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::GetExpense")
|
||||
}
|
||||
|
||||
func (h *ExpenseHandler) ListExpenses(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
req := &contract.ListExpenseRequest{
|
||||
Page: 1,
|
||||
Limit: 10,
|
||||
}
|
||||
|
||||
if pageStr := c.Query("page"); pageStr != "" {
|
||||
if page, err := strconv.Atoi(pageStr); err == nil {
|
||||
req.Page = page
|
||||
}
|
||||
}
|
||||
|
||||
if limitStr := c.Query("limit"); limitStr != "" {
|
||||
if limit, err := strconv.Atoi(limitStr); err == nil {
|
||||
req.Limit = limit
|
||||
}
|
||||
}
|
||||
|
||||
if search := c.Query("search"); search != "" {
|
||||
req.Search = search
|
||||
}
|
||||
|
||||
if status := c.Query("status"); status != "" {
|
||||
req.Status = status
|
||||
}
|
||||
|
||||
// Prioritize outlet_id from context (e.g. outlet-scoped user),
|
||||
// fall back to query param if context has no outlet.
|
||||
if contextInfo.OutletID != uuid.Nil {
|
||||
req.OutletID = contextInfo.OutletID.String()
|
||||
} else if outletID := c.Query("outlet_id"); outletID != "" {
|
||||
req.OutletID = outletID
|
||||
}
|
||||
|
||||
if startDate := c.Query("start_date"); startDate != "" {
|
||||
req.StartDate = startDate
|
||||
}
|
||||
|
||||
if endDate := c.Query("end_date"); endDate != "" {
|
||||
req.EndDate = endDate
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.expenseValidator.ValidateListExpenseRequest(req)
|
||||
if validationError != nil {
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::ListExpenses")
|
||||
return
|
||||
}
|
||||
|
||||
expenseResponse := h.expenseService.ListExpenses(ctx, contextInfo, req)
|
||||
if expenseResponse.HasErrors() {
|
||||
errorResp := expenseResponse.GetErrors()[0]
|
||||
logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::ListExpenses -> Failed to list expenses from service")
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::ListExpenses")
|
||||
}
|
||||
|
||||
func (h *ExpenseHandler) GetExpenseAnalytics(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
var req contract.ExpenseAnalyticsRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::GetExpenseAnalytics -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::GetExpenseAnalytics")
|
||||
return
|
||||
}
|
||||
|
||||
if contextInfo.OutletID != uuid.Nil {
|
||||
outletID := contextInfo.OutletID.String()
|
||||
req.OutletID = &outletID
|
||||
} else if outletID := c.Query("outlet_id"); outletID != "" {
|
||||
req.OutletID = &outletID
|
||||
}
|
||||
|
||||
expenseResponse := h.expenseService.GetExpenseAnalytics(ctx, contextInfo, &req)
|
||||
if expenseResponse.HasErrors() {
|
||||
errorResp := expenseResponse.GetErrors()[0]
|
||||
logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::GetExpenseAnalytics -> Failed to get expense analytics from service")
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::GetExpenseAnalytics")
|
||||
}
|
||||
@@ -275,4 +275,3 @@ func (h *IngredientUnitConverterHandler) GetUnitsByIngredientID(c *gin.Context)
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, unitsResponse, "IngredientUnitConverterHandler::GetUnitsByIngredientID")
|
||||
}
|
||||
|
||||
|
||||
@@ -140,6 +140,7 @@ func (h *OrderHandler) ListOrders(c *gin.Context) {
|
||||
if modelReq.OutletID == nil && contextInfo.OutletID != uuid.Nil {
|
||||
modelReq.OutletID = &contextInfo.OutletID
|
||||
}
|
||||
|
||||
response, err := h.orderService.ListOrders(c.Request.Context(), modelReq)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::ListOrders", err.Error())}), "OrderHandler::ListOrders")
|
||||
|
||||
@@ -60,6 +60,7 @@ func (h *ProductHandler) CreateProduct(c *gin.Context) {
|
||||
|
||||
func (h *ProductHandler) UpdateProduct(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
productIDStr := c.Param("id")
|
||||
productID, err := uuid.Parse(productIDStr)
|
||||
@@ -85,7 +86,7 @@ func (h *ProductHandler) UpdateProduct(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
productResponse := h.productService.UpdateProduct(ctx, productID, &req)
|
||||
productResponse := h.productService.UpdateProduct(ctx, contextInfo, productID, &req)
|
||||
if productResponse.HasErrors() {
|
||||
errorResp := productResponse.GetErrors()[0]
|
||||
logger.FromContext(ctx).WithError(errorResp).Error("ProductHandler::UpdateProduct -> Failed to update product from service")
|
||||
|
||||
@@ -219,4 +219,4 @@ func (h *ProductRecipeHandler) BulkCreate(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, contract.BuildSuccessResponse(recipes))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
"apskel-pos-be/internal/validator"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PurchaseCategoryHandler struct {
|
||||
purchaseCategoryService service.PurchaseCategoryService
|
||||
purchaseCategoryValidator validator.PurchaseCategoryValidator
|
||||
}
|
||||
|
||||
func NewPurchaseCategoryHandler(purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator) *PurchaseCategoryHandler {
|
||||
return &PurchaseCategoryHandler{
|
||||
purchaseCategoryService: purchaseCategoryService,
|
||||
purchaseCategoryValidator: purchaseCategoryValidator,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PurchaseCategoryHandler) CreatePurchaseCategory(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
var req contract.CreatePurchaseCategoryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("PurchaseCategoryHandler::CreatePurchaseCategory -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::CreatePurchaseCategory")
|
||||
return
|
||||
}
|
||||
|
||||
if validationError, validationErrorCode := h.purchaseCategoryValidator.ValidateCreatePurchaseCategoryRequest(&req); validationError != nil {
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::CreatePurchaseCategory")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.purchaseCategoryService.CreatePurchaseCategory(ctx, contextInfo, &req)
|
||||
util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::CreatePurchaseCategory")
|
||||
}
|
||||
|
||||
func (h *PurchaseCategoryHandler) UpdatePurchaseCategory(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
categoryID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid purchase category ID")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::UpdatePurchaseCategory")
|
||||
return
|
||||
}
|
||||
|
||||
var req contract.UpdatePurchaseCategoryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("PurchaseCategoryHandler::UpdatePurchaseCategory -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::UpdatePurchaseCategory")
|
||||
return
|
||||
}
|
||||
|
||||
if validationError, validationErrorCode := h.purchaseCategoryValidator.ValidateUpdatePurchaseCategoryRequest(&req); validationError != nil {
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::UpdatePurchaseCategory")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.purchaseCategoryService.UpdatePurchaseCategory(ctx, contextInfo, categoryID, &req)
|
||||
util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::UpdatePurchaseCategory")
|
||||
}
|
||||
|
||||
func (h *PurchaseCategoryHandler) DeletePurchaseCategory(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
categoryID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid purchase category ID")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::DeletePurchaseCategory")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.purchaseCategoryService.DeletePurchaseCategory(ctx, contextInfo, categoryID)
|
||||
util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::DeletePurchaseCategory")
|
||||
}
|
||||
|
||||
func (h *PurchaseCategoryHandler) GetPurchaseCategory(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
categoryID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid purchase category ID")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::GetPurchaseCategory")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.purchaseCategoryService.GetPurchaseCategoryByID(ctx, contextInfo, categoryID)
|
||||
util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::GetPurchaseCategory")
|
||||
}
|
||||
|
||||
func (h *PurchaseCategoryHandler) ListPurchaseCategories(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
req := &contract.ListPurchaseCategoriesRequest{
|
||||
Page: 1,
|
||||
Limit: 100,
|
||||
}
|
||||
|
||||
if pageStr := c.Query("page"); pageStr != "" {
|
||||
if page, err := strconv.Atoi(pageStr); err == nil {
|
||||
req.Page = page
|
||||
}
|
||||
}
|
||||
|
||||
if limitStr := c.Query("limit"); limitStr != "" {
|
||||
if limit, err := strconv.Atoi(limitStr); err == nil {
|
||||
req.Limit = limit
|
||||
}
|
||||
}
|
||||
|
||||
if parentIDStr := c.Query("parent_id"); parentIDStr != "" {
|
||||
if parentID, err := uuid.Parse(parentIDStr); err == nil {
|
||||
req.ParentID = &parentID
|
||||
}
|
||||
}
|
||||
|
||||
if categoryType := c.Query("type"); categoryType != "" {
|
||||
req.Type = categoryType
|
||||
}
|
||||
|
||||
if search := c.Query("search"); search != "" {
|
||||
req.Search = search
|
||||
}
|
||||
|
||||
if isActiveStr := c.Query("is_active"); isActiveStr != "" {
|
||||
if isActive, err := strconv.ParseBool(isActiveStr); err == nil {
|
||||
req.IsActive = &isActive
|
||||
}
|
||||
}
|
||||
|
||||
if validationError, validationErrorCode := h.purchaseCategoryValidator.ValidateListPurchaseCategoriesRequest(req); validationError != nil {
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::ListPurchaseCategories")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.purchaseCategoryService.ListPurchaseCategories(ctx, contextInfo, req)
|
||||
util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::ListPurchaseCategories")
|
||||
}
|
||||
@@ -176,6 +176,20 @@ 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 startDate, err := time.Parse("2006-01-02", startDateStr); err == nil {
|
||||
req.StartDate = &startDate
|
||||
@@ -224,6 +238,21 @@ func (h *PurchaseOrderHandler) GetPurchaseOrdersByStatus(c *gin.Context) {
|
||||
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) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
@@ -66,3 +66,35 @@ func (h *ReportHandler) GetDailyTransactionReportPDF(c *gin.Context) {
|
||||
"file_name": fileName,
|
||||
}), "ReportHandler::GetDailyTransactionReportPDF")
|
||||
}
|
||||
|
||||
func (h *ReportHandler) GetProfitLossReportPDF(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
ci := appcontext.FromGinContext(ctx)
|
||||
|
||||
outletID := h.resolveOutletID(c, ci.OutletID)
|
||||
var dayPtr *time.Time
|
||||
if d := c.Query("date"); d != "" {
|
||||
if t, err := time.Parse("2006-01-02", d); err == nil {
|
||||
dayPtr = &t
|
||||
}
|
||||
}
|
||||
|
||||
user, err := h.userService.GetUserByID(ctx, ci.UserID)
|
||||
var genBy string
|
||||
if err != nil {
|
||||
genBy = ci.UserID.String()
|
||||
} else {
|
||||
genBy = user.Name
|
||||
}
|
||||
|
||||
publicURL, fileName, err := h.reportService.GenerateProfitLossPDF(ctx, ci.OrganizationID.String(), outletID, dayPtr, genBy)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "ReportHandler::GetProfitLossReportPDF", err.Error())}), "ReportHandler::GetProfitLossReportPDF")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(map[string]string{
|
||||
"url": publicURL,
|
||||
"file_name": fileName,
|
||||
}), "ReportHandler::GetProfitLossReportPDF")
|
||||
}
|
||||
|
||||
@@ -13,11 +13,12 @@ func CategoryEntityToModel(entity *entities.Category) *models.Category {
|
||||
return &models.Category{
|
||||
ID: entity.ID,
|
||||
OrganizationID: entity.OrganizationID,
|
||||
OutletID: entity.OutletID,
|
||||
Name: entity.Name,
|
||||
Description: entity.Description,
|
||||
ImageURL: nil, // Entity doesn't have ImageURL, model does
|
||||
Order: entity.Order, // Entity doesn't have SortOrder, model does
|
||||
IsActive: true, // Entity doesn't have IsActive, default to true
|
||||
ImageURL: nil,
|
||||
Order: entity.Order,
|
||||
IsActive: true,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
}
|
||||
@@ -32,14 +33,14 @@ func CategoryModelToEntity(model *models.Category) *entities.Category {
|
||||
if model.ImageURL != nil {
|
||||
metadata["image_url"] = *model.ImageURL
|
||||
}
|
||||
// metadata["sort_order"] = model.SortOrder
|
||||
|
||||
return &entities.Category{
|
||||
ID: model.ID,
|
||||
OrganizationID: model.OrganizationID,
|
||||
OutletID: model.OutletID,
|
||||
Name: model.Name,
|
||||
Description: model.Description,
|
||||
BusinessType: "restaurant", // Default business type
|
||||
BusinessType: "restaurant",
|
||||
Order: model.Order,
|
||||
Metadata: metadata,
|
||||
CreatedAt: model.CreatedAt,
|
||||
@@ -56,14 +57,15 @@ func CreateCategoryRequestToEntity(req *models.CreateCategoryRequest) *entities.
|
||||
if req.ImageURL != nil {
|
||||
metadata["image_url"] = *req.ImageURL
|
||||
}
|
||||
// metadata["sort_order"] = req.SortOrder
|
||||
|
||||
return &entities.Category{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
ParentID: req.ParentID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Order: req.Order,
|
||||
BusinessType: "restaurant", // Default business type
|
||||
BusinessType: "restaurant",
|
||||
Metadata: metadata,
|
||||
}
|
||||
}
|
||||
@@ -84,14 +86,24 @@ func CategoryEntityToResponse(entity *entities.Category) *models.CategoryRespons
|
||||
}
|
||||
}
|
||||
|
||||
// Parent name is only available when the Parent association is preloaded
|
||||
var parentName *string
|
||||
if entity.Parent != nil {
|
||||
name := entity.Parent.Name
|
||||
parentName = &name
|
||||
}
|
||||
|
||||
return &models.CategoryResponse{
|
||||
ID: entity.ID,
|
||||
OrganizationID: entity.OrganizationID,
|
||||
OutletID: entity.OutletID,
|
||||
ParentID: entity.ParentID,
|
||||
ParentName: parentName,
|
||||
Name: entity.Name,
|
||||
Description: entity.Description,
|
||||
ImageURL: imageURL,
|
||||
Order: entity.Order,
|
||||
IsActive: true, // Default to true since entity doesn't have this field
|
||||
Order: entity.Order,
|
||||
IsActive: true,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
}
|
||||
@@ -121,6 +133,14 @@ func UpdateCategoryEntityFromRequest(entity *entities.Category, req *models.Upda
|
||||
if req.Order != nil {
|
||||
entity.Order = *req.Order
|
||||
}
|
||||
|
||||
if req.OutletID != nil {
|
||||
entity.OutletID = req.OutletID
|
||||
}
|
||||
|
||||
if req.ParentID != nil {
|
||||
entity.ParentID = req.ParentID
|
||||
}
|
||||
}
|
||||
|
||||
func CategoryEntitiesToModels(entities []*entities.Category) []*models.Category {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
func ExpenseEntityToModel(entity *entities.Expense) *models.Expense {
|
||||
if entity == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.Expense{
|
||||
ID: entity.ID,
|
||||
OrganizationID: entity.OrganizationID,
|
||||
OutletID: entity.OutletID,
|
||||
Receiver: entity.Receiver,
|
||||
TransactionDate: entity.TransactionDate,
|
||||
CodeNumber: entity.CodeNumber,
|
||||
Status: entity.Status,
|
||||
Description: entity.Description,
|
||||
Tax: entity.Tax,
|
||||
Total: entity.Total,
|
||||
Reserved1: entity.Reserved1,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func ExpenseModelToEntity(model *models.Expense) *entities.Expense {
|
||||
if model == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &entities.Expense{
|
||||
ID: model.ID,
|
||||
OrganizationID: model.OrganizationID,
|
||||
OutletID: model.OutletID,
|
||||
Receiver: model.Receiver,
|
||||
TransactionDate: model.TransactionDate,
|
||||
CodeNumber: model.CodeNumber,
|
||||
Status: model.Status,
|
||||
Description: model.Description,
|
||||
Tax: model.Tax,
|
||||
Total: model.Total,
|
||||
Reserved1: model.Reserved1,
|
||||
CreatedAt: model.CreatedAt,
|
||||
UpdatedAt: model.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func ExpenseEntityToResponse(entity *entities.Expense) *models.ExpenseResponse {
|
||||
if entity == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
resp := &models.ExpenseResponse{
|
||||
ID: entity.ID,
|
||||
OrganizationID: entity.OrganizationID,
|
||||
OutletID: entity.OutletID,
|
||||
Receiver: entity.Receiver,
|
||||
TransactionDate: entity.TransactionDate,
|
||||
CodeNumber: entity.CodeNumber,
|
||||
Status: entity.Status,
|
||||
Description: entity.Description,
|
||||
Tax: entity.Tax,
|
||||
Total: entity.Total,
|
||||
Reserved1: entity.Reserved1,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
}
|
||||
|
||||
if entity.Items != nil {
|
||||
resp.Items = ExpenseItemEntitiesToResponses(entity.Items)
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
func ExpenseEntitiesToResponses(entities []*entities.Expense) []*models.ExpenseResponse {
|
||||
if entities == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
responses := make([]*models.ExpenseResponse, len(entities))
|
||||
for i, entity := range entities {
|
||||
responses[i] = ExpenseEntityToResponse(entity)
|
||||
}
|
||||
return responses
|
||||
}
|
||||
|
||||
func ExpenseItemEntityToResponse(entity *entities.ExpenseItem) *models.ExpenseItemResponse {
|
||||
if entity == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
response := &models.ExpenseItemResponse{
|
||||
ID: entity.ID,
|
||||
ExpenseID: entity.ExpenseID,
|
||||
ChartOfAccountID: entity.ChartOfAccountID,
|
||||
PurchaseCategoryID: entity.PurchaseCategoryID,
|
||||
Item: entity.Item,
|
||||
Description: entity.Description,
|
||||
Amount: entity.Amount,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
}
|
||||
|
||||
if entity.ChartOfAccount != nil {
|
||||
response.ChartOfAccountName = entity.ChartOfAccount.Name
|
||||
}
|
||||
|
||||
if entity.PurchaseCategory != nil {
|
||||
response.PurchaseCategoryName = entity.PurchaseCategory.Name
|
||||
response.PurchaseCategoryType = string(entity.PurchaseCategory.Type)
|
||||
response.PurchaseCategory = PurchaseCategoryEntityToResponse(entity.PurchaseCategory)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
func ExpenseItemEntitiesToResponses(entities []entities.ExpenseItem) []models.ExpenseItemResponse {
|
||||
if entities == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
responses := make([]models.ExpenseItemResponse, len(entities))
|
||||
for i, entity := range entities {
|
||||
response := ExpenseItemEntityToResponse(&entity)
|
||||
if response != nil {
|
||||
responses[i] = *response
|
||||
}
|
||||
}
|
||||
return responses
|
||||
}
|
||||
@@ -82,7 +82,7 @@ func OrderEntityToResponse(order *entities.Order) *models.OrderResponse {
|
||||
}
|
||||
|
||||
for i, item := range order.OrderItems {
|
||||
resp := OrderItemEntityToResponse(&item)
|
||||
resp := OrderItemEntityToResponse(&item, order.OutletID)
|
||||
if resp != nil {
|
||||
resp.PaidQuantity = paidQtyByOrderItem[item.ID]
|
||||
response.OrderItems[i] = *resp
|
||||
@@ -101,11 +101,20 @@ func OrderEntityToResponse(order *entities.Order) *models.OrderResponse {
|
||||
return response
|
||||
}
|
||||
|
||||
func OrderItemEntityToResponse(item *entities.OrderItem) *models.OrderItemResponse {
|
||||
func OrderItemEntityToResponse(item *entities.OrderItem, outletID uuid.UUID) *models.OrderItemResponse {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve print_to_checker from preloaded outlet prices
|
||||
printToChecker := true // default
|
||||
for _, op := range item.Product.ProductOutletPrices {
|
||||
if op.OutletID == outletID {
|
||||
printToChecker = op.PrintToChecker
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
response := &models.OrderItemResponse{
|
||||
ID: item.ID,
|
||||
OrderID: item.OrderID,
|
||||
@@ -130,10 +139,19 @@ func OrderItemEntityToResponse(item *entities.OrderItem) *models.OrderItemRespon
|
||||
CreatedAt: item.CreatedAt,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
PrinterType: item.Product.PrinterType,
|
||||
PrintToChecker: printToChecker,
|
||||
}
|
||||
|
||||
if item.Product.ID != uuid.Nil {
|
||||
response.ProductName = item.Product.Name
|
||||
if item.Product.CategoryID != uuid.Nil {
|
||||
categoryID := item.Product.CategoryID
|
||||
response.CategoryID = &categoryID
|
||||
}
|
||||
if item.Product.Category.ID != uuid.Nil {
|
||||
categoryName := item.Product.Category.Name
|
||||
response.CategoryName = &categoryName
|
||||
}
|
||||
}
|
||||
|
||||
if item.ProductVariant != nil {
|
||||
@@ -316,14 +334,14 @@ func OrderEntitiesToResponses(orders []*entities.Order) []models.OrderResponse {
|
||||
return responses
|
||||
}
|
||||
|
||||
func OrderItemEntitiesToResponses(items []*entities.OrderItem) []models.OrderItemResponse {
|
||||
func OrderItemEntitiesToResponses(items []*entities.OrderItem, outletID uuid.UUID) []models.OrderItemResponse {
|
||||
if items == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
responses := make([]models.OrderItemResponse, len(items))
|
||||
for i, item := range items {
|
||||
response := OrderItemEntityToResponse(item)
|
||||
response := OrderItemEntityToResponse(item, outletID)
|
||||
if response != nil {
|
||||
responses[i] = *response
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func TestOrderItemEntityToResponse_WithProductNames(t *testing.T) {
|
||||
}
|
||||
|
||||
// Act
|
||||
result := OrderItemEntityToResponse(orderItem)
|
||||
result := OrderItemEntityToResponse(orderItem, uuid.Nil)
|
||||
|
||||
// Assert
|
||||
assert.NotNil(t, result)
|
||||
@@ -89,7 +89,7 @@ func TestOrderItemEntityToResponse_WithoutProductVariant(t *testing.T) {
|
||||
}
|
||||
|
||||
// Act
|
||||
result := OrderItemEntityToResponse(orderItem)
|
||||
result := OrderItemEntityToResponse(orderItem, uuid.Nil)
|
||||
|
||||
// Assert
|
||||
assert.NotNil(t, result)
|
||||
@@ -129,7 +129,7 @@ func TestOrderItemEntityToResponse_WithoutProductPreload(t *testing.T) {
|
||||
}
|
||||
|
||||
// Act
|
||||
result := OrderItemEntityToResponse(orderItem)
|
||||
result := OrderItemEntityToResponse(orderItem, uuid.Nil)
|
||||
|
||||
// Assert
|
||||
assert.NotNil(t, result)
|
||||
|
||||
@@ -11,17 +11,17 @@ func MapProductIngredientEntityToModel(entity *entities.ProductIngredient) *mode
|
||||
}
|
||||
|
||||
return &models.ProductIngredient{
|
||||
ID: entity.ID,
|
||||
OrganizationID: entity.OrganizationID,
|
||||
OutletID: entity.OutletID,
|
||||
ProductID: entity.ProductID,
|
||||
IngredientID: entity.IngredientID,
|
||||
Quantity: entity.Quantity,
|
||||
WastePercentage: entity.WastePercentage,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
Product: ProductEntityToModel(entity.Product),
|
||||
Ingredient: MapIngredientEntityToModel(entity.Ingredient),
|
||||
ID: entity.ID,
|
||||
OrganizationID: entity.OrganizationID,
|
||||
OutletID: entity.OutletID,
|
||||
ProductID: entity.ProductID,
|
||||
IngredientID: entity.IngredientID,
|
||||
Quantity: entity.Quantity,
|
||||
WastePercentage: entity.WastePercentage,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
Product: ProductEntityToModel(entity.Product),
|
||||
Ingredient: MapIngredientEntityToModel(entity.Ingredient),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,17 +31,17 @@ func MapProductIngredientModelToEntity(model *models.ProductIngredient) *entitie
|
||||
}
|
||||
|
||||
return &entities.ProductIngredient{
|
||||
ID: model.ID,
|
||||
OrganizationID: model.OrganizationID,
|
||||
OutletID: model.OutletID,
|
||||
ProductID: model.ProductID,
|
||||
IngredientID: model.IngredientID,
|
||||
Quantity: model.Quantity,
|
||||
WastePercentage: model.WastePercentage,
|
||||
CreatedAt: model.CreatedAt,
|
||||
UpdatedAt: model.UpdatedAt,
|
||||
Product: ProductModelToEntity(model.Product),
|
||||
Ingredient: MapIngredientModelToEntity(model.Ingredient),
|
||||
ID: model.ID,
|
||||
OrganizationID: model.OrganizationID,
|
||||
OutletID: model.OutletID,
|
||||
ProductID: model.ProductID,
|
||||
IngredientID: model.IngredientID,
|
||||
Quantity: model.Quantity,
|
||||
WastePercentage: model.WastePercentage,
|
||||
CreatedAt: model.CreatedAt,
|
||||
UpdatedAt: model.UpdatedAt,
|
||||
Product: ProductModelToEntity(model.Product),
|
||||
Ingredient: MapIngredientModelToEntity(model.Ingredient),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,12 +11,13 @@ func ProductOutletPriceEntityToModel(entity *entities.ProductOutletPrice) *model
|
||||
}
|
||||
|
||||
return &models.ProductOutletPrice{
|
||||
ID: entity.ID,
|
||||
ProductID: entity.ProductID,
|
||||
OutletID: entity.OutletID,
|
||||
Price: entity.Price,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
ID: entity.ID,
|
||||
ProductID: entity.ProductID,
|
||||
OutletID: entity.OutletID,
|
||||
Price: entity.Price,
|
||||
PrintToChecker: entity.PrintToChecker,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,12 +27,13 @@ func ProductOutletPriceModelToEntity(model *models.ProductOutletPrice) *entities
|
||||
}
|
||||
|
||||
return &entities.ProductOutletPrice{
|
||||
ID: model.ID,
|
||||
ProductID: model.ProductID,
|
||||
OutletID: model.OutletID,
|
||||
Price: model.Price,
|
||||
CreatedAt: model.CreatedAt,
|
||||
UpdatedAt: model.UpdatedAt,
|
||||
ID: model.ID,
|
||||
ProductID: model.ProductID,
|
||||
OutletID: model.OutletID,
|
||||
Price: model.Price,
|
||||
PrintToChecker: model.PrintToChecker,
|
||||
CreatedAt: model.CreatedAt,
|
||||
UpdatedAt: model.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
func CreatePurchaseCategoryRequestToEntity(req *models.CreatePurchaseCategoryRequest) *entities.PurchaseCategory {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &entities.PurchaseCategory{
|
||||
OrganizationID: req.OrganizationID,
|
||||
ParentID: req.ParentID,
|
||||
Name: req.Name,
|
||||
Type: entities.PurchaseCategoryType(req.Type),
|
||||
SortOrder: req.SortOrder,
|
||||
IsActive: req.IsActive,
|
||||
}
|
||||
}
|
||||
|
||||
func PurchaseCategoryEntityToResponse(entity *entities.PurchaseCategory) *models.PurchaseCategoryResponse {
|
||||
if entity == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.PurchaseCategoryResponse{
|
||||
ID: entity.ID,
|
||||
OrganizationID: entity.OrganizationID,
|
||||
PresetID: entity.PresetID,
|
||||
ParentID: entity.ParentID,
|
||||
Code: entity.Code,
|
||||
Name: entity.Name,
|
||||
Type: string(entity.Type),
|
||||
SortOrder: entity.SortOrder,
|
||||
IsSystem: entity.IsSystem,
|
||||
IsActive: entity.IsActive,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func PurchaseCategoryEntitiesToResponses(categoryEntities []*entities.PurchaseCategory) []models.PurchaseCategoryResponse {
|
||||
responses := make([]models.PurchaseCategoryResponse, len(categoryEntities))
|
||||
for i, entity := range categoryEntities {
|
||||
response := PurchaseCategoryEntityToResponse(entity)
|
||||
if response != nil {
|
||||
responses[i] = *response
|
||||
}
|
||||
}
|
||||
return responses
|
||||
}
|
||||
@@ -1,10 +1,33 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"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 {
|
||||
if entity == nil {
|
||||
return nil
|
||||
@@ -13,6 +36,7 @@ func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.Purchase
|
||||
return &models.PurchaseOrder{
|
||||
ID: entity.ID,
|
||||
OrganizationID: entity.OrganizationID,
|
||||
OutletID: entity.OutletID,
|
||||
VendorID: entity.VendorID,
|
||||
PONumber: entity.PONumber,
|
||||
TransactionDate: entity.TransactionDate,
|
||||
@@ -21,6 +45,8 @@ func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.Purchase
|
||||
Status: entity.Status,
|
||||
Message: entity.Message,
|
||||
TotalAmount: entity.TotalAmount,
|
||||
TeamScope: entity.TeamScope,
|
||||
TeamCategoryID: entity.TeamCategoryID,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
}
|
||||
@@ -34,6 +60,7 @@ func PurchaseOrderModelToEntity(model *models.PurchaseOrder) *entities.PurchaseO
|
||||
return &entities.PurchaseOrder{
|
||||
ID: model.ID,
|
||||
OrganizationID: model.OrganizationID,
|
||||
OutletID: model.OutletID,
|
||||
VendorID: model.VendorID,
|
||||
PONumber: model.PONumber,
|
||||
TransactionDate: model.TransactionDate,
|
||||
@@ -42,6 +69,8 @@ func PurchaseOrderModelToEntity(model *models.PurchaseOrder) *entities.PurchaseO
|
||||
Status: model.Status,
|
||||
Message: model.Message,
|
||||
TotalAmount: model.TotalAmount,
|
||||
TeamScope: model.TeamScope,
|
||||
TeamCategoryID: model.TeamCategoryID,
|
||||
CreatedAt: model.CreatedAt,
|
||||
UpdatedAt: model.UpdatedAt,
|
||||
}
|
||||
@@ -55,6 +84,7 @@ func PurchaseOrderEntityToResponse(entity *entities.PurchaseOrder) *models.Purch
|
||||
response := &models.PurchaseOrderResponse{
|
||||
ID: entity.ID,
|
||||
OrganizationID: entity.OrganizationID,
|
||||
OutletID: entity.OutletID,
|
||||
VendorID: entity.VendorID,
|
||||
PONumber: entity.PONumber,
|
||||
TransactionDate: entity.TransactionDate,
|
||||
@@ -63,8 +93,11 @@ func PurchaseOrderEntityToResponse(entity *entities.PurchaseOrder) *models.Purch
|
||||
Status: entity.Status,
|
||||
Message: entity.Message,
|
||||
TotalAmount: entity.TotalAmount,
|
||||
TeamScope: entity.TeamScope,
|
||||
TeamCategoryID: entity.TeamCategoryID,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
Team: purchaseTeamFromEntity(entity),
|
||||
}
|
||||
|
||||
// Map vendor if present
|
||||
@@ -91,15 +124,16 @@ func PurchaseOrderItemEntityToModel(entity *entities.PurchaseOrderItem) *models.
|
||||
}
|
||||
|
||||
return &models.PurchaseOrderItem{
|
||||
ID: entity.ID,
|
||||
PurchaseOrderID: entity.PurchaseOrderID,
|
||||
IngredientID: entity.IngredientID,
|
||||
Description: entity.Description,
|
||||
Quantity: entity.Quantity,
|
||||
UnitID: entity.UnitID,
|
||||
Amount: entity.Amount,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
ID: entity.ID,
|
||||
PurchaseOrderID: entity.PurchaseOrderID,
|
||||
IngredientID: entity.IngredientID,
|
||||
PurchaseCategoryID: entity.PurchaseCategoryID,
|
||||
Description: entity.Description,
|
||||
Quantity: entity.Quantity,
|
||||
UnitID: entity.UnitID,
|
||||
Amount: entity.Amount,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,15 +143,16 @@ func PurchaseOrderItemModelToEntity(model *models.PurchaseOrderItem) *entities.P
|
||||
}
|
||||
|
||||
return &entities.PurchaseOrderItem{
|
||||
ID: model.ID,
|
||||
PurchaseOrderID: model.PurchaseOrderID,
|
||||
IngredientID: model.IngredientID,
|
||||
Description: model.Description,
|
||||
Quantity: model.Quantity,
|
||||
UnitID: model.UnitID,
|
||||
Amount: model.Amount,
|
||||
CreatedAt: model.CreatedAt,
|
||||
UpdatedAt: model.UpdatedAt,
|
||||
ID: model.ID,
|
||||
PurchaseOrderID: model.PurchaseOrderID,
|
||||
IngredientID: model.IngredientID,
|
||||
PurchaseCategoryID: model.PurchaseCategoryID,
|
||||
Description: model.Description,
|
||||
Quantity: model.Quantity,
|
||||
UnitID: model.UnitID,
|
||||
Amount: model.Amount,
|
||||
CreatedAt: model.CreatedAt,
|
||||
UpdatedAt: model.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,15 +162,16 @@ func PurchaseOrderItemEntityToResponse(entity *entities.PurchaseOrderItem) *mode
|
||||
}
|
||||
|
||||
response := &models.PurchaseOrderItemResponse{
|
||||
ID: entity.ID,
|
||||
PurchaseOrderID: entity.PurchaseOrderID,
|
||||
IngredientID: entity.IngredientID,
|
||||
Description: entity.Description,
|
||||
Quantity: entity.Quantity,
|
||||
UnitID: entity.UnitID,
|
||||
Amount: entity.Amount,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
ID: entity.ID,
|
||||
PurchaseOrderID: entity.PurchaseOrderID,
|
||||
IngredientID: entity.IngredientID,
|
||||
PurchaseCategoryID: entity.PurchaseCategoryID,
|
||||
Description: entity.Description,
|
||||
Quantity: entity.Quantity,
|
||||
UnitID: entity.UnitID,
|
||||
Amount: entity.Amount,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
}
|
||||
|
||||
// Map ingredient if present
|
||||
@@ -146,6 +182,10 @@ func PurchaseOrderItemEntityToResponse(entity *entities.PurchaseOrderItem) *mode
|
||||
}
|
||||
}
|
||||
|
||||
if entity.PurchaseCategory != nil {
|
||||
response.PurchaseCategory = PurchaseCategoryEntityToResponse(entity.PurchaseCategory)
|
||||
}
|
||||
|
||||
// Map unit if present
|
||||
if entity.Unit != nil {
|
||||
response.Unit = &models.UnitResponse{
|
||||
|
||||
@@ -82,7 +82,11 @@ func (m *AuthMiddleware) RequireRole(allowedRoles ...string) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc {
|
||||
return m.RequireRole("superadmin", "admin", "manager")
|
||||
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) RequireAdminOrManagerOrPurchasing() gin.HandlerFunc {
|
||||
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
IdempotencyKeyHeader = "X-Idempotency-Key"
|
||||
idempotencyTTL = 24 * time.Hour
|
||||
idempotencyPrefix = "idempotency:"
|
||||
)
|
||||
|
||||
type cachedResponse struct {
|
||||
StatusCode int `json:"status_code"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// IdempotencyMiddleware returns a Gin middleware that ensures idempotent processing
|
||||
// for mutating operations. Client must send X-Idempotency-Key header.
|
||||
func IdempotencyMiddleware(redisClient *redis.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
key := c.GetHeader(IdempotencyKeyHeader)
|
||||
if key == "" {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"errors": []gin.H{
|
||||
{
|
||||
"code": "missing_idempotency_key",
|
||||
"entity": "IdempotencyMiddleware",
|
||||
"cause": "X-Idempotency-Key header is required",
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
redisKey := fmt.Sprintf("%s%s", idempotencyPrefix, key)
|
||||
ctx := context.Background()
|
||||
|
||||
fmt.Printf("[DEBUG] IdempotencyMiddleware: key=%s redisKey=%s\n", key, redisKey)
|
||||
|
||||
// Check if key already exists (request was already processed)
|
||||
cached, err := redisClient.Get(ctx, redisKey).Result()
|
||||
if err == nil {
|
||||
// Key exists — return cached response
|
||||
fmt.Printf("[DEBUG] IdempotencyMiddleware: cache HIT for key=%s\n", key)
|
||||
var resp cachedResponse
|
||||
if err := json.Unmarshal([]byte(cached), &resp); err == nil {
|
||||
for k, v := range resp.Headers {
|
||||
c.Writer.Header().Set(k, v)
|
||||
}
|
||||
c.Writer.Header().Set("X-Idempotent-Replay", "true")
|
||||
c.Data(resp.StatusCode, "application/json", []byte(resp.Body))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("[DEBUG] IdempotencyMiddleware: cache MISS for key=%s err=%v\n", key, err)
|
||||
}
|
||||
|
||||
// Mark key as in-progress to prevent concurrent duplicates
|
||||
set, err := redisClient.SetNX(ctx, redisKey, "processing", idempotencyTTL).Result()
|
||||
if err != nil {
|
||||
// Redis error — proceed without idempotency (fail open)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if !set {
|
||||
// Another request with the same key is being processed
|
||||
c.AbortWithStatusJSON(http.StatusConflict, gin.H{
|
||||
"success": false,
|
||||
"errors": []gin.H{
|
||||
{
|
||||
"code": "request_in_progress",
|
||||
"entity": "IdempotencyMiddleware",
|
||||
"cause": "A request with this idempotency key is already being processed",
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Capture response using a custom writer
|
||||
writer := &responseCapture{
|
||||
ResponseWriter: c.Writer,
|
||||
body: &bytes.Buffer{},
|
||||
}
|
||||
c.Writer = writer
|
||||
|
||||
c.Next()
|
||||
|
||||
// After handler completes, cache the response only if successful (2xx)
|
||||
statusCode := writer.Status()
|
||||
if statusCode >= 200 && statusCode < 300 {
|
||||
resp := cachedResponse{
|
||||
StatusCode: statusCode,
|
||||
Headers: map[string]string{
|
||||
"Content-Type": writer.Header().Get("Content-Type"),
|
||||
},
|
||||
Body: writer.body.String(),
|
||||
}
|
||||
|
||||
respJSON, err := json.Marshal(resp)
|
||||
if err == nil {
|
||||
redisClient.Set(ctx, redisKey, string(respJSON), idempotencyTTL)
|
||||
}
|
||||
} else {
|
||||
// Remove the in-progress key so the client can retry with the same key
|
||||
redisClient.Del(ctx, redisKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// responseCapture wraps gin.ResponseWriter to capture the response body
|
||||
type responseCapture struct {
|
||||
gin.ResponseWriter
|
||||
body *bytes.Buffer
|
||||
}
|
||||
|
||||
func (w *responseCapture) Write(b []byte) (int, error) {
|
||||
w.body.Write(b)
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func (w *responseCapture) WriteString(s string) (int, error) {
|
||||
w.body.WriteString(s)
|
||||
return w.ResponseWriter.WriteString(s)
|
||||
}
|
||||
@@ -25,12 +25,12 @@ type AccountResponse struct {
|
||||
}
|
||||
|
||||
type CreateAccountRequest struct {
|
||||
ChartOfAccountID uuid.UUID `json:"chart_of_account_id" validate:"required"`
|
||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||
Number string `json:"number" validate:"required,min=1,max=50"`
|
||||
AccountType string `json:"account_type" validate:"required,oneof=cash wallet bank credit debit asset liability equity revenue expense"`
|
||||
OpeningBalance float64 `json:"opening_balance"`
|
||||
Description *string `json:"description"`
|
||||
ChartOfAccountID uuid.UUID `json:"chart_of_account_id" validate:"required"`
|
||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||
Number string `json:"number" validate:"required,min=1,max=50"`
|
||||
AccountType string `json:"account_type" validate:"required,oneof=cash wallet bank credit debit asset liability equity revenue expense"`
|
||||
OpeningBalance float64 `json:"opening_balance"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
type UpdateAccountRequest struct {
|
||||
|
||||
+378
-34
@@ -1,8 +1,12 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -19,6 +23,7 @@ type PaymentMethodAnalyticsRequest struct {
|
||||
type PaymentMethodAnalyticsResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
GroupBy string `json:"group_by"`
|
||||
@@ -58,6 +63,7 @@ type SalesAnalyticsRequest struct {
|
||||
type SalesAnalyticsResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
GroupBy string `json:"group_by"`
|
||||
@@ -91,9 +97,34 @@ type SalesAnalyticsData struct {
|
||||
type PurchasingAnalyticsRequest struct {
|
||||
OrganizationID uuid.UUID `validate:"required"`
|
||||
OutletID *uuid.UUID `validate:"omitempty"`
|
||||
DateFrom time.Time `validate:"required"`
|
||||
DateTo time.Time `validate:"required"`
|
||||
GroupBy string `validate:"omitempty,oneof=day hour week month"`
|
||||
// Team is the raw value the team picker sends: a parent category id,
|
||||
// "central" for Pusat, "none" for purchases with no team, or empty for all.
|
||||
Team string
|
||||
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
|
||||
@@ -101,6 +132,7 @@ type PurchasingAnalyticsResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
Team string `json:"team,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
GroupBy string `json:"group_by"`
|
||||
@@ -108,26 +140,49 @@ type PurchasingAnalyticsResponse struct {
|
||||
Data []PurchasingAnalyticsData `json:"data"`
|
||||
IngredientData []PurchasingIngredientData `json:"ingredient_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
|
||||
type PurchasingSummary struct {
|
||||
TotalPurchases float64 `json:"total_purchases"`
|
||||
RawMaterialPurchases float64 `json:"raw_material_purchases"`
|
||||
ExpensePurchases float64 `json:"expense_purchases"`
|
||||
TotalPurchaseOrders int64 `json:"total_purchase_orders"`
|
||||
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
|
||||
ExpenseCount int64 `json:"expense_count"`
|
||||
TotalQuantity float64 `json:"total_quantity"`
|
||||
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
|
||||
TotalIngredients int64 `json:"total_ingredients"`
|
||||
TotalVendors int64 `json:"total_vendors"`
|
||||
TotalTeams int64 `json:"total_teams"`
|
||||
}
|
||||
|
||||
// PurchasingAnalyticsData represents purchasing analytics by time period
|
||||
type PurchasingAnalyticsData struct {
|
||||
Date time.Time `json:"date"`
|
||||
Purchases float64 `json:"purchases"`
|
||||
PurchaseOrders int64 `json:"purchase_orders"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Ingredients int64 `json:"ingredients"`
|
||||
Vendors int64 `json:"vendors"`
|
||||
Date time.Time `json:"date"`
|
||||
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
|
||||
@@ -142,12 +197,12 @@ type PurchasingIngredientData struct {
|
||||
|
||||
// PurchasingVendorData represents purchasing analytics for a vendor
|
||||
type PurchasingVendorData struct {
|
||||
VendorID uuid.UUID `json:"vendor_id"`
|
||||
VendorName string `json:"vendor_name"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||
IngredientCount int64 `json:"ingredient_count"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
VendorID *uuid.UUID `json:"vendor_id"`
|
||||
VendorName string `json:"vendor_name"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||
IngredientCount int64 `json:"ingredient_count"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
// ProductAnalyticsRequest represents the request for product analytics
|
||||
@@ -163,6 +218,7 @@ type ProductAnalyticsRequest struct {
|
||||
type ProductAnalyticsResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
Data []ProductAnalyticsData `json:"data"`
|
||||
@@ -172,6 +228,7 @@ type ProductAnalyticsData struct {
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
ProductSku string `json:"product_sku"`
|
||||
ProductPrice float64 `json:"product_price"`
|
||||
CategoryID uuid.UUID `json:"category_id"`
|
||||
CategoryName string `json:"category_name"`
|
||||
CategoryOrder int `json:"category_order"`
|
||||
@@ -199,6 +256,7 @@ type ProductAnalyticsPerCategoryRequest struct {
|
||||
type ProductAnalyticsPerCategoryResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
Data []ProductAnalyticsPerCategoryData `json:"data"`
|
||||
@@ -216,6 +274,135 @@ type ProductAnalyticsPerCategoryData struct {
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
}
|
||||
|
||||
// ProductAnalyticsPerParentCategoryRequest represents the request for product analytics per parent category
|
||||
type ProductAnalyticsPerParentCategoryRequest struct {
|
||||
OrganizationID uuid.UUID `validate:"required"`
|
||||
OutletID *uuid.UUID `validate:"omitempty"`
|
||||
DateFrom time.Time `validate:"required"`
|
||||
DateTo time.Time `validate:"required"`
|
||||
}
|
||||
|
||||
// ProductAnalyticsPerParentCategoryResponse represents the response for product analytics per parent category
|
||||
type ProductAnalyticsPerParentCategoryResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
Data []ProductAnalyticsPerParentCategoryData `json:"data"`
|
||||
Budget BudgetCutOff `json:"budget"`
|
||||
}
|
||||
|
||||
type ProductAnalyticsPerParentCategoryData struct {
|
||||
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||
ParentCategoryName string `json:"parent_category_name"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
CategoryCount int64 `json:"category_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
}
|
||||
|
||||
// ParentCategoryAnalyticsDetailRequest represents the request for the drill-down of one parent category
|
||||
type ParentCategoryAnalyticsDetailRequest struct {
|
||||
OrganizationID uuid.UUID `validate:"required"`
|
||||
ParentCategoryID uuid.UUID `validate:"required"`
|
||||
OutletID *uuid.UUID `validate:"omitempty"`
|
||||
DateFrom time.Time `validate:"required"`
|
||||
DateTo time.Time `validate:"required"`
|
||||
}
|
||||
|
||||
// ParentCategoryAnalyticsDetailResponse represents the drill-down of one parent category
|
||||
type ParentCategoryAnalyticsDetailResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||
ParentCategoryName string `json:"parent_category_name"`
|
||||
Summary ParentCategoryAnalyticsDetailSummary `json:"summary"`
|
||||
Categories []ParentCategoryAnalyticsDetailData `json:"categories"`
|
||||
Budget BudgetCutOff `json:"budget"`
|
||||
}
|
||||
|
||||
type ParentCategoryAnalyticsDetailSummary struct {
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
CategoryCount int64 `json:"category_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
}
|
||||
|
||||
type ParentCategoryAnalyticsDetailData struct {
|
||||
CategoryID uuid.UUID `json:"category_id"`
|
||||
CategoryName string `json:"category_name"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
Products []ParentCategoryAnalyticsProductData `json:"products"`
|
||||
}
|
||||
|
||||
type ParentCategoryAnalyticsProductData struct {
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
ProductSku string `json:"product_sku"`
|
||||
ProductPrice float64 `json:"product_price"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
AveragePrice float64 `json:"average_price"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
StandardHppPerUnit float64 `json:"standard_hpp_per_unit"`
|
||||
StandardHppTotal float64 `json:"standard_hpp_total"`
|
||||
FifoHppPerUnit float64 `json:"fifo_hpp_per_unit"`
|
||||
FifoHppTotal float64 `json:"fifo_hpp_total"`
|
||||
MovingAverageHppPerUnit float64 `json:"moving_average_hpp_per_unit"`
|
||||
MovingAverageHppTotal float64 `json:"moving_average_hpp_total"`
|
||||
}
|
||||
|
||||
// BudgetCutOff is the Monday-to-Sunday spending limit breakdown attached to the
|
||||
// parent category reports.
|
||||
type BudgetCutOff struct {
|
||||
Percentages BudgetPercentages `json:"percentages"`
|
||||
CutOffFrom time.Time `json:"cut_off_from"`
|
||||
CutOffTo time.Time `json:"cut_off_to"`
|
||||
Total BudgetPeriod `json:"total"`
|
||||
Weekly []BudgetPeriod `json:"weekly"`
|
||||
Monthly []BudgetMonthPeriod `json:"monthly"`
|
||||
}
|
||||
|
||||
type BudgetPercentages struct {
|
||||
Purchase float64 `json:"purchase"`
|
||||
Owner float64 `json:"owner"`
|
||||
Team float64 `json:"team"`
|
||||
}
|
||||
|
||||
type BudgetPeriod struct {
|
||||
PeriodStart time.Time `json:"period_start"`
|
||||
PeriodEnd time.Time `json:"period_end"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
LimitPurchase float64 `json:"limit_purchase"`
|
||||
LimitOwner float64 `json:"limit_owner"`
|
||||
LimitTeam float64 `json:"limit_team"`
|
||||
}
|
||||
|
||||
type BudgetMonthPeriod struct {
|
||||
Month string `json:"month"`
|
||||
WeekCount int `json:"week_count"`
|
||||
BudgetPeriod
|
||||
}
|
||||
|
||||
// DashboardAnalyticsRequest represents the request for dashboard analytics
|
||||
type DashboardAnalyticsRequest struct {
|
||||
OrganizationID uuid.UUID `validate:"required"`
|
||||
@@ -228,6 +415,7 @@ type DashboardAnalyticsRequest struct {
|
||||
type DashboardAnalyticsResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
Overview DashboardOverview `json:"overview"`
|
||||
@@ -238,15 +426,17 @@ type DashboardAnalyticsResponse struct {
|
||||
|
||||
// DashboardOverview represents the overview data for dashboard
|
||||
type DashboardOverview struct {
|
||||
TotalSales float64 `json:"total_sales"`
|
||||
TotalOrders int64 `json:"total_orders"`
|
||||
AverageOrderValue float64 `json:"average_order_value"`
|
||||
TotalCustomers int64 `json:"total_customers"`
|
||||
VoidedOrders int64 `json:"voided_orders"`
|
||||
RefundedOrders int64 `json:"refunded_orders"`
|
||||
TotalSales float64 `json:"total_sales"`
|
||||
TotalOrders int64 `json:"total_orders"`
|
||||
AverageOrderValue float64 `json:"average_order_value"`
|
||||
TotalCustomers int64 `json:"total_customers"`
|
||||
VoidedOrders int64 `json:"voided_orders"`
|
||||
RefundedOrders int64 `json:"refunded_orders"`
|
||||
TotalItemSold int64 `json:"total_item_sold"`
|
||||
TotalLowStock int64 `json:"total_low_stock"`
|
||||
TotalProductActive int64 `json:"total_product_active"`
|
||||
}
|
||||
|
||||
// ProfitLossAnalyticsRequest represents the request for profit and loss analytics
|
||||
type ProfitLossAnalyticsRequest struct {
|
||||
OrganizationID uuid.UUID `validate:"required"`
|
||||
OutletID *uuid.UUID `validate:"omitempty"`
|
||||
@@ -255,19 +445,39 @@ type ProfitLossAnalyticsRequest struct {
|
||||
GroupBy string `validate:"omitempty,oneof=day hour week month"`
|
||||
}
|
||||
|
||||
// ProfitLossAnalyticsResponse represents the response for profit and loss analytics
|
||||
type ProfitLossAnalyticsResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
GroupBy string `json:"group_by"`
|
||||
Summary ProfitLossSummary `json:"summary"`
|
||||
Data []ProfitLossData `json:"data"`
|
||||
ProductData []ProductProfitData `json:"product_data"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
GroupBy string `json:"group_by"`
|
||||
Summary ProfitLossSummary `json:"summary"`
|
||||
Data []ProfitLossData `json:"data"`
|
||||
ProductData []ProductProfitData `json:"product_data"`
|
||||
MainSummary []ProfitLossSummaryRow `json:"main_summary"`
|
||||
Purchasing ProfitLossPurchasing `json:"purchasing"`
|
||||
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
|
||||
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
||||
}
|
||||
|
||||
type ProfitLossPurchasing struct {
|
||||
TodayTotal float64 `json:"today_total"`
|
||||
MtdTotal float64 `json:"mtd_total"`
|
||||
TodayRawMaterial float64 `json:"today_raw_material"`
|
||||
MtdRawMaterial float64 `json:"mtd_raw_material"`
|
||||
TodayExpense float64 `json:"today_expense"`
|
||||
MtdExpense float64 `json:"mtd_expense"`
|
||||
Items []ProfitLossPurchasingItem `json:"items"`
|
||||
}
|
||||
|
||||
type ProfitLossPurchasingItem struct {
|
||||
Date time.Time `json:"date"`
|
||||
Item string `json:"item"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Nominal float64 `json:"nominal"`
|
||||
}
|
||||
|
||||
// ProfitLossSummary represents the summary of profit and loss analytics
|
||||
type ProfitLossSummary struct {
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
@@ -282,7 +492,6 @@ type ProfitLossSummary struct {
|
||||
ProfitabilityRatio float64 `json:"profitability_ratio"`
|
||||
}
|
||||
|
||||
// ProfitLossData represents individual profit and loss data point by time period
|
||||
type ProfitLossData struct {
|
||||
Date time.Time `json:"date"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
@@ -296,7 +505,6 @@ type ProfitLossData struct {
|
||||
Orders int64 `json:"orders"`
|
||||
}
|
||||
|
||||
// ProductProfitData represents profit data for individual products
|
||||
type ProductProfitData struct {
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
@@ -311,3 +519,139 @@ type ProductProfitData struct {
|
||||
AverageCost float64 `json:"average_cost"`
|
||||
ProfitPerUnit float64 `json:"profit_per_unit"`
|
||||
}
|
||||
|
||||
type ProfitLossSummaryRow struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
IsBold bool `json:"is_bold"`
|
||||
TodayNominal float64 `json:"today_nominal"`
|
||||
TodayPct float64 `json:"today_pct"`
|
||||
MtdNominal float64 `json:"mtd_nominal"`
|
||||
MtdPct float64 `json:"mtd_pct"`
|
||||
SubItems []ProfitLossSummaryRow `json:"sub_items,omitempty"`
|
||||
}
|
||||
|
||||
type OperationalExpenseItem struct {
|
||||
Item string `json:"item"`
|
||||
Nominal float64 `json:"nominal"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryPeriodRequest struct {
|
||||
OrganizationID uuid.UUID `validate:"required"`
|
||||
OutletID *uuid.UUID `validate:"omitempty"`
|
||||
DateFrom time.Time `validate:"required"`
|
||||
DateTo time.Time `validate:"required"`
|
||||
ExcludeGajiStaffFromReimburse bool `validate:"omitempty"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryMonthlyRequest struct {
|
||||
OrganizationID uuid.UUID `validate:"required"`
|
||||
OutletID *uuid.UUID `validate:"omitempty"`
|
||||
Month time.Time `validate:"required"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryMTDRequest struct {
|
||||
OrganizationID uuid.UUID `validate:"required"`
|
||||
OutletID *uuid.UUID `validate:"omitempty"`
|
||||
DateTo time.Time `validate:"required"`
|
||||
ExcludeGajiStaffFromReimburse bool `validate:"omitempty"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryPeriodResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
Period ExclusiveSummaryPeriodRange `json:"period"`
|
||||
Summary ExclusiveSummaryPeriodSummary `json:"summary"`
|
||||
Reimburse ExclusiveSummaryReimburse `json:"reimburse"`
|
||||
HPPBreakdown []ExclusiveSummaryCategoryBreakdown `json:"hpp_breakdown"`
|
||||
OperationalExpenseBreakdown []ExclusiveSummaryCategoryBreakdown `json:"operational_expense_breakdown"`
|
||||
DailySummary []ExclusiveSummaryDailySummary `json:"daily_summary"`
|
||||
DailyTransactions []ExclusiveSummaryDailyTransaction `json:"daily_transactions"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryPeriodRange struct {
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryPeriodSummary struct {
|
||||
Sales float64 `json:"sales"`
|
||||
HPP float64 `json:"hpp"`
|
||||
GrossProfit float64 `json:"gross_profit"`
|
||||
SalaryTotal float64 `json:"salary_total"`
|
||||
SalaryDW float64 `json:"salary_dw"`
|
||||
SalaryStaff float64 `json:"salary_staff"`
|
||||
SalaryOther float64 `json:"salary_other"`
|
||||
OtherOperationalExpenses float64 `json:"other_operational_expenses"`
|
||||
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
NetProfit float64 `json:"net_profit"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryReimburse struct {
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
ExcludedSalaryStaff float64 `json:"excluded_salary_staff"`
|
||||
TotalReimburse float64 `json:"total_reimburse"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryCategoryBreakdown struct {
|
||||
CategoryCode string `json:"category_code"`
|
||||
CategoryName string `json:"category_name"`
|
||||
Amount float64 `json:"amount"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryDailySummary struct {
|
||||
Date time.Time `json:"date"`
|
||||
TransactionCount int64 `json:"transaction_count"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryDailyTransaction struct {
|
||||
Date time.Time `json:"date"`
|
||||
CategoryCode string `json:"category_code"`
|
||||
CategoryName string `json:"category_name"`
|
||||
Description string `json:"description"`
|
||||
Amount float64 `json:"amount"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryMonthlyResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
Month string `json:"month"`
|
||||
Summary ExclusiveSummaryMonthlySummary `json:"summary"`
|
||||
Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"`
|
||||
BankBalance []ExclusiveSummaryBankBalance `json:"bank_balance"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryMonthlySummary struct {
|
||||
TotalSales float64 `json:"total_sales"`
|
||||
HPP float64 `json:"hpp"`
|
||||
GrossProfit float64 `json:"gross_profit"`
|
||||
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
NetProfit float64 `json:"net_profit"`
|
||||
NetProfitMargin float64 `json:"net_profit_margin"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryMonthlyPeriod struct {
|
||||
Label string `json:"label"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
Sales float64 `json:"sales"`
|
||||
HPP float64 `json:"hpp"`
|
||||
GrossProfit float64 `json:"gross_profit"`
|
||||
GrossMargin float64 `json:"gross_margin"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryBankBalance struct {
|
||||
Bank string `json:"bank"`
|
||||
OpeningBalance *float64 `json:"opening_balance"`
|
||||
IncomingMutation *float64 `json:"incoming_mutation"`
|
||||
OutgoingMutation *float64 `json:"outgoing_mutation"`
|
||||
ClosingBalance *float64 `json:"closing_balance"`
|
||||
Notes *string `json:"notes"`
|
||||
}
|
||||
|
||||
@@ -9,10 +9,11 @@ import (
|
||||
type Category struct {
|
||||
ID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
OutletID *uuid.UUID
|
||||
Name string
|
||||
Description *string
|
||||
ImageURL *string
|
||||
Order int
|
||||
Order int
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
@@ -20,27 +21,34 @@ type Category struct {
|
||||
|
||||
type CreateCategoryRequest struct {
|
||||
OrganizationID uuid.UUID `validate:"required"`
|
||||
Name string `validate:"required,min=1,max=255"`
|
||||
Description *string `validate:"omitempty,max=1000"`
|
||||
ImageURL *string `validate:"omitempty,url"`
|
||||
Order int `validate:"min=0"`
|
||||
OutletID *uuid.UUID
|
||||
ParentID *uuid.UUID
|
||||
Name string `validate:"required,min=1,max=255"`
|
||||
Description *string `validate:"omitempty,max=1000"`
|
||||
ImageURL *string `validate:"omitempty,url"`
|
||||
Order int `validate:"min=0"`
|
||||
}
|
||||
|
||||
type UpdateCategoryRequest struct {
|
||||
Name *string `validate:"omitempty,min=1,max=255"`
|
||||
Description *string `validate:"omitempty,max=1000"`
|
||||
ImageURL *string `validate:"omitempty,url"`
|
||||
Order *int `validate:"omitempty,min=0"`
|
||||
OutletID *uuid.UUID
|
||||
ParentID *uuid.UUID
|
||||
Order *int `validate:"omitempty,min=0"`
|
||||
IsActive *bool
|
||||
}
|
||||
|
||||
type CategoryResponse struct {
|
||||
ID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
OutletID *uuid.UUID
|
||||
ParentID *uuid.UUID
|
||||
ParentName *string
|
||||
Name string
|
||||
Description *string
|
||||
ImageURL *string
|
||||
Order int
|
||||
Order int
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
|
||||
+11
-11
@@ -23,17 +23,17 @@ type UpdateCustomerRequest struct {
|
||||
}
|
||||
|
||||
type CustomerResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
Name string `json:"name"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Address *string `json:"address,omitempty"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Metadata entities.Metadata `json:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
Name string `json:"name"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Address *string `json:"address,omitempty"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Metadata entities.Metadata `json:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ListCustomersQuery represents query parameters for listing customers
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Expense struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID uuid.UUID `json:"outlet_id"`
|
||||
Receiver string `json:"receiver"`
|
||||
TransactionDate time.Time `json:"transaction_date"`
|
||||
CodeNumber string `json:"code_number"`
|
||||
Status string `json:"status"`
|
||||
Description *string `json:"description"`
|
||||
Tax float64 `json:"tax"`
|
||||
Total float64 `json:"total"`
|
||||
Reserved1 *string `json:"reserved1"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ExpenseItem struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ExpenseID uuid.UUID `json:"expense_id"`
|
||||
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
|
||||
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
|
||||
Item string `json:"item"`
|
||||
Description *string `json:"description"`
|
||||
Amount float64 `json:"amount"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ExpenseResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID uuid.UUID `json:"outlet_id"`
|
||||
Receiver string `json:"receiver"`
|
||||
TransactionDate time.Time `json:"transaction_date"`
|
||||
CodeNumber string `json:"code_number"`
|
||||
Status string `json:"status"`
|
||||
Description *string `json:"description"`
|
||||
Tax float64 `json:"tax"`
|
||||
Total float64 `json:"total"`
|
||||
Reserved1 *string `json:"reserved1"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Items []ExpenseItemResponse `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
type ExpenseItemResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ExpenseID uuid.UUID `json:"expense_id"`
|
||||
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
|
||||
ChartOfAccountName string `json:"chart_of_account_name,omitempty"`
|
||||
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
|
||||
PurchaseCategoryName string `json:"purchase_category_name,omitempty"`
|
||||
PurchaseCategoryType string `json:"purchase_category_type,omitempty"`
|
||||
PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
|
||||
Item string `json:"item"`
|
||||
Description *string `json:"description"`
|
||||
Amount float64 `json:"amount"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CreateExpenseRequest struct {
|
||||
Receiver string `json:"receiver"`
|
||||
TransactionDate string `json:"transaction_date"`
|
||||
CodeNumber string `json:"code_number"`
|
||||
OutletID string `json:"outlet_id"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
Description *string `json:"description"`
|
||||
Tax float64 `json:"tax"`
|
||||
Total float64 `json:"total"`
|
||||
Items []CreateExpenseItemRequest `json:"items"`
|
||||
}
|
||||
|
||||
type CreateExpenseItemRequest struct {
|
||||
ChartOfAccountID string `json:"chart_of_account_id"`
|
||||
PurchaseCategoryID string `json:"purchase_category_id"`
|
||||
Item string `json:"item"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Amount float64 `json:"amount"`
|
||||
}
|
||||
|
||||
type UpdateExpenseRequest struct {
|
||||
Receiver *string `json:"receiver,omitempty"`
|
||||
TransactionDate *string `json:"transaction_date,omitempty"`
|
||||
CodeNumber *string `json:"code_number,omitempty"`
|
||||
OutletID *string `json:"outlet_id,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Tax *float64 `json:"tax,omitempty"`
|
||||
Total *float64 `json:"total,omitempty"`
|
||||
Reserved1 *string `json:"reserved1,omitempty"`
|
||||
Items []UpdateExpenseItemRequest `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateExpenseItemRequest struct {
|
||||
ChartOfAccountID *string `json:"chart_of_account_id,omitempty"`
|
||||
PurchaseCategoryID *string `json:"purchase_category_id,omitempty"`
|
||||
Item *string `json:"item,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Amount *float64 `json:"amount,omitempty"`
|
||||
}
|
||||
|
||||
type ListExpenseRequest struct {
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
Search string `json:"search,omitempty"`
|
||||
OutletID string `json:"outlet_id,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
StartDate string `json:"start_date,omitempty"`
|
||||
EndDate string `json:"end_date,omitempty"`
|
||||
}
|
||||
|
||||
type ListExpenseResponse struct {
|
||||
Expenses []*ExpenseResponse `json:"expenses"`
|
||||
TotalCount int `json:"total_count"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsRequest struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
GroupBy string `json:"group_by"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
GroupBy string `json:"group_by"`
|
||||
Summary ExpenseAnalyticsSummary `json:"summary"`
|
||||
Data []ExpenseAnalyticsData `json:"data"`
|
||||
CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"`
|
||||
ChartOfAccountData []ExpenseAnalyticsChartOfAccountData `json:"chart_of_account_data"`
|
||||
ItemData []ExpenseAnalyticsItemData `json:"item_data"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsSummary struct {
|
||||
TotalExpenses float64 `json:"total_expenses"`
|
||||
TotalExpenseCount int64 `json:"total_expense_count"`
|
||||
TotalTax float64 `json:"total_tax"`
|
||||
AverageExpenseValue float64 `json:"average_expense_value"`
|
||||
TotalCategories int64 `json:"total_categories"`
|
||||
TotalItems int64 `json:"total_items"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsData struct {
|
||||
Date time.Time `json:"date"`
|
||||
Expenses float64 `json:"expenses"`
|
||||
ExpenseCount int64 `json:"expense_count"`
|
||||
Tax float64 `json:"tax"`
|
||||
Items int64 `json:"items"`
|
||||
Categories int64 `json:"categories"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsCategoryData struct {
|
||||
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
|
||||
PurchaseCategoryName string `json:"purchase_category_name"`
|
||||
PurchaseCategoryType string `json:"purchase_category_type"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
ExpenseCount int64 `json:"expense_count"`
|
||||
ItemCount int64 `json:"item_count"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsChartOfAccountData struct {
|
||||
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
|
||||
ChartOfAccountName string `json:"chart_of_account_name"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
ExpenseCount int64 `json:"expense_count"`
|
||||
ItemCount int64 `json:"item_count"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsItemData struct {
|
||||
Item string `json:"item"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
ExpenseCount int64 `json:"expense_count"`
|
||||
ItemCount int64 `json:"item_count"`
|
||||
}
|
||||
@@ -12,7 +12,7 @@ type Ingredient struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
Name string `json:"name"`
|
||||
UnitID uuid.UUID `json:"unit_id"`
|
||||
UnitID *uuid.UUID `json:"unit_id"`
|
||||
Cost float64 `json:"cost"`
|
||||
Stock float64 `json:"stock"`
|
||||
IsSemiFinished bool `json:"is_semi_finished"`
|
||||
@@ -29,7 +29,7 @@ type CreateIngredientRequest struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||
UnitID uuid.UUID `json:"unit_id" validate:"required"`
|
||||
UnitID *uuid.UUID `json:"unit_id" validate:"omitempty"`
|
||||
Cost float64 `json:"cost" validate:"min=0"`
|
||||
Stock float64 `json:"stock" validate:"min=0"`
|
||||
IsSemiFinished bool `json:"is_semi_finished"`
|
||||
@@ -48,7 +48,7 @@ type CompositionItemRequest struct {
|
||||
type UpdateIngredientRequest struct {
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||
UnitID uuid.UUID `json:"unit_id" validate:"required"`
|
||||
UnitID *uuid.UUID `json:"unit_id" validate:"omitempty"`
|
||||
Cost float64 `json:"cost" validate:"min=0"`
|
||||
Stock float64 `json:"stock" validate:"min=0"`
|
||||
IsSemiFinished bool `json:"is_semi_finished"`
|
||||
@@ -61,7 +61,7 @@ type IngredientResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
Name string `json:"name"`
|
||||
UnitID uuid.UUID `json:"unit_id"`
|
||||
UnitID *uuid.UUID `json:"unit_id"`
|
||||
Cost float64 `json:"cost"`
|
||||
Stock float64 `json:"stock"`
|
||||
IsSemiFinished bool `json:"is_semi_finished"`
|
||||
|
||||
@@ -97,8 +97,7 @@ type ListIngredientUnitConvertersResponse struct {
|
||||
type IngredientUnitsResponse struct {
|
||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||
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"`
|
||||
Units []*UnitResponse `json:"units"`
|
||||
}
|
||||
|
||||
|
||||
@@ -188,6 +188,8 @@ type OrderItemResponse struct {
|
||||
ProductName string
|
||||
ProductVariantID *uuid.UUID
|
||||
ProductVariantName *string
|
||||
CategoryID *uuid.UUID
|
||||
CategoryName *string
|
||||
Quantity int
|
||||
UnitPrice float64
|
||||
TotalPrice float64
|
||||
@@ -207,6 +209,7 @@ type OrderItemResponse struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
PrinterType string
|
||||
PrintToChecker bool
|
||||
PaidQuantity int
|
||||
}
|
||||
|
||||
|
||||
@@ -52,8 +52,8 @@ type UpdateOrderIngredientTransactionRequest struct {
|
||||
GrossQty *float64 `json:"gross_qty,omitempty" validate:"omitempty,gt=0"`
|
||||
NetQty *float64 `json:"net_qty,omitempty" validate:"omitempty,gt=0"`
|
||||
WasteQty *float64 `json:"waste_qty,omitempty" validate:"min=0"`
|
||||
Unit *string `json:"unit,omitempty" validate:"omitempty,max=50"`
|
||||
TransactionDate *time.Time `json:"transaction_date,omitempty"`
|
||||
Unit *string `json:"unit,omitempty" validate:"omitempty,max=50"`
|
||||
TransactionDate *time.Time `json:"transaction_date,omitempty"`
|
||||
}
|
||||
|
||||
type OrderIngredientTransactionResponse struct {
|
||||
@@ -98,11 +98,11 @@ type ListOrderIngredientTransactionsRequest struct {
|
||||
}
|
||||
|
||||
type OrderIngredientTransactionSummary struct {
|
||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||
IngredientName string `json:"ingredient_name"`
|
||||
TotalGrossQty float64 `json:"total_gross_qty"`
|
||||
TotalNetQty float64 `json:"total_net_qty"`
|
||||
TotalWasteQty float64 `json:"total_waste_qty"`
|
||||
WastePercentage float64 `json:"waste_percentage"`
|
||||
Unit string `json:"unit"`
|
||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||
IngredientName string `json:"ingredient_name"`
|
||||
TotalGrossQty float64 `json:"total_gross_qty"`
|
||||
TotalNetQty float64 `json:"total_net_qty"`
|
||||
TotalWasteQty float64 `json:"total_waste_qty"`
|
||||
WastePercentage float64 `json:"waste_percentage"`
|
||||
Unit string `json:"unit"`
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ type ProductVariant struct {
|
||||
|
||||
type CreateProductRequest struct {
|
||||
OrganizationID uuid.UUID `validate:"required"`
|
||||
OutletID uuid.UUID `validate:"omitempty"` // If set, upsert product_outlet_prices on create
|
||||
CategoryID uuid.UUID `validate:"required"`
|
||||
SKU *string `validate:"omitempty,max=100"`
|
||||
Name string `validate:"required,min=1,max=255"`
|
||||
@@ -49,6 +50,7 @@ type CreateProductRequest struct {
|
||||
BusinessType constants.BusinessType `validate:"required"`
|
||||
ImageURL *string `validate:"omitempty,max=500"`
|
||||
PrinterType *string `validate:"omitempty,max=50"`
|
||||
PrintToChecker *bool `validate:"omitempty"`
|
||||
UnitID *uuid.UUID `validate:"omitempty"`
|
||||
HasIngredients bool `validate:"omitempty"`
|
||||
Metadata map[string]interface{}
|
||||
@@ -60,6 +62,7 @@ type CreateProductRequest struct {
|
||||
}
|
||||
|
||||
type UpdateProductRequest struct {
|
||||
OutletID uuid.UUID `validate:"omitempty"` // If set, upsert product_outlet_prices on update
|
||||
CategoryID *uuid.UUID `validate:"omitempty"`
|
||||
SKU *string `validate:"omitempty,max=100"`
|
||||
Name *string `validate:"omitempty,min=1,max=255"`
|
||||
@@ -68,6 +71,7 @@ type UpdateProductRequest struct {
|
||||
Cost *float64 `validate:"omitempty,min=0"`
|
||||
ImageURL *string `validate:"omitempty,max=500"`
|
||||
PrinterType *string `validate:"omitempty,max=50"`
|
||||
PrintToChecker *bool `validate:"omitempty"`
|
||||
UnitID *uuid.UUID `validate:"omitempty"`
|
||||
HasIngredients *bool `validate:"omitempty"`
|
||||
Metadata map[string]interface{}
|
||||
@@ -106,6 +110,7 @@ type ProductResponse struct {
|
||||
BusinessType constants.BusinessType
|
||||
ImageURL *string
|
||||
PrinterType string
|
||||
PrintToChecker bool
|
||||
UnitID *uuid.UUID
|
||||
HasIngredients bool
|
||||
Metadata map[string]interface{}
|
||||
@@ -116,9 +121,10 @@ type ProductResponse struct {
|
||||
}
|
||||
|
||||
type OutletPrice struct {
|
||||
OutletID uuid.UUID
|
||||
OutletName string
|
||||
Price float64
|
||||
OutletID uuid.UUID
|
||||
OutletName string
|
||||
Price float64
|
||||
PrintToChecker bool
|
||||
}
|
||||
|
||||
type ProductVariantResponse struct {
|
||||
|
||||
@@ -7,15 +7,15 @@ import (
|
||||
)
|
||||
|
||||
type ProductIngredient struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
WastePercentage float64 `json:"waste_percentage"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
WastePercentage float64 `json:"waste_percentage"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Relations
|
||||
Product *Product `json:"product,omitempty"`
|
||||
@@ -37,15 +37,15 @@ type UpdateProductIngredientRequest struct {
|
||||
}
|
||||
|
||||
type ProductIngredientResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
WastePercentage float64 `json:"waste_percentage"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
WastePercentage float64 `json:"waste_percentage"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Relations
|
||||
Product *Product `json:"product,omitempty"`
|
||||
|
||||
@@ -7,22 +7,25 @@ import (
|
||||
)
|
||||
|
||||
type ProductOutletPrice struct {
|
||||
ID uuid.UUID
|
||||
ProductID uuid.UUID
|
||||
OutletID uuid.UUID
|
||||
Price float64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID uuid.UUID
|
||||
ProductID uuid.UUID
|
||||
OutletID uuid.UUID
|
||||
Price float64
|
||||
PrintToChecker bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type CreateProductOutletPriceRequest struct {
|
||||
ProductID uuid.UUID `validate:"required"`
|
||||
OutletID uuid.UUID `validate:"required"`
|
||||
Price float64 `validate:"required,min=0"`
|
||||
ProductID uuid.UUID `validate:"required"`
|
||||
OutletID uuid.UUID `validate:"required"`
|
||||
Price float64 `validate:"required,min=0"`
|
||||
PrintToChecker bool
|
||||
}
|
||||
|
||||
type UpdateProductOutletPriceRequest struct {
|
||||
Price *float64 `validate:"required,min=0"`
|
||||
Price *float64 `validate:"required,min=0"`
|
||||
PrintToChecker *bool
|
||||
}
|
||||
|
||||
type ProductOutletPriceResponse struct {
|
||||
|
||||
@@ -56,4 +56,4 @@ type ProductRecipeResponse struct {
|
||||
Product *Product `json:"product,omitempty"`
|
||||
ProductVariant *ProductVariant `json:"product_variant,omitempty"`
|
||||
Ingredient *Ingredient `json:"ingredient,omitempty"`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PurchaseCategoryResponse struct {
|
||||
ID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
PresetID *uuid.UUID
|
||||
ParentID *uuid.UUID
|
||||
Code string
|
||||
Name string
|
||||
Type string
|
||||
SortOrder int
|
||||
IsSystem bool
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type CreatePurchaseCategoryRequest struct {
|
||||
OrganizationID uuid.UUID
|
||||
ParentID *uuid.UUID
|
||||
Code *string
|
||||
Name string
|
||||
Type string
|
||||
SortOrder int
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
type UpdatePurchaseCategoryRequest struct {
|
||||
ParentID *uuid.UUID
|
||||
Code *string
|
||||
Name *string
|
||||
Type *string
|
||||
SortOrder *int
|
||||
IsActive *bool
|
||||
}
|
||||
|
||||
type ListPurchaseCategoriesRequest struct {
|
||||
OrganizationID uuid.UUID
|
||||
ParentID *uuid.UUID
|
||||
Type string
|
||||
Search string
|
||||
IsActive *bool
|
||||
Page int
|
||||
Limit int
|
||||
}
|
||||
@@ -7,30 +7,42 @@ import (
|
||||
)
|
||||
|
||||
type PurchaseOrder struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
VendorID uuid.UUID `json:"vendor_id"`
|
||||
PONumber string `json:"po_number"`
|
||||
TransactionDate time.Time `json:"transaction_date"`
|
||||
DueDate time.Time `json:"due_date"`
|
||||
Reference *string `json:"reference"`
|
||||
Status string `json:"status"`
|
||||
Message *string `json:"message"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
VendorID *uuid.UUID `json:"vendor_id"`
|
||||
PONumber string `json:"po_number"`
|
||||
TransactionDate time.Time `json:"transaction_date"`
|
||||
DueDate *time.Time `json:"due_date"`
|
||||
Reference *string `json:"reference"`
|
||||
Status string `json:"status"`
|
||||
Message *string `json:"message"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
TeamScope *string `json:"team_scope"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id"`
|
||||
CreatedAt time.Time `json:"created_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 {
|
||||
ID uuid.UUID `json:"id"`
|
||||
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
|
||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||
Description *string `json:"description"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
UnitID uuid.UUID `json:"unit_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
|
||||
IngredientID *uuid.UUID `json:"ingredient_id"`
|
||||
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
|
||||
Description *string `json:"description"`
|
||||
Quantity *float64 `json:"quantity"`
|
||||
UnitID *uuid.UUID `json:"unit_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PurchaseOrderAttachment struct {
|
||||
@@ -43,33 +55,39 @@ type PurchaseOrderAttachment struct {
|
||||
type PurchaseOrderResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
VendorID uuid.UUID `json:"vendor_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
VendorID *uuid.UUID `json:"vendor_id"`
|
||||
PONumber string `json:"po_number"`
|
||||
TransactionDate time.Time `json:"transaction_date"`
|
||||
DueDate time.Time `json:"due_date"`
|
||||
DueDate *time.Time `json:"due_date"`
|
||||
Reference *string `json:"reference"`
|
||||
Status string `json:"status"`
|
||||
Message *string `json:"message"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
TeamScope *string `json:"team_scope"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Team *PurchaseTeam `json:"team,omitempty"`
|
||||
Vendor *VendorResponse `json:"vendor,omitempty"`
|
||||
Items []PurchaseOrderItemResponse `json:"items,omitempty"`
|
||||
Attachments []PurchaseOrderAttachmentResponse `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
type PurchaseOrderItemResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
|
||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||
Description *string `json:"description"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
UnitID uuid.UUID `json:"unit_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Ingredient *IngredientResponse `json:"ingredient,omitempty"`
|
||||
Unit *UnitResponse `json:"unit,omitempty"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
|
||||
IngredientID *uuid.UUID `json:"ingredient_id"`
|
||||
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
|
||||
Description *string `json:"description"`
|
||||
Quantity *float64 `json:"quantity"`
|
||||
UnitID *uuid.UUID `json:"unit_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Ingredient *IngredientResponse `json:"ingredient,omitempty"`
|
||||
PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
|
||||
Unit *UnitResponse `json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
type PurchaseOrderAttachmentResponse struct {
|
||||
@@ -81,23 +99,27 @@ type PurchaseOrderAttachmentResponse struct {
|
||||
}
|
||||
|
||||
type CreatePurchaseOrderRequest struct {
|
||||
VendorID uuid.UUID `json:"vendor_id"`
|
||||
VendorID *uuid.UUID `json:"vendor_id,omitempty"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
PONumber string `json:"po_number"`
|
||||
TransactionDate time.Time `json:"transaction_date"`
|
||||
DueDate time.Time `json:"due_date"`
|
||||
DueDate *time.Time `json:"due_date,omitempty"`
|
||||
Reference *string `json:"reference,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
Message *string `json:"message,omitempty"`
|
||||
TeamScope *string `json:"team_scope,omitempty"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"`
|
||||
Items []CreatePurchaseOrderItemRequest `json:"items"`
|
||||
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
||||
}
|
||||
|
||||
type CreatePurchaseOrderItemRequest struct {
|
||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
UnitID uuid.UUID `json:"unit_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
IngredientID *uuid.UUID `json:"ingredient_id,omitempty"`
|
||||
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Quantity *float64 `json:"quantity,omitempty"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||
Amount float64 `json:"amount"`
|
||||
}
|
||||
|
||||
type UpdatePurchaseOrderRequest struct {
|
||||
@@ -108,27 +130,37 @@ type UpdatePurchaseOrderRequest struct {
|
||||
Reference *string `json:"reference,omitempty"`
|
||||
Status *string `json:"status,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"`
|
||||
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
||||
}
|
||||
|
||||
type UpdatePurchaseOrderItemRequest struct {
|
||||
ID *uuid.UUID `json:"id,omitempty"` // For existing items
|
||||
IngredientID *uuid.UUID `json:"ingredient_id,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Quantity *float64 `json:"quantity,omitempty"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||
Amount *float64 `json:"amount,omitempty"`
|
||||
ID *uuid.UUID `json:"id,omitempty"` // For existing items
|
||||
IngredientID *uuid.UUID `json:"ingredient_id,omitempty"`
|
||||
PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Quantity *float64 `json:"quantity,omitempty"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||
Amount *float64 `json:"amount,omitempty"`
|
||||
}
|
||||
|
||||
type ListPurchaseOrdersRequest struct {
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
Search string `json:"search,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
VendorID *uuid.UUID `json:"vendor_id,omitempty"`
|
||||
StartDate *time.Time `json:"start_date,omitempty"`
|
||||
EndDate *time.Time `json:"end_date,omitempty"`
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
Search string `json:"search,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
VendorID *uuid.UUID `json:"vendor_id,omitempty"`
|
||||
Team string `json:"team,omitempty"`
|
||||
TeamScope string `json:"team_scope,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 {
|
||||
|
||||
@@ -63,10 +63,12 @@ type UserResponse struct {
|
||||
|
||||
func (u *User) HasPermission(requiredRole constants.UserRole) bool {
|
||||
roleHierarchy := map[constants.UserRole]int{
|
||||
constants.RoleWaiter: 1,
|
||||
constants.RoleCashier: 2,
|
||||
constants.RoleManager: 3,
|
||||
constants.RoleAdmin: 4,
|
||||
constants.RoleWaiter: 1,
|
||||
constants.RoleCashier: 2,
|
||||
constants.RolePurchasing: 3,
|
||||
constants.RoleManager: 4,
|
||||
constants.RoleAdmin: 5,
|
||||
constants.RoleOwner: 6,
|
||||
}
|
||||
|
||||
userLevel := roleHierarchy[u.Role]
|
||||
|
||||
@@ -3,10 +3,15 @@ package processor
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type AnalyticsProcessor interface {
|
||||
@@ -15,20 +20,39 @@ type AnalyticsProcessor interface {
|
||||
GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error)
|
||||
GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error)
|
||||
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error)
|
||||
GetProductAnalyticsPerParentCategory(ctx context.Context, req *models.ProductAnalyticsPerParentCategoryRequest) (*models.ProductAnalyticsPerParentCategoryResponse, error)
|
||||
GetParentCategoryAnalyticsDetail(ctx context.Context, req *models.ParentCategoryAnalyticsDetailRequest) (*models.ParentCategoryAnalyticsDetailResponse, error)
|
||||
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
|
||||
GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error)
|
||||
GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error)
|
||||
GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error)
|
||||
GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error)
|
||||
}
|
||||
|
||||
type AnalyticsProcessorImpl struct {
|
||||
analyticsRepo repository.AnalyticsRepository
|
||||
expenseRepo ExpenseRepository
|
||||
}
|
||||
|
||||
func NewAnalyticsProcessorImpl(analyticsRepo repository.AnalyticsRepository) *AnalyticsProcessorImpl {
|
||||
func NewAnalyticsProcessorImpl(analyticsRepo repository.AnalyticsRepository, expenseRepo ExpenseRepository) *AnalyticsProcessorImpl {
|
||||
return &AnalyticsProcessorImpl{
|
||||
analyticsRepo: analyticsRepo,
|
||||
expenseRepo: expenseRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// resolveOutletName fetches the outlet name from the database if outletID is provided
|
||||
func (p *AnalyticsProcessorImpl) resolveOutletName(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) *string {
|
||||
if outletID == nil {
|
||||
return nil
|
||||
}
|
||||
name, err := p.analyticsRepo.GetOutletName(ctx, organizationID, *outletID)
|
||||
if err != nil || name == "" {
|
||||
return nil
|
||||
}
|
||||
return &name
|
||||
}
|
||||
|
||||
func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) {
|
||||
if req.DateFrom.After(req.DateTo) {
|
||||
return nil, fmt.Errorf("date_from cannot be after date_to")
|
||||
@@ -83,6 +107,7 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context,
|
||||
return &models.PaymentMethodAnalyticsResponse{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||
DateFrom: req.DateFrom,
|
||||
DateTo: req.DateTo,
|
||||
GroupBy: req.GroupBy,
|
||||
@@ -157,6 +182,7 @@ func (p *AnalyticsProcessorImpl) GetSalesAnalytics(ctx context.Context, req *mod
|
||||
return &models.SalesAnalyticsResponse{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||
DateFrom: req.DateFrom,
|
||||
DateTo: req.DateTo,
|
||||
GroupBy: req.GroupBy,
|
||||
@@ -174,7 +200,12 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
|
||||
req.GroupBy = "day"
|
||||
}
|
||||
|
||||
result, err := p.analyticsRepo.GetPurchasingAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy)
|
||||
teamFilter, err := models.ParsePurchaseTeamFilter(req.Team)
|
||||
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 {
|
||||
return nil, fmt.Errorf("failed to get purchasing analytics: %w", err)
|
||||
}
|
||||
@@ -182,12 +213,16 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
|
||||
data := make([]models.PurchasingAnalyticsData, len(result.Data))
|
||||
for i, item := range result.Data {
|
||||
data[i] = models.PurchasingAnalyticsData{
|
||||
Date: item.Date,
|
||||
Purchases: item.Purchases,
|
||||
PurchaseOrders: item.PurchaseOrders,
|
||||
Quantity: item.Quantity,
|
||||
Ingredients: item.Ingredients,
|
||||
Vendors: item.Vendors,
|
||||
Date: item.Date,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,24 +250,46 @@ 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{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
OutletName: result.OutletName,
|
||||
Team: req.Team,
|
||||
DateFrom: req.DateFrom,
|
||||
DateTo: req.DateTo,
|
||||
GroupBy: req.GroupBy,
|
||||
Summary: models.PurchasingSummary{
|
||||
TotalPurchases: result.Summary.TotalPurchases,
|
||||
RawMaterialPurchases: result.Summary.RawMaterialPurchases,
|
||||
ExpensePurchases: result.Summary.ExpensePurchases,
|
||||
TotalPurchaseOrders: result.Summary.TotalPurchaseOrders,
|
||||
RawMaterialPurchaseOrders: result.Summary.RawMaterialPurchaseOrders,
|
||||
ExpenseCount: result.Summary.ExpenseCount,
|
||||
TotalQuantity: result.Summary.TotalQuantity,
|
||||
AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue,
|
||||
TotalIngredients: result.Summary.TotalIngredients,
|
||||
TotalVendors: result.Summary.TotalVendors,
|
||||
TotalTeams: result.Summary.TotalTeams,
|
||||
},
|
||||
Data: data,
|
||||
IngredientData: ingredientData,
|
||||
VendorData: vendorData,
|
||||
TeamData: teamData,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -260,6 +317,7 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m
|
||||
ProductID: data.ProductID,
|
||||
ProductName: data.ProductName,
|
||||
ProductSku: data.ProductSku,
|
||||
ProductPrice: data.ProductPrice,
|
||||
CategoryID: data.CategoryID,
|
||||
CategoryName: data.CategoryName,
|
||||
CategoryOrder: data.CategoryOrder,
|
||||
@@ -279,6 +337,7 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m
|
||||
return &models.ProductAnalyticsResponse{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||
DateFrom: req.DateFrom,
|
||||
DateTo: req.DateTo,
|
||||
Data: resultData,
|
||||
@@ -316,12 +375,249 @@ func (p *AnalyticsProcessorImpl) GetProductAnalyticsPerCategory(ctx context.Cont
|
||||
return &models.ProductAnalyticsPerCategoryResponse{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||
DateFrom: req.DateFrom,
|
||||
DateTo: req.DateTo,
|
||||
Data: resultData,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *AnalyticsProcessorImpl) GetProductAnalyticsPerParentCategory(ctx context.Context, req *models.ProductAnalyticsPerParentCategoryRequest) (*models.ProductAnalyticsPerParentCategoryResponse, error) {
|
||||
// Validate date range
|
||||
if req.DateFrom.After(req.DateTo) {
|
||||
return nil, fmt.Errorf("date_from cannot be after date_to")
|
||||
}
|
||||
|
||||
// Get analytics data from repository
|
||||
analyticsData, err := p.analyticsRepo.GetProductAnalyticsPerParentCategory(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get product analytics per parent category: %w", err)
|
||||
}
|
||||
|
||||
// Transform data
|
||||
var resultData []models.ProductAnalyticsPerParentCategoryData
|
||||
for _, data := range analyticsData {
|
||||
resultData = append(resultData, models.ProductAnalyticsPerParentCategoryData{
|
||||
ParentCategoryID: data.ParentCategoryID,
|
||||
ParentCategoryName: data.ParentCategoryName,
|
||||
TotalRevenue: data.TotalRevenue,
|
||||
TotalQuantity: data.TotalQuantity,
|
||||
CategoryCount: data.CategoryCount,
|
||||
ProductCount: data.ProductCount,
|
||||
OrderCount: data.OrderCount,
|
||||
TotalStandardHpp: data.TotalStandardHpp,
|
||||
TotalFifoHpp: data.TotalFifoHpp,
|
||||
TotalMovingAverageHpp: data.TotalMovingAverageHpp,
|
||||
})
|
||||
}
|
||||
|
||||
budget, err := p.buildBudgetCutOff(ctx, req.OrganizationID, req.OutletID, nil, req.DateFrom, req.DateTo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &models.ProductAnalyticsPerParentCategoryResponse{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||
DateFrom: req.DateFrom,
|
||||
DateTo: req.DateTo,
|
||||
Data: resultData,
|
||||
Budget: budget,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Context, req *models.ParentCategoryAnalyticsDetailRequest) (*models.ParentCategoryAnalyticsDetailResponse, error) {
|
||||
// Validate date range
|
||||
if req.DateFrom.After(req.DateTo) {
|
||||
return nil, fmt.Errorf("date_from cannot be after date_to")
|
||||
}
|
||||
|
||||
detail, err := p.analyticsRepo.GetParentCategoryAnalyticsDetail(ctx, req.OrganizationID, req.OutletID, req.ParentCategoryID, req.DateFrom, req.DateTo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get parent category analytics detail: %w", err)
|
||||
}
|
||||
|
||||
// Bucket the product rows by the category they belong to
|
||||
productsByCategory := make(map[uuid.UUID][]models.ParentCategoryAnalyticsProductData)
|
||||
for _, product := range detail.Products {
|
||||
productsByCategory[product.CategoryID] = append(productsByCategory[product.CategoryID], models.ParentCategoryAnalyticsProductData{
|
||||
ProductID: product.ProductID,
|
||||
ProductName: product.ProductName,
|
||||
ProductSku: product.ProductSku,
|
||||
ProductPrice: product.ProductPrice,
|
||||
QuantitySold: product.QuantitySold,
|
||||
Revenue: product.Revenue,
|
||||
AveragePrice: product.AveragePrice,
|
||||
OrderCount: product.OrderCount,
|
||||
StandardHppPerUnit: product.StandardHppPerUnit,
|
||||
StandardHppTotal: product.StandardHppTotal,
|
||||
FifoHppPerUnit: product.FifoHppPerUnit,
|
||||
FifoHppTotal: product.FifoHppTotal,
|
||||
MovingAverageHppPerUnit: product.MovingAverageHppPerUnit,
|
||||
MovingAverageHppTotal: product.MovingAverageHppTotal,
|
||||
})
|
||||
}
|
||||
|
||||
categories := make([]models.ParentCategoryAnalyticsDetailData, 0, len(detail.Categories))
|
||||
for _, category := range detail.Categories {
|
||||
products := productsByCategory[category.CategoryID]
|
||||
if products == nil {
|
||||
products = []models.ParentCategoryAnalyticsProductData{}
|
||||
}
|
||||
|
||||
categories = append(categories, models.ParentCategoryAnalyticsDetailData{
|
||||
CategoryID: category.CategoryID,
|
||||
CategoryName: category.CategoryName,
|
||||
TotalRevenue: category.TotalRevenue,
|
||||
TotalQuantity: category.TotalQuantity,
|
||||
ProductCount: category.ProductCount,
|
||||
OrderCount: category.OrderCount,
|
||||
TotalStandardHpp: category.TotalStandardHpp,
|
||||
TotalFifoHpp: category.TotalFifoHpp,
|
||||
TotalMovingAverageHpp: category.TotalMovingAverageHpp,
|
||||
Products: products,
|
||||
})
|
||||
}
|
||||
|
||||
summary := models.ParentCategoryAnalyticsDetailSummary{}
|
||||
if detail.Summary != nil {
|
||||
summary = models.ParentCategoryAnalyticsDetailSummary{
|
||||
TotalRevenue: detail.Summary.TotalRevenue,
|
||||
TotalQuantity: detail.Summary.TotalQuantity,
|
||||
CategoryCount: detail.Summary.CategoryCount,
|
||||
ProductCount: detail.Summary.ProductCount,
|
||||
OrderCount: detail.Summary.OrderCount,
|
||||
TotalStandardHpp: detail.Summary.TotalStandardHpp,
|
||||
TotalFifoHpp: detail.Summary.TotalFifoHpp,
|
||||
TotalMovingAverageHpp: detail.Summary.TotalMovingAverageHpp,
|
||||
}
|
||||
}
|
||||
|
||||
budget, err := p.buildBudgetCutOff(ctx, req.OrganizationID, req.OutletID, &req.ParentCategoryID, req.DateFrom, req.DateTo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &models.ParentCategoryAnalyticsDetailResponse{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||
DateFrom: req.DateFrom,
|
||||
DateTo: req.DateTo,
|
||||
ParentCategoryID: detail.ParentCategoryID,
|
||||
ParentCategoryName: detail.ParentCategoryName,
|
||||
Summary: summary,
|
||||
Categories: categories,
|
||||
Budget: budget,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// startOfWeek returns the Monday 00:00 of the week containing t, in t's own location.
|
||||
func startOfWeek(t time.Time) time.Time {
|
||||
daysSinceMonday := (int(t.Weekday()) + 6) % 7
|
||||
year, month, day := t.Date()
|
||||
return time.Date(year, month, day-daysSinceMonday, 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
|
||||
// endOfWeek returns the Sunday 23:59:59.999999999 of the week containing t.
|
||||
func endOfWeek(t time.Time) time.Time {
|
||||
return startOfWeek(t).AddDate(0, 0, 7).Add(-time.Nanosecond)
|
||||
}
|
||||
|
||||
// newBudgetPeriod splits a period's revenue into the spending limits.
|
||||
func newBudgetPeriod(start, end time.Time, revenue float64, orderCount int64) models.BudgetPeriod {
|
||||
return models.BudgetPeriod{
|
||||
PeriodStart: start,
|
||||
PeriodEnd: end,
|
||||
Revenue: revenue,
|
||||
OrderCount: orderCount,
|
||||
LimitPurchase: revenue * constants.BudgetLimitPurchasePercent / 100,
|
||||
LimitOwner: revenue * constants.BudgetLimitOwnerPercent / 100,
|
||||
LimitTeam: revenue * constants.BudgetLimitTeamPercent / 100,
|
||||
}
|
||||
}
|
||||
|
||||
// buildBudgetCutOff produces the weekly cut-off breakdown for the given scope. Weeks
|
||||
// are always whole Monday-to-Sunday blocks, so the covered range is widened to the
|
||||
// week boundaries around the requested dates. A nil parentCategoryID covers every
|
||||
// category.
|
||||
func (p *AnalyticsProcessorImpl) buildBudgetCutOff(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, parentCategoryID *uuid.UUID, dateFrom, dateTo time.Time) (models.BudgetCutOff, error) {
|
||||
cutOffFrom := startOfWeek(dateFrom)
|
||||
cutOffTo := endOfWeek(dateTo)
|
||||
|
||||
budget := models.BudgetCutOff{
|
||||
Percentages: models.BudgetPercentages{
|
||||
Purchase: constants.BudgetLimitPurchasePercent,
|
||||
Owner: constants.BudgetLimitOwnerPercent,
|
||||
Team: constants.BudgetLimitTeamPercent,
|
||||
},
|
||||
CutOffFrom: cutOffFrom,
|
||||
CutOffTo: cutOffTo,
|
||||
Weekly: []models.BudgetPeriod{},
|
||||
Monthly: []models.BudgetMonthPeriod{},
|
||||
}
|
||||
|
||||
rows, err := p.analyticsRepo.GetBudgetCutOffWeekly(ctx, organizationID, outletID, parentCategoryID, cutOffFrom, cutOffTo)
|
||||
if err != nil {
|
||||
return budget, fmt.Errorf("failed to get budget cut off: %w", err)
|
||||
}
|
||||
|
||||
// Key the rows by their Monday so weeks without any sales can still be emitted
|
||||
rowsByWeek := make(map[string]*entities.BudgetCutOffWeek, len(rows))
|
||||
for _, row := range rows {
|
||||
rowsByWeek[row.WeekStart.In(cutOffFrom.Location()).Format("2006-01-02")] = row
|
||||
}
|
||||
|
||||
var (
|
||||
totalRevenue float64
|
||||
totalOrders int64
|
||||
monthOrder []string
|
||||
monthAccumulator = map[string]*models.BudgetMonthPeriod{}
|
||||
)
|
||||
|
||||
for week := cutOffFrom; !week.After(cutOffTo); week = week.AddDate(0, 0, 7) {
|
||||
var revenue float64
|
||||
var orderCount int64
|
||||
if row, ok := rowsByWeek[week.Format("2006-01-02")]; ok {
|
||||
revenue, orderCount = row.Revenue, row.OrderCount
|
||||
}
|
||||
|
||||
period := newBudgetPeriod(week, endOfWeek(week), revenue, orderCount)
|
||||
budget.Weekly = append(budget.Weekly, period)
|
||||
|
||||
totalRevenue += revenue
|
||||
totalOrders += orderCount
|
||||
|
||||
// A week belongs to the month of its Monday, so every week is counted once
|
||||
monthKey := week.Format("2006-01")
|
||||
month, ok := monthAccumulator[monthKey]
|
||||
if !ok {
|
||||
month = &models.BudgetMonthPeriod{Month: monthKey}
|
||||
month.PeriodStart = period.PeriodStart
|
||||
monthAccumulator[monthKey] = month
|
||||
monthOrder = append(monthOrder, monthKey)
|
||||
}
|
||||
month.WeekCount++
|
||||
month.PeriodEnd = period.PeriodEnd
|
||||
month.Revenue += revenue
|
||||
month.OrderCount += orderCount
|
||||
}
|
||||
|
||||
for _, monthKey := range monthOrder {
|
||||
month := monthAccumulator[monthKey]
|
||||
budget.Monthly = append(budget.Monthly, models.BudgetMonthPeriod{
|
||||
Month: month.Month,
|
||||
WeekCount: month.WeekCount,
|
||||
BudgetPeriod: newBudgetPeriod(month.PeriodStart, month.PeriodEnd, month.Revenue, month.OrderCount),
|
||||
})
|
||||
}
|
||||
|
||||
budget.Total = newBudgetPeriod(cutOffFrom, cutOffTo, totalRevenue, totalOrders)
|
||||
|
||||
return budget, nil
|
||||
}
|
||||
|
||||
func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) {
|
||||
// Validate date range
|
||||
if req.DateFrom.After(req.DateTo) {
|
||||
@@ -377,15 +673,19 @@ func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req
|
||||
return &models.DashboardAnalyticsResponse{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||
DateFrom: req.DateFrom,
|
||||
DateTo: req.DateTo,
|
||||
Overview: models.DashboardOverview{
|
||||
TotalSales: overview.TotalSales,
|
||||
TotalOrders: overview.TotalOrders,
|
||||
AverageOrderValue: overview.AverageOrderValue,
|
||||
TotalCustomers: overview.TotalCustomers,
|
||||
VoidedOrders: overview.VoidedOrders,
|
||||
RefundedOrders: overview.RefundedOrders,
|
||||
TotalSales: overview.TotalSales,
|
||||
TotalOrders: overview.TotalOrders,
|
||||
AverageOrderValue: overview.AverageOrderValue,
|
||||
TotalCustomers: overview.TotalCustomers,
|
||||
VoidedOrders: overview.VoidedOrders,
|
||||
RefundedOrders: overview.RefundedOrders,
|
||||
TotalItemSold: overview.TotalItemSold,
|
||||
TotalLowStock: overview.TotalLowStock,
|
||||
TotalProductActive: overview.TotalProductActive,
|
||||
},
|
||||
TopProducts: topProducts.Data,
|
||||
PaymentMethods: paymentMethods.Data,
|
||||
@@ -394,17 +694,27 @@ func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req
|
||||
}
|
||||
|
||||
func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) {
|
||||
if req.DateFrom.IsZero() {
|
||||
return nil, fmt.Errorf("date_from is required")
|
||||
}
|
||||
|
||||
if req.DateTo.IsZero() {
|
||||
return nil, fmt.Errorf("date_to is required")
|
||||
}
|
||||
|
||||
if req.DateFrom.After(req.DateTo) {
|
||||
return nil, fmt.Errorf("date_from cannot be after date_to")
|
||||
}
|
||||
|
||||
// Get analytics data from repository
|
||||
if req.GroupBy == "" {
|
||||
req.GroupBy = "day"
|
||||
}
|
||||
|
||||
result, err := p.analyticsRepo.GetProfitLossAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get profit/loss analytics: %w", err)
|
||||
}
|
||||
|
||||
// Transform entities to models
|
||||
data := make([]models.ProfitLossData, len(result.Data))
|
||||
for i, item := range result.Data {
|
||||
data[i] = models.ProfitLossData{
|
||||
@@ -439,9 +749,159 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
|
||||
}
|
||||
}
|
||||
|
||||
type categoryAmount struct {
|
||||
Name string
|
||||
TodayAmt float64
|
||||
MtdAmt float64
|
||||
}
|
||||
|
||||
categoryMap := make(map[string]*categoryAmount)
|
||||
var categoryOrder []string
|
||||
|
||||
for _, cat := range result.TodayExpenseByCategory {
|
||||
name := cat.CategoryName
|
||||
if _, exists := categoryMap[name]; !exists {
|
||||
categoryMap[name] = &categoryAmount{Name: name}
|
||||
categoryOrder = append(categoryOrder, name)
|
||||
}
|
||||
categoryMap[name].TodayAmt = cat.Amount
|
||||
}
|
||||
|
||||
for _, cat := range result.MtdExpenseByCategory {
|
||||
name := cat.CategoryName
|
||||
if _, exists := categoryMap[name]; !exists {
|
||||
categoryMap[name] = &categoryAmount{Name: name}
|
||||
categoryOrder = append(categoryOrder, name)
|
||||
}
|
||||
categoryMap[name].MtdAmt = cat.Amount
|
||||
}
|
||||
|
||||
var todayTotalOps float64
|
||||
var mtdTotalOps float64
|
||||
var todayGaji float64
|
||||
var mtdGaji float64
|
||||
for _, cat := range categoryMap {
|
||||
if isSalaryExpenseCategory(cat.Name) {
|
||||
todayGaji += cat.TodayAmt
|
||||
mtdGaji += cat.MtdAmt
|
||||
continue
|
||||
}
|
||||
todayTotalOps += cat.TodayAmt
|
||||
mtdTotalOps += cat.MtdAmt
|
||||
}
|
||||
|
||||
todayGrossProfit := result.TodayRevenue - result.TodayCost
|
||||
mtdGrossProfit := result.MtdRevenue - result.MtdCost
|
||||
|
||||
todayProfitBeforeGaji := todayGrossProfit - todayTotalOps
|
||||
mtdProfitBeforeGaji := mtdGrossProfit - mtdTotalOps
|
||||
|
||||
todayNetProfit := todayProfitBeforeGaji - todayGaji
|
||||
mtdNetProfit := mtdProfitBeforeGaji - mtdGaji
|
||||
|
||||
todayPct := func(nominal float64) float64 {
|
||||
if result.TodayRevenue == 0 {
|
||||
return 0
|
||||
}
|
||||
return (nominal / result.TodayRevenue) * 100
|
||||
}
|
||||
mtdPct := func(nominal float64) float64 {
|
||||
if result.MtdRevenue == 0 {
|
||||
return 0
|
||||
}
|
||||
return (nominal / result.MtdRevenue) * 100
|
||||
}
|
||||
|
||||
opsSubItems := make([]models.ProfitLossSummaryRow, 0, len(categoryOrder)+1)
|
||||
opsCategoryCount := 0
|
||||
for _, name := range categoryOrder {
|
||||
cat := categoryMap[name]
|
||||
if isSalaryExpenseCategory(cat.Name) {
|
||||
continue
|
||||
}
|
||||
opsCategoryCount++
|
||||
opsSubItems = append(opsSubItems, models.ProfitLossSummaryRow{
|
||||
ID: fmt.Sprintf("by_%s", slugify(name)),
|
||||
Label: fmt.Sprintf("%d. %s", opsCategoryCount, cat.Name),
|
||||
TodayNominal: cat.TodayAmt,
|
||||
TodayPct: todayPct(cat.TodayAmt),
|
||||
MtdNominal: cat.MtdAmt,
|
||||
MtdPct: mtdPct(cat.MtdAmt),
|
||||
})
|
||||
}
|
||||
opsSubItems = append(opsSubItems, models.ProfitLossSummaryRow{
|
||||
ID: "total_biaya_ops",
|
||||
Label: fmt.Sprintf("Total Biaya OPS (%d kategori)", opsCategoryCount),
|
||||
IsBold: true,
|
||||
TodayNominal: todayTotalOps,
|
||||
TodayPct: todayPct(todayTotalOps),
|
||||
MtdNominal: mtdTotalOps,
|
||||
MtdPct: mtdPct(mtdTotalOps),
|
||||
})
|
||||
|
||||
mainSummary := []models.ProfitLossSummaryRow{
|
||||
{
|
||||
ID: "total_omset", Label: "TOTAL OMSET",
|
||||
TodayNominal: result.TodayRevenue, TodayPct: todayPct(result.TodayRevenue),
|
||||
MtdNominal: result.MtdRevenue, MtdPct: mtdPct(result.MtdRevenue),
|
||||
},
|
||||
{
|
||||
ID: "hpp", Label: "HPP",
|
||||
TodayNominal: result.TodayCost, TodayPct: todayPct(result.TodayCost),
|
||||
MtdNominal: result.MtdCost, MtdPct: mtdPct(result.MtdCost),
|
||||
},
|
||||
{
|
||||
ID: "laba_kotor", Label: "Laba Kotor (1-2)",
|
||||
TodayNominal: todayGrossProfit, TodayPct: todayPct(todayGrossProfit),
|
||||
MtdNominal: mtdGrossProfit, MtdPct: mtdPct(mtdGrossProfit),
|
||||
},
|
||||
{
|
||||
ID: "biaya_ops", Label: "BIAYA OPS",
|
||||
TodayNominal: todayTotalOps, TodayPct: todayPct(todayTotalOps),
|
||||
MtdNominal: mtdTotalOps, MtdPct: mtdPct(mtdTotalOps),
|
||||
SubItems: opsSubItems,
|
||||
},
|
||||
{
|
||||
ID: "laba_rugi_sblm_gaji", Label: "Laba/Rugi sblm Gaji (3-4)",
|
||||
TodayNominal: todayProfitBeforeGaji, TodayPct: todayPct(todayProfitBeforeGaji),
|
||||
MtdNominal: mtdProfitBeforeGaji, MtdPct: mtdPct(mtdProfitBeforeGaji),
|
||||
},
|
||||
{
|
||||
ID: "biaya_gaji", Label: "BIAYA GAJI",
|
||||
TodayNominal: todayGaji, TodayPct: todayPct(todayGaji),
|
||||
MtdNominal: mtdGaji, MtdPct: mtdPct(mtdGaji),
|
||||
},
|
||||
{
|
||||
ID: "laba_rugi", Label: "Laba/Rugi (5-6)", IsBold: true,
|
||||
TodayNominal: todayNetProfit, TodayPct: todayPct(todayNetProfit),
|
||||
MtdNominal: mtdNetProfit, MtdPct: mtdPct(mtdNetProfit),
|
||||
},
|
||||
}
|
||||
|
||||
opsItems := make([]models.OperationalExpenseItem, len(result.OperationalExpenseItems))
|
||||
var opsTotal float64
|
||||
for i, item := range result.OperationalExpenseItems {
|
||||
opsItems[i] = models.OperationalExpenseItem{
|
||||
Item: item.Item,
|
||||
Nominal: item.Amount,
|
||||
}
|
||||
opsTotal += item.Amount
|
||||
}
|
||||
|
||||
purchasingItems := make([]models.ProfitLossPurchasingItem, len(result.PurchasingItems))
|
||||
for i, item := range result.PurchasingItems {
|
||||
purchasingItems[i] = models.ProfitLossPurchasingItem{
|
||||
Date: item.Date,
|
||||
Item: item.Item,
|
||||
Quantity: item.Quantity,
|
||||
Nominal: item.Amount,
|
||||
}
|
||||
}
|
||||
|
||||
return &models.ProfitLossAnalyticsResponse{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||
DateFrom: req.DateFrom,
|
||||
DateTo: req.DateTo,
|
||||
GroupBy: req.GroupBy,
|
||||
@@ -460,5 +920,319 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
|
||||
},
|
||||
Data: data,
|
||||
ProductData: productData,
|
||||
MainSummary: mainSummary,
|
||||
Purchasing: models.ProfitLossPurchasing{
|
||||
TodayTotal: result.TodayPurchasing,
|
||||
MtdTotal: result.MtdPurchasing,
|
||||
TodayRawMaterial: result.TodayPurchasingRawMaterial,
|
||||
MtdRawMaterial: result.MtdPurchasingRawMaterial,
|
||||
TodayExpense: result.TodayPurchasingExpense,
|
||||
MtdExpense: result.MtdPurchasingExpense,
|
||||
Items: purchasingItems,
|
||||
},
|
||||
OperationalExpenses: opsItems,
|
||||
OperationalExpensesTotal: opsTotal,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isSalaryExpenseCategory(name string) bool {
|
||||
name = strings.ToLower(name)
|
||||
return strings.Contains(name, "gaji") || strings.Contains(name, "salary")
|
||||
}
|
||||
|
||||
func slugify(s string) string {
|
||||
result := make([]byte, 0, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z':
|
||||
result = append(result, c)
|
||||
case c >= 'A' && c <= 'Z':
|
||||
result = append(result, c+32)
|
||||
case c >= '0' && c <= '9':
|
||||
result = append(result, c)
|
||||
default:
|
||||
if len(result) == 0 || result[len(result)-1] != '_' {
|
||||
result = append(result, '_')
|
||||
}
|
||||
}
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
|
||||
if req.DateFrom.After(req.DateTo) {
|
||||
return nil, fmt.Errorf("date_from cannot be after date_to")
|
||||
}
|
||||
|
||||
return p.buildExclusiveSummaryPeriod(ctx, req)
|
||||
}
|
||||
|
||||
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) {
|
||||
monthStart := time.Date(req.Month.Year(), req.Month.Month(), 1, 0, 0, 0, 0, req.Month.Location())
|
||||
monthEnd := monthStart.AddDate(0, 1, 0).Add(-time.Nanosecond)
|
||||
|
||||
fullPeriod, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
DateFrom: monthStart,
|
||||
DateTo: monthEnd,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
periods := make([]models.ExclusiveSummaryMonthlyPeriod, 0)
|
||||
for _, bucket := range buildExclusiveSummaryMonthlyBuckets(monthStart) {
|
||||
period, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
DateFrom: bucket.DateFrom,
|
||||
DateTo: bucket.DateTo,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
periods = append(periods, models.ExclusiveSummaryMonthlyPeriod{
|
||||
Label: bucket.Label,
|
||||
DateFrom: bucket.DateFrom,
|
||||
DateTo: bucket.DateTo,
|
||||
Sales: period.Summary.Sales,
|
||||
HPP: period.Summary.HPP,
|
||||
GrossProfit: period.Summary.GrossProfit,
|
||||
GrossMargin: percentage(period.Summary.GrossProfit, period.Summary.Sales),
|
||||
})
|
||||
}
|
||||
|
||||
bankBalances, err := p.analyticsRepo.GetExclusiveSummaryBankBalances(ctx, req.OrganizationID, req.OutletID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get exclusive summary bank balances: %w", err)
|
||||
}
|
||||
|
||||
bankBalance := make([]models.ExclusiveSummaryBankBalance, len(bankBalances))
|
||||
for i, item := range bankBalances {
|
||||
bankBalance[i] = models.ExclusiveSummaryBankBalance{
|
||||
Bank: item.Bank,
|
||||
OpeningBalance: item.OpeningBalance,
|
||||
IncomingMutation: item.IncomingMutation,
|
||||
OutgoingMutation: item.OutgoingMutation,
|
||||
ClosingBalance: item.ClosingBalance,
|
||||
Notes: item.Notes,
|
||||
}
|
||||
}
|
||||
|
||||
return &models.ExclusiveSummaryMonthlyResponse{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||
Month: monthStart.Format("2006-01"),
|
||||
Summary: models.ExclusiveSummaryMonthlySummary{
|
||||
TotalSales: fullPeriod.Summary.Sales,
|
||||
HPP: fullPeriod.Summary.HPP,
|
||||
GrossProfit: fullPeriod.Summary.GrossProfit,
|
||||
OperationalExpensesTotal: fullPeriod.Summary.OperationalExpensesTotal,
|
||||
TotalCost: fullPeriod.Summary.TotalCost,
|
||||
NetProfit: fullPeriod.Summary.NetProfit,
|
||||
NetProfitMargin: percentage(fullPeriod.Summary.NetProfit, fullPeriod.Summary.Sales),
|
||||
},
|
||||
Periods: periods,
|
||||
BankBalance: bankBalance,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
|
||||
mtdStart := time.Date(req.DateTo.Year(), req.DateTo.Month(), 1, 0, 0, 0, 0, req.DateTo.Location())
|
||||
|
||||
return p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
DateFrom: mtdStart,
|
||||
DateTo: req.DateTo,
|
||||
ExcludeGajiStaffFromReimburse: req.ExcludeGajiStaffFromReimburse,
|
||||
})
|
||||
}
|
||||
|
||||
func (p *AnalyticsProcessorImpl) buildExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
|
||||
result, err := p.analyticsRepo.GetExclusiveSummaryAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get exclusive summary analytics: %w", err)
|
||||
}
|
||||
|
||||
hppBreakdown, hppTotal := exclusiveSummaryCategoryBreakdown(result.HPPBreakdown)
|
||||
operationalBreakdown, operationalTotal := exclusiveSummaryCategoryBreakdown(result.OperationalExpenseBreakdown)
|
||||
salaryDW, salaryStaff, salaryOther := exclusiveSummarySalaryBreakdown(result.DailyTransactions)
|
||||
salaryTotal := salaryDW + salaryStaff + salaryOther
|
||||
otherOperationalExpenses := operationalTotal - salaryTotal
|
||||
if otherOperationalExpenses < 0 {
|
||||
otherOperationalExpenses = 0
|
||||
}
|
||||
|
||||
grossProfit := result.SalesTotal - hppTotal
|
||||
totalCost := hppTotal + operationalTotal
|
||||
netProfit := result.SalesTotal - totalCost
|
||||
excludedSalaryStaff := 0.0
|
||||
if req.ExcludeGajiStaffFromReimburse {
|
||||
excludedSalaryStaff = salaryStaff
|
||||
}
|
||||
|
||||
dailySummary := make([]models.ExclusiveSummaryDailySummary, len(result.DailySummary))
|
||||
for i, item := range result.DailySummary {
|
||||
dailySummary[i] = models.ExclusiveSummaryDailySummary{
|
||||
Date: item.Date,
|
||||
TransactionCount: item.TransactionCount,
|
||||
TotalCost: item.TotalCost,
|
||||
}
|
||||
}
|
||||
|
||||
dailyTransactions := make([]models.ExclusiveSummaryDailyTransaction, len(result.DailyTransactions))
|
||||
for i, item := range result.DailyTransactions {
|
||||
dailyTransactions[i] = models.ExclusiveSummaryDailyTransaction{
|
||||
Date: item.Date,
|
||||
CategoryCode: item.CategoryCode,
|
||||
CategoryName: item.CategoryName,
|
||||
Description: item.Description,
|
||||
Amount: item.Amount,
|
||||
Source: item.Source,
|
||||
}
|
||||
}
|
||||
|
||||
return &models.ExclusiveSummaryPeriodResponse{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||
Period: models.ExclusiveSummaryPeriodRange{
|
||||
DateFrom: req.DateFrom,
|
||||
DateTo: req.DateTo,
|
||||
},
|
||||
Summary: models.ExclusiveSummaryPeriodSummary{
|
||||
Sales: result.SalesTotal,
|
||||
HPP: hppTotal,
|
||||
GrossProfit: grossProfit,
|
||||
SalaryTotal: salaryTotal,
|
||||
SalaryDW: salaryDW,
|
||||
SalaryStaff: salaryStaff,
|
||||
SalaryOther: salaryOther,
|
||||
OtherOperationalExpenses: otherOperationalExpenses,
|
||||
OperationalExpensesTotal: operationalTotal,
|
||||
TotalCost: totalCost,
|
||||
NetProfit: netProfit,
|
||||
},
|
||||
Reimburse: models.ExclusiveSummaryReimburse{
|
||||
TotalCost: totalCost,
|
||||
ExcludedSalaryStaff: excludedSalaryStaff,
|
||||
TotalReimburse: totalCost - excludedSalaryStaff,
|
||||
},
|
||||
HPPBreakdown: hppBreakdown,
|
||||
OperationalExpenseBreakdown: operationalBreakdown,
|
||||
DailySummary: dailySummary,
|
||||
DailyTransactions: dailyTransactions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func exclusiveSummaryCategoryBreakdown(items []entities.ExclusiveSummaryCategoryTotal) ([]models.ExclusiveSummaryCategoryBreakdown, float64) {
|
||||
var total float64
|
||||
for _, item := range items {
|
||||
total += item.Amount
|
||||
}
|
||||
|
||||
breakdown := make([]models.ExclusiveSummaryCategoryBreakdown, len(items))
|
||||
for i, item := range items {
|
||||
breakdown[i] = models.ExclusiveSummaryCategoryBreakdown{
|
||||
CategoryCode: item.CategoryCode,
|
||||
CategoryName: item.CategoryName,
|
||||
Amount: item.Amount,
|
||||
Percentage: percentage(item.Amount, total),
|
||||
}
|
||||
}
|
||||
|
||||
return breakdown, total
|
||||
}
|
||||
|
||||
func exclusiveSummarySalaryBreakdown(transactions []entities.ExclusiveSummaryDailyTransaction) (float64, float64, float64) {
|
||||
var salaryDW float64
|
||||
var salaryStaff float64
|
||||
var salaryOther float64
|
||||
|
||||
for _, transaction := range transactions {
|
||||
if !isExclusiveSummarySalary(transaction.CategoryCode, transaction.CategoryName, transaction.Description) {
|
||||
continue
|
||||
}
|
||||
|
||||
classification := strings.ToLower(transaction.CategoryCode + " " + transaction.CategoryName + " " + transaction.Description)
|
||||
switch {
|
||||
case strings.Contains(classification, "staff") || strings.Contains(classification, "kary") || strings.Contains(classification, "karyawan"):
|
||||
salaryStaff += transaction.Amount
|
||||
case strings.Contains(classification, "dw"):
|
||||
salaryDW += transaction.Amount
|
||||
default:
|
||||
salaryOther += transaction.Amount
|
||||
}
|
||||
}
|
||||
|
||||
return salaryDW, salaryStaff, salaryOther
|
||||
}
|
||||
|
||||
func isExclusiveSummarySalary(parts ...string) bool {
|
||||
text := strings.ToLower(strings.Join(parts, " "))
|
||||
return strings.Contains(text, "gaji") || strings.Contains(text, "salary")
|
||||
}
|
||||
|
||||
func percentage(numerator, denominator float64) float64 {
|
||||
if denominator == 0 {
|
||||
return 0
|
||||
}
|
||||
return (numerator / denominator) * 100
|
||||
}
|
||||
|
||||
type exclusiveSummaryMonthlyBucket struct {
|
||||
Label string
|
||||
DateFrom time.Time
|
||||
DateTo time.Time
|
||||
}
|
||||
|
||||
func buildExclusiveSummaryMonthlyBuckets(monthStart time.Time) []exclusiveSummaryMonthlyBucket {
|
||||
monthEnd := monthStart.AddDate(0, 1, 0).Add(-time.Nanosecond)
|
||||
buckets := make([]exclusiveSummaryMonthlyBucket, 0, 6)
|
||||
currentStart := monthStart
|
||||
|
||||
for !currentStart.After(monthEnd) {
|
||||
currentEnd := currentStart
|
||||
for currentEnd.Weekday() != time.Sunday && currentEnd.Day() < monthEnd.Day() {
|
||||
currentEnd = currentEnd.AddDate(0, 0, 1)
|
||||
}
|
||||
|
||||
bucketEnd := time.Date(currentEnd.Year(), currentEnd.Month(), currentEnd.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), currentEnd.Location())
|
||||
if bucketEnd.After(monthEnd) {
|
||||
bucketEnd = monthEnd
|
||||
}
|
||||
|
||||
buckets = append(buckets, exclusiveSummaryMonthlyBucket{
|
||||
Label: fmt.Sprintf("%d - %d %s", currentStart.Day(), bucketEnd.Day(), indonesianMonthName(currentStart.Month())),
|
||||
DateFrom: currentStart,
|
||||
DateTo: bucketEnd,
|
||||
})
|
||||
|
||||
currentStart = time.Date(bucketEnd.Year(), bucketEnd.Month(), bucketEnd.Day(), 0, 0, 0, 0, bucketEnd.Location()).AddDate(0, 0, 1)
|
||||
}
|
||||
|
||||
return buckets
|
||||
}
|
||||
|
||||
func indonesianMonthName(month time.Month) string {
|
||||
names := map[time.Month]string{
|
||||
time.January: "Januari",
|
||||
time.February: "Februari",
|
||||
time.March: "Maret",
|
||||
time.April: "April",
|
||||
time.May: "Mei",
|
||||
time.June: "Juni",
|
||||
time.July: "Juli",
|
||||
time.August: "Agustus",
|
||||
time.September: "September",
|
||||
time.October: "Oktober",
|
||||
time.November: "November",
|
||||
time.December: "Desember",
|
||||
}
|
||||
return names[month]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
|
||||
@@ -13,7 +14,16 @@ import (
|
||||
)
|
||||
|
||||
type analyticsRepositoryStub struct {
|
||||
purchasingResult *entities.PurchasingAnalytics
|
||||
purchasingResult *entities.PurchasingAnalytics
|
||||
purchasingTeam *entities.PurchaseTeamFilter
|
||||
budgetCutOffWeeks []*entities.BudgetCutOffWeek
|
||||
profitLossResult *entities.ProfitLossAnalytics
|
||||
exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics
|
||||
bankBalances []entities.ExclusiveSummaryBankBalance
|
||||
profitLossGroup string
|
||||
exclusiveSummaryCalls int
|
||||
exclusiveSummaryFrom []time.Time
|
||||
exclusiveSummaryTo []time.Time
|
||||
}
|
||||
|
||||
func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) {
|
||||
@@ -24,7 +34,8 @@ func (analyticsRepositoryStub) GetSalesAnalytics(context.Context, uuid.UUID, *uu
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s analyticsRepositoryStub) GetPurchasingAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.PurchasingAnalytics, error) {
|
||||
func (s *analyticsRepositoryStub) GetPurchasingAnalytics(_ context.Context, _ uuid.UUID, _ *uuid.UUID, team *entities.PurchaseTeamFilter, _, _ time.Time, _ string) (*entities.PurchasingAnalytics, error) {
|
||||
s.purchasingTeam = team
|
||||
return s.purchasingResult, nil
|
||||
}
|
||||
|
||||
@@ -36,26 +47,95 @@ func (analyticsRepositoryStub) GetProductAnalyticsPerCategory(context.Context, u
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (analyticsRepositoryStub) GetProductAnalyticsPerParentCategory(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.ProductAnalyticsPerParentCategory, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (analyticsRepositoryStub) GetParentCategoryAnalyticsDetail(context.Context, uuid.UUID, *uuid.UUID, uuid.UUID, time.Time, time.Time) (*entities.ParentCategoryAnalyticsDetail, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s analyticsRepositoryStub) GetBudgetCutOffWeekly(context.Context, uuid.UUID, *uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.BudgetCutOffWeek, error) {
|
||||
return s.budgetCutOffWeeks, nil
|
||||
}
|
||||
|
||||
func (analyticsRepositoryStub) GetDashboardOverview(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) (*entities.DashboardOverview, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (analyticsRepositoryStub) GetProfitLossAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.ProfitLossAnalytics, error) {
|
||||
func (s analyticsRepositoryStub) GetProfitLossAnalytics(_ context.Context, _ uuid.UUID, _ *uuid.UUID, _, _ time.Time, groupBy string) (*entities.ProfitLossAnalytics, error) {
|
||||
s.profitLossGroup = groupBy
|
||||
return s.profitLossResult, nil
|
||||
}
|
||||
|
||||
func (s *analyticsRepositoryStub) GetExclusiveSummaryAnalytics(_ context.Context, _ uuid.UUID, _ *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error) {
|
||||
s.exclusiveSummaryFrom = append(s.exclusiveSummaryFrom, dateFrom)
|
||||
s.exclusiveSummaryTo = append(s.exclusiveSummaryTo, dateTo)
|
||||
if s.exclusiveSummaryCalls < len(s.exclusiveSummaryResults) {
|
||||
result := s.exclusiveSummaryResults[s.exclusiveSummaryCalls]
|
||||
s.exclusiveSummaryCalls++
|
||||
return result, nil
|
||||
}
|
||||
s.exclusiveSummaryCalls++
|
||||
return &entities.ExclusiveSummaryAnalytics{}, nil
|
||||
}
|
||||
|
||||
func (s *analyticsRepositoryStub) GetExclusiveSummaryBankBalances(context.Context, uuid.UUID, *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error) {
|
||||
return s.bankBalances, nil
|
||||
}
|
||||
|
||||
func (analyticsRepositoryStub) GetOutletName(context.Context, uuid.UUID, uuid.UUID) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
type expenseRepositoryStub struct{}
|
||||
|
||||
func (expenseRepositoryStub) Create(context.Context, *entities.Expense) error { return nil }
|
||||
func (expenseRepositoryStub) GetByID(context.Context, uuid.UUID) (*entities.Expense, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (expenseRepositoryStub) GetByIDAndOrganizationID(context.Context, uuid.UUID, uuid.UUID) (*entities.Expense, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (expenseRepositoryStub) Update(context.Context, *entities.Expense) error { return nil }
|
||||
func (expenseRepositoryStub) Delete(context.Context, uuid.UUID) error { return nil }
|
||||
func (expenseRepositoryStub) List(context.Context, uuid.UUID, map[string]interface{}, int, int) ([]*entities.Expense, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
func (expenseRepositoryStub) GetAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.ExpenseAnalytics, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (expenseRepositoryStub) CreateItem(context.Context, *entities.ExpenseItem) error { return nil }
|
||||
func (expenseRepositoryStub) DeleteItemsByExpenseID(context.Context, uuid.UUID) error { return nil }
|
||||
|
||||
func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T) {
|
||||
outletID := uuid.New()
|
||||
outletName := "Main Outlet"
|
||||
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||
processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{
|
||||
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
|
||||
purchasingResult: &entities.PurchasingAnalytics{
|
||||
OutletName: &outletName,
|
||||
Summary: entities.PurchasingSummary{
|
||||
TotalPurchases: 125,
|
||||
TotalPurchases: 300,
|
||||
RawMaterialPurchases: 125,
|
||||
ExpensePurchases: 175,
|
||||
TotalPurchaseOrders: 3,
|
||||
RawMaterialPurchaseOrders: 1,
|
||||
ExpenseCount: 2,
|
||||
},
|
||||
Data: []entities.PurchasingAnalyticsData{
|
||||
{
|
||||
Date: now,
|
||||
Purchases: 300,
|
||||
RawMaterialPurchases: 125,
|
||||
ExpensePurchases: 175,
|
||||
PurchaseOrders: 3,
|
||||
RawMaterialPurchaseOrders: 1,
|
||||
ExpenseCount: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}, expenseRepositoryStub{})
|
||||
|
||||
result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{
|
||||
OrganizationID: uuid.New(),
|
||||
@@ -69,5 +149,424 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T)
|
||||
require.Equal(t, &outletID, result.OutletID)
|
||||
require.NotNil(t, result.OutletName)
|
||||
require.Equal(t, outletName, *result.OutletName)
|
||||
require.Equal(t, float64(125), result.Summary.TotalPurchases)
|
||||
require.Equal(t, float64(300), result.Summary.TotalPurchases)
|
||||
require.Equal(t, float64(125), result.Summary.RawMaterialPurchases)
|
||||
require.Equal(t, float64(175), result.Summary.ExpensePurchases)
|
||||
require.Equal(t, int64(3), result.Summary.TotalPurchaseOrders)
|
||||
require.Equal(t, int64(1), result.Summary.RawMaterialPurchaseOrders)
|
||||
require.Equal(t, int64(2), result.Summary.ExpenseCount)
|
||||
require.Len(t, result.Data, 1)
|
||||
require.Equal(t, float64(300), result.Data[0].Purchases)
|
||||
require.Equal(t, float64(125), result.Data[0].RawMaterialPurchases)
|
||||
require.Equal(t, float64(175), result.Data[0].ExpensePurchases)
|
||||
}
|
||||
|
||||
func TestAnalyticsProcessorGetPurchasingAnalyticsPassesTeamFilter(t *testing.T) {
|
||||
categoryID := 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)
|
||||
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
|
||||
purchasingResult: &entities.PurchasingAnalytics{
|
||||
Summary: entities.PurchasingSummary{TotalPurchases: 300, TotalTeams: 2},
|
||||
TeamData: []entities.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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, expenseRepositoryStub{})
|
||||
|
||||
result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{
|
||||
OrganizationID: uuid.New(),
|
||||
DateFrom: now,
|
||||
DateTo: now,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
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, float64(150), result.TeamData[0].RawMaterialPurchases)
|
||||
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 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) {
|
||||
productID := uuid.New()
|
||||
categoryID := uuid.New()
|
||||
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
|
||||
profitLossResult: &entities.ProfitLossAnalytics{
|
||||
Summary: entities.ProfitLossSummary{
|
||||
TotalRevenue: 1000,
|
||||
TotalCost: 400,
|
||||
GrossProfit: 600,
|
||||
GrossProfitMargin: 60,
|
||||
TotalTax: 50,
|
||||
TotalDiscount: 25,
|
||||
NetProfit: 575,
|
||||
NetProfitMargin: 57.5,
|
||||
TotalOrders: 10,
|
||||
AverageProfit: 57.5,
|
||||
ProfitabilityRatio: 150,
|
||||
},
|
||||
Data: []entities.ProfitLossData{
|
||||
{
|
||||
Date: now,
|
||||
Revenue: 1000,
|
||||
Cost: 400,
|
||||
GrossProfit: 600,
|
||||
GrossProfitMargin: 60,
|
||||
Tax: 50,
|
||||
Discount: 25,
|
||||
NetProfit: 575,
|
||||
NetProfitMargin: 57.5,
|
||||
Orders: 10,
|
||||
},
|
||||
},
|
||||
ProductData: []entities.ProductProfitData{
|
||||
{
|
||||
ProductID: productID,
|
||||
ProductName: "Nasi",
|
||||
CategoryID: categoryID,
|
||||
CategoryName: "Food",
|
||||
QuantitySold: 5,
|
||||
Revenue: 500,
|
||||
Cost: 200,
|
||||
GrossProfit: 300,
|
||||
GrossProfitMargin: 60,
|
||||
AveragePrice: 100,
|
||||
AverageCost: 40,
|
||||
ProfitPerUnit: 60,
|
||||
},
|
||||
},
|
||||
TodayRevenue: 1000,
|
||||
TodayCost: 400,
|
||||
MtdRevenue: 2000,
|
||||
MtdCost: 800,
|
||||
},
|
||||
}, expenseRepositoryStub{})
|
||||
|
||||
result, err := processor.GetProfitLossAnalytics(context.Background(), &models.ProfitLossAnalyticsRequest{
|
||||
OrganizationID: uuid.New(),
|
||||
DateFrom: now,
|
||||
DateTo: now,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, "day", result.GroupBy)
|
||||
require.Equal(t, float64(1000), result.Summary.TotalRevenue)
|
||||
require.Len(t, result.Data, 1)
|
||||
require.Equal(t, float64(575), result.Data[0].NetProfit)
|
||||
require.Len(t, result.ProductData, 1)
|
||||
require.Equal(t, productID, result.ProductData[0].ProductID)
|
||||
require.NotEmpty(t, result.MainSummary)
|
||||
require.Equal(t, "total_omset", result.MainSummary[0].ID)
|
||||
}
|
||||
|
||||
func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *testing.T) {
|
||||
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
|
||||
profitLossResult: &entities.ProfitLossAnalytics{
|
||||
Summary: entities.ProfitLossSummary{
|
||||
TotalRevenue: 10000,
|
||||
TotalCost: 4000,
|
||||
},
|
||||
TodayRevenue: 10000,
|
||||
TodayCost: 4000,
|
||||
MtdRevenue: 20000,
|
||||
MtdCost: 8000,
|
||||
TodayExpenseByCategory: []entities.ExpenseCategoryTotal{
|
||||
{CategoryName: "Gaji", Amount: 1500},
|
||||
{CategoryName: "Promosi", Amount: 300},
|
||||
{CategoryName: "Sewa", Amount: 500},
|
||||
},
|
||||
MtdExpenseByCategory: []entities.ExpenseCategoryTotal{
|
||||
{CategoryName: "Gaji", Amount: 3000},
|
||||
{CategoryName: "Promosi", Amount: 600},
|
||||
{CategoryName: "Sewa", Amount: 1000},
|
||||
},
|
||||
},
|
||||
}, expenseRepositoryStub{})
|
||||
|
||||
result, err := processor.GetProfitLossAnalytics(context.Background(), &models.ProfitLossAnalyticsRequest{
|
||||
OrganizationID: uuid.New(),
|
||||
DateFrom: now,
|
||||
DateTo: now,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
require.Len(t, result.MainSummary, 7)
|
||||
|
||||
require.Equal(t, "total_omset", result.MainSummary[0].ID)
|
||||
require.Equal(t, float64(10000), result.MainSummary[0].TodayNominal)
|
||||
require.Equal(t, float64(20000), result.MainSummary[0].MtdNominal)
|
||||
|
||||
require.Equal(t, "hpp", result.MainSummary[1].ID)
|
||||
require.Equal(t, float64(4000), result.MainSummary[1].TodayNominal)
|
||||
require.Equal(t, float64(8000), result.MainSummary[1].MtdNominal)
|
||||
|
||||
require.Equal(t, "laba_kotor", result.MainSummary[2].ID)
|
||||
require.Equal(t, float64(6000), result.MainSummary[2].TodayNominal)
|
||||
require.Equal(t, float64(12000), result.MainSummary[2].MtdNominal)
|
||||
|
||||
require.Equal(t, "biaya_ops", result.MainSummary[3].ID)
|
||||
require.Equal(t, float64(800), result.MainSummary[3].TodayNominal)
|
||||
require.Equal(t, float64(1600), result.MainSummary[3].MtdNominal)
|
||||
require.Len(t, result.MainSummary[3].SubItems, 3) // 2 operational categories + 1 total
|
||||
|
||||
require.Equal(t, "by_promosi", result.MainSummary[3].SubItems[0].ID)
|
||||
require.Equal(t, float64(300), result.MainSummary[3].SubItems[0].TodayNominal)
|
||||
require.Equal(t, float64(600), result.MainSummary[3].SubItems[0].MtdNominal)
|
||||
|
||||
require.Equal(t, "by_sewa", result.MainSummary[3].SubItems[1].ID)
|
||||
require.Equal(t, float64(500), result.MainSummary[3].SubItems[1].TodayNominal)
|
||||
require.Equal(t, float64(1000), result.MainSummary[3].SubItems[1].MtdNominal)
|
||||
|
||||
require.Equal(t, "total_biaya_ops", result.MainSummary[3].SubItems[2].ID)
|
||||
require.True(t, result.MainSummary[3].SubItems[2].IsBold)
|
||||
require.Equal(t, float64(800), result.MainSummary[3].SubItems[2].TodayNominal)
|
||||
require.Equal(t, float64(1600), result.MainSummary[3].SubItems[2].MtdNominal)
|
||||
|
||||
require.Equal(t, "laba_rugi_sblm_gaji", result.MainSummary[4].ID)
|
||||
require.Equal(t, float64(5200), result.MainSummary[4].TodayNominal)
|
||||
require.Equal(t, float64(10400), result.MainSummary[4].MtdNominal)
|
||||
|
||||
require.Equal(t, "biaya_gaji", result.MainSummary[5].ID)
|
||||
require.Equal(t, float64(1500), result.MainSummary[5].TodayNominal)
|
||||
require.Equal(t, float64(3000), result.MainSummary[5].MtdNominal)
|
||||
|
||||
require.Equal(t, "laba_rugi", result.MainSummary[6].ID)
|
||||
require.Equal(t, float64(3700), result.MainSummary[6].TodayNominal)
|
||||
require.Equal(t, float64(7400), result.MainSummary[6].MtdNominal)
|
||||
require.True(t, result.MainSummary[6].IsBold)
|
||||
}
|
||||
|
||||
func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesTotalsAndReimburse(t *testing.T) {
|
||||
now := time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC)
|
||||
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
|
||||
exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{
|
||||
{
|
||||
SalesTotal: 1000,
|
||||
HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{
|
||||
{CategoryCode: "RAW", CategoryName: "Raw", Amount: 400},
|
||||
},
|
||||
OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{
|
||||
{CategoryCode: "GAJI", CategoryName: "Gaji", Amount: 250},
|
||||
{CategoryCode: "OPS", CategoryName: "Operasional", Amount: 100},
|
||||
},
|
||||
DailySummary: []entities.ExclusiveSummaryDailySummary{
|
||||
{Date: now, TransactionCount: 3, TotalCost: 750},
|
||||
},
|
||||
DailyTransactions: []entities.ExclusiveSummaryDailyTransaction{
|
||||
{Date: now, CategoryCode: "RAW", CategoryName: "Raw", Description: "beras", Amount: 400, Source: "purchase_order"},
|
||||
{Date: now, CategoryCode: "GAJI", CategoryName: "Gaji", Description: "gaji karyawan", Amount: 200, Source: "purchase_order"},
|
||||
{Date: now, CategoryCode: "GAJI", CategoryName: "Gaji", Description: "DW", Amount: 50, Source: "purchase_order"},
|
||||
{Date: now, CategoryCode: "OPS", CategoryName: "Operasional", Description: "atk", Amount: 100, Source: "purchase_order"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, expenseRepositoryStub{})
|
||||
|
||||
result, err := processor.GetExclusiveSummaryPeriod(context.Background(), &models.ExclusiveSummaryPeriodRequest{
|
||||
OrganizationID: uuid.New(),
|
||||
DateFrom: now,
|
||||
DateTo: now,
|
||||
ExcludeGajiStaffFromReimburse: true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, float64(1000), result.Summary.Sales)
|
||||
require.Equal(t, float64(400), result.Summary.HPP)
|
||||
require.Equal(t, float64(600), result.Summary.GrossProfit)
|
||||
require.Equal(t, float64(350), result.Summary.OperationalExpensesTotal)
|
||||
require.Equal(t, float64(750), result.Summary.TotalCost)
|
||||
require.Equal(t, float64(250), result.Summary.NetProfit)
|
||||
require.Equal(t, float64(250), result.Summary.SalaryTotal)
|
||||
require.Equal(t, float64(50), result.Summary.SalaryDW)
|
||||
require.Equal(t, float64(200), result.Summary.SalaryStaff)
|
||||
require.Equal(t, float64(100), result.Summary.OtherOperationalExpenses)
|
||||
require.Equal(t, float64(200), result.Reimburse.ExcludedSalaryStaff)
|
||||
require.Equal(t, float64(550), result.Reimburse.TotalReimburse)
|
||||
require.Len(t, result.HPPBreakdown, 1)
|
||||
require.Equal(t, float64(100), result.HPPBreakdown[0].Percentage)
|
||||
require.Len(t, result.DailySummary, 1)
|
||||
require.Len(t, result.DailyTransactions, 4)
|
||||
}
|
||||
|
||||
func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsSummaryAndBuckets(t *testing.T) {
|
||||
location, err := time.LoadLocation("Asia/Jakarta")
|
||||
require.NoError(t, err)
|
||||
month := time.Date(2026, 5, 1, 0, 0, 0, 0, location)
|
||||
openingBalance := 5000000.0
|
||||
closingBalance := 5000000.0
|
||||
notes := "Main cash account for daily transactions"
|
||||
stub := &analyticsRepositoryStub{
|
||||
exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{
|
||||
{SalesTotal: 1000, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 400}}, OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 100}}},
|
||||
{SalesTotal: 100, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 40}}},
|
||||
{SalesTotal: 200, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 80}}},
|
||||
{SalesTotal: 300, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 120}}},
|
||||
{SalesTotal: 400, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 160}}},
|
||||
{SalesTotal: 500, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 200}}},
|
||||
},
|
||||
bankBalances: []entities.ExclusiveSummaryBankBalance{
|
||||
{Bank: "Cash and Bank", OpeningBalance: &openingBalance, ClosingBalance: &closingBalance, Notes: ¬es},
|
||||
},
|
||||
}
|
||||
processor := NewAnalyticsProcessorImpl(stub, expenseRepositoryStub{})
|
||||
|
||||
result, err := processor.GetExclusiveSummaryMonthly(context.Background(), &models.ExclusiveSummaryMonthlyRequest{
|
||||
OrganizationID: uuid.New(),
|
||||
Month: month,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, "2026-05", result.Month)
|
||||
require.Equal(t, float64(1000), result.Summary.TotalSales)
|
||||
require.Equal(t, float64(400), result.Summary.HPP)
|
||||
require.Equal(t, float64(500), result.Summary.NetProfit)
|
||||
require.InDelta(t, float64(50), result.Summary.NetProfitMargin, 0.0001)
|
||||
require.Len(t, result.Periods, 5)
|
||||
require.Equal(t, "1 - 3 Mei", result.Periods[0].Label)
|
||||
require.Equal(t, "25 - 31 Mei", result.Periods[4].Label)
|
||||
require.Len(t, result.BankBalance, 1)
|
||||
require.Equal(t, "Cash and Bank", result.BankBalance[0].Bank)
|
||||
require.NotNil(t, result.BankBalance[0].OpeningBalance)
|
||||
require.Equal(t, openingBalance, *result.BankBalance[0].OpeningBalance)
|
||||
require.NotNil(t, result.BankBalance[0].ClosingBalance)
|
||||
require.Equal(t, closingBalance, *result.BankBalance[0].ClosingBalance)
|
||||
require.Nil(t, result.BankBalance[0].IncomingMutation)
|
||||
require.Nil(t, result.BankBalance[0].OutgoingMutation)
|
||||
require.NotNil(t, result.BankBalance[0].Notes)
|
||||
require.Equal(t, notes, *result.BankBalance[0].Notes)
|
||||
require.Equal(t, 6, stub.exclusiveSummaryCalls)
|
||||
}
|
||||
|
||||
func TestAnalyticsProcessorGetExclusiveSummaryMTDBuildsMonthToDateBreakdown(t *testing.T) {
|
||||
location, err := time.LoadLocation("Asia/Jakarta")
|
||||
require.NoError(t, err)
|
||||
dateTo := time.Date(2026, 6, 18, 23, 59, 59, int(time.Second-time.Nanosecond), location)
|
||||
stub := &analyticsRepositoryStub{
|
||||
exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{
|
||||
{
|
||||
SalesTotal: 1000,
|
||||
HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{
|
||||
{CategoryCode: "RAW", CategoryName: "Raw Material", Amount: 400},
|
||||
},
|
||||
OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{
|
||||
{CategoryCode: "OPS", CategoryName: "Operational", Amount: 100},
|
||||
},
|
||||
DailySummary: []entities.ExclusiveSummaryDailySummary{
|
||||
{Date: dateTo, TransactionCount: 2, TotalCost: 500},
|
||||
},
|
||||
DailyTransactions: []entities.ExclusiveSummaryDailyTransaction{
|
||||
{Date: dateTo, CategoryCode: "RAW", CategoryName: "Raw Material", Description: "beras", Amount: 400, Source: "purchase_order"},
|
||||
{Date: dateTo, CategoryCode: "OPS", CategoryName: "Operational", Description: "atk", Amount: 100, Source: "expense"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
processor := NewAnalyticsProcessorImpl(stub, expenseRepositoryStub{})
|
||||
|
||||
result, err := processor.GetExclusiveSummaryMTD(context.Background(), &models.ExclusiveSummaryMTDRequest{
|
||||
OrganizationID: uuid.New(),
|
||||
DateTo: dateTo,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, stub.exclusiveSummaryFrom, 1)
|
||||
require.Equal(t, time.Date(2026, 6, 1, 0, 0, 0, 0, location), stub.exclusiveSummaryFrom[0])
|
||||
require.Equal(t, dateTo, stub.exclusiveSummaryTo[0])
|
||||
require.Equal(t, stub.exclusiveSummaryFrom[0], result.Period.DateFrom)
|
||||
require.Equal(t, dateTo, result.Period.DateTo)
|
||||
require.Equal(t, float64(1000), result.Summary.Sales)
|
||||
require.Equal(t, float64(400), result.Summary.HPP)
|
||||
require.Equal(t, float64(500), result.Summary.TotalCost)
|
||||
require.Equal(t, float64(500), result.Summary.NetProfit)
|
||||
require.Len(t, result.HPPBreakdown, 1)
|
||||
require.Equal(t, float64(100), result.HPPBreakdown[0].Percentage)
|
||||
require.Len(t, result.OperationalExpenseBreakdown, 1)
|
||||
require.Len(t, result.DailySummary, 1)
|
||||
require.Len(t, result.DailyTransactions, 2)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func jakarta(t *testing.T) *time.Location {
|
||||
t.Helper()
|
||||
loc, err := time.LoadLocation("Asia/Jakarta")
|
||||
require.NoError(t, err)
|
||||
return loc
|
||||
}
|
||||
|
||||
func TestStartOfWeekLandsOnMonday(t *testing.T) {
|
||||
loc := jakarta(t)
|
||||
|
||||
// 3 Aug 2026 is a Monday, so the whole week must collapse onto it
|
||||
monday := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
|
||||
|
||||
for offset := 0; offset < 7; offset++ {
|
||||
day := monday.AddDate(0, 0, offset).Add(13 * time.Hour)
|
||||
|
||||
got := startOfWeek(day)
|
||||
require.Equal(t, monday, got, "day %s should map to %s", day, monday)
|
||||
require.Equal(t, time.Monday, got.Weekday())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndOfWeekLandsOnSunday(t *testing.T) {
|
||||
loc := jakarta(t)
|
||||
|
||||
// Sunday 9 Aug 2026 closes the week that starts Monday 3 Aug
|
||||
got := endOfWeek(time.Date(2026, 8, 5, 9, 30, 0, 0, loc))
|
||||
|
||||
require.Equal(t, time.Sunday, got.Weekday())
|
||||
require.Equal(t, 2026, got.Year())
|
||||
require.Equal(t, time.August, got.Month())
|
||||
require.Equal(t, 9, got.Day())
|
||||
require.Equal(t, 23, got.Hour())
|
||||
require.Equal(t, 59, got.Minute())
|
||||
}
|
||||
|
||||
func TestBuildBudgetCutOffWidensToWholeWeeks(t *testing.T) {
|
||||
loc := jakarta(t)
|
||||
processor := &AnalyticsProcessorImpl{analyticsRepo: &analyticsRepositoryStub{}}
|
||||
|
||||
// Saturday 1 Aug to Monday 31 Aug 2026: both ends fall mid-week
|
||||
from := time.Date(2026, 8, 1, 0, 0, 0, 0, loc)
|
||||
to := time.Date(2026, 8, 31, 23, 59, 59, 0, loc)
|
||||
|
||||
budget, err := processor.buildBudgetCutOff(context.Background(), uuid.New(), nil, nil, from, to)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Reaches back into July and forward into September to keep weeks whole
|
||||
require.Equal(t, time.Monday, budget.CutOffFrom.Weekday())
|
||||
require.Equal(t, time.July, budget.CutOffFrom.Month())
|
||||
require.Equal(t, 27, budget.CutOffFrom.Day())
|
||||
require.Equal(t, time.Sunday, budget.CutOffTo.Weekday())
|
||||
require.Equal(t, time.September, budget.CutOffTo.Month())
|
||||
require.Equal(t, 6, budget.CutOffTo.Day())
|
||||
|
||||
require.Len(t, budget.Weekly, 6)
|
||||
for _, week := range budget.Weekly {
|
||||
require.Equal(t, time.Monday, week.PeriodStart.Weekday())
|
||||
require.Equal(t, time.Sunday, week.PeriodEnd.Weekday())
|
||||
}
|
||||
|
||||
// A week is filed under the month of its Monday, so the 27 Jul week counts as July
|
||||
require.Len(t, budget.Monthly, 2)
|
||||
require.Equal(t, "2026-07", budget.Monthly[0].Month)
|
||||
require.Equal(t, 1, budget.Monthly[0].WeekCount)
|
||||
require.Equal(t, "2026-08", budget.Monthly[1].Month)
|
||||
require.Equal(t, 5, budget.Monthly[1].WeekCount)
|
||||
}
|
||||
|
||||
func TestBuildBudgetCutOffAppliesLimits(t *testing.T) {
|
||||
loc := jakarta(t)
|
||||
weekStart := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
|
||||
|
||||
stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{
|
||||
{WeekStart: weekStart, Revenue: 10_000_000, OrderCount: 120},
|
||||
}}
|
||||
processor := &AnalyticsProcessorImpl{analyticsRepo: stub}
|
||||
|
||||
budget, err := processor.buildBudgetCutOff(context.Background(), uuid.New(), nil, nil, weekStart, weekStart.AddDate(0, 0, 6))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, budget.Weekly, 1)
|
||||
|
||||
// 60 / 20 / 20 of the week's revenue
|
||||
week := budget.Weekly[0]
|
||||
require.Equal(t, float64(6_000_000), week.LimitPurchase)
|
||||
require.Equal(t, float64(2_000_000), week.LimitOwner)
|
||||
require.Equal(t, float64(2_000_000), week.LimitTeam)
|
||||
require.Equal(t, int64(120), week.OrderCount)
|
||||
|
||||
// Totals mirror the single week
|
||||
require.Equal(t, week.Revenue, budget.Total.Revenue)
|
||||
require.Equal(t, week.LimitPurchase, budget.Total.LimitPurchase)
|
||||
}
|
||||
|
||||
func TestBuildBudgetCutOffAccumulatesMonthlyFromWeeks(t *testing.T) {
|
||||
loc := jakarta(t)
|
||||
first := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
|
||||
|
||||
stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{
|
||||
{WeekStart: first, Revenue: 10_000_000, OrderCount: 100},
|
||||
{WeekStart: first.AddDate(0, 0, 7), Revenue: 5_000_000, OrderCount: 60},
|
||||
}}
|
||||
processor := &AnalyticsProcessorImpl{analyticsRepo: stub}
|
||||
|
||||
budget, err := processor.buildBudgetCutOff(context.Background(), uuid.New(), nil, nil, first, first.AddDate(0, 0, 9))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, budget.Monthly, 1)
|
||||
month := budget.Monthly[0]
|
||||
require.Equal(t, "2026-08", month.Month)
|
||||
require.Equal(t, 2, month.WeekCount)
|
||||
require.Equal(t, float64(15_000_000), month.Revenue)
|
||||
require.Equal(t, int64(160), month.OrderCount)
|
||||
|
||||
// The month limit is the accumulation of its weeks
|
||||
require.Equal(t, float64(9_000_000), month.LimitPurchase)
|
||||
require.Equal(t, budget.Weekly[0].LimitPurchase+budget.Weekly[1].LimitPurchase, month.LimitPurchase)
|
||||
}
|
||||
|
||||
func TestBuildBudgetCutOffEmitsWeeksWithoutSales(t *testing.T) {
|
||||
loc := jakarta(t)
|
||||
first := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
|
||||
|
||||
// Only the third week has sales; the two quiet weeks must still be reported
|
||||
stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{
|
||||
{WeekStart: first.AddDate(0, 0, 14), Revenue: 4_000_000, OrderCount: 40},
|
||||
}}
|
||||
processor := &AnalyticsProcessorImpl{analyticsRepo: stub}
|
||||
|
||||
budget, err := processor.buildBudgetCutOff(context.Background(), uuid.New(), nil, nil, first, first.AddDate(0, 0, 16))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, budget.Weekly, 3)
|
||||
require.Zero(t, budget.Weekly[0].Revenue)
|
||||
require.Zero(t, budget.Weekly[0].LimitPurchase)
|
||||
require.Zero(t, budget.Weekly[1].Revenue)
|
||||
require.Equal(t, float64(4_000_000), budget.Weekly[2].Revenue)
|
||||
require.Equal(t, float64(4_000_000), budget.Total.Revenue)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ type CategoryRepository interface {
|
||||
GetByID(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)
|
||||
ListParentCategories(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]*entities.Category, error)
|
||||
GetByBusinessType(ctx context.Context, businessType string) ([]*entities.Category, error)
|
||||
Update(ctx context.Context, category *entities.Category) error
|
||||
Delete(ctx context.Context, id uuid.UUID) error
|
||||
@@ -53,6 +54,18 @@ func (p *CategoryProcessorImpl) CreateCategory(ctx context.Context, req *models.
|
||||
return nil, fmt.Errorf("category with name '%s' already exists for this organization", req.Name)
|
||||
}
|
||||
|
||||
var parentName *string
|
||||
if req.ParentID != nil {
|
||||
parentCategory, err := p.categoryRepo.GetByID(ctx, *req.ParentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parent category not found: %w", err)
|
||||
}
|
||||
if parentCategory.OrganizationID != req.OrganizationID {
|
||||
return nil, fmt.Errorf("parent category must belong to the same organization")
|
||||
}
|
||||
parentName = &parentCategory.Name
|
||||
}
|
||||
|
||||
// Map request to entity
|
||||
categoryEntity := mappers.CreateCategoryRequestToEntity(req)
|
||||
|
||||
@@ -63,6 +76,7 @@ func (p *CategoryProcessorImpl) CreateCategory(ctx context.Context, req *models.
|
||||
|
||||
// Map entity to response model
|
||||
response := mappers.CategoryEntityToResponse(categoryEntity)
|
||||
response.ParentName = parentName
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -84,6 +98,23 @@ func (p *CategoryProcessorImpl) UpdateCategory(ctx context.Context, id uuid.UUID
|
||||
}
|
||||
}
|
||||
|
||||
if req.ParentID != nil {
|
||||
if *req.ParentID == id {
|
||||
return nil, fmt.Errorf("category cannot be its own parent")
|
||||
}
|
||||
|
||||
parentCategory, err := p.categoryRepo.GetByID(ctx, *req.ParentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parent category not found: %w", err)
|
||||
}
|
||||
if parentCategory.OrganizationID != existingCategory.OrganizationID {
|
||||
return nil, fmt.Errorf("parent category must belong to the same organization")
|
||||
}
|
||||
|
||||
// Refresh the preloaded association so the response carries the new parent
|
||||
existingCategory.Parent = parentCategory
|
||||
}
|
||||
|
||||
// Apply updates to entity
|
||||
mappers.UpdateCategoryEntityFromRequest(existingCategory, req)
|
||||
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/mappers"
|
||||
"apskel-pos-be/internal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ExpenseProcessor interface {
|
||||
CreateExpense(ctx context.Context, organizationID uuid.UUID, req *models.CreateExpenseRequest) (*models.ExpenseResponse, error)
|
||||
UpdateExpense(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdateExpenseRequest) (*models.ExpenseResponse, error)
|
||||
DeleteExpense(ctx context.Context, id, organizationID uuid.UUID) error
|
||||
GetExpenseByID(ctx context.Context, id, organizationID uuid.UUID) (*models.ExpenseResponse, error)
|
||||
ListExpenses(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.ExpenseResponse, int, error)
|
||||
GetExpenseAnalytics(ctx context.Context, req *models.ExpenseAnalyticsRequest) (*models.ExpenseAnalyticsResponse, error)
|
||||
}
|
||||
|
||||
type ExpenseProcessorImpl struct {
|
||||
expenseRepo ExpenseRepository
|
||||
purchaseCategoryRepo PurchaseCategoryRepository
|
||||
}
|
||||
|
||||
func NewExpenseProcessorImpl(expenseRepo ExpenseRepository, purchaseCategoryRepo PurchaseCategoryRepository) *ExpenseProcessorImpl {
|
||||
return &ExpenseProcessorImpl{
|
||||
expenseRepo: expenseRepo,
|
||||
purchaseCategoryRepo: purchaseCategoryRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID uuid.UUID, req *models.CreateExpenseRequest) (*models.ExpenseResponse, error) {
|
||||
outletID, err := uuid.Parse(req.OutletID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid outlet_id: %w", err)
|
||||
}
|
||||
|
||||
transactionDate, err := time.Parse("2006-01-02", req.TransactionDate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid transaction_date format, expected YYYY-MM-DD: %w", err)
|
||||
}
|
||||
|
||||
status := string(constants.ExpenseStatusDraft)
|
||||
if req.Status != nil {
|
||||
status = *req.Status
|
||||
}
|
||||
|
||||
items := make([]entities.ExpenseItem, len(req.Items))
|
||||
for i, itemReq := range req.Items {
|
||||
chartOfAccountID, err := uuid.Parse(itemReq.ChartOfAccountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid chart_of_account_id for item: %w", err)
|
||||
}
|
||||
|
||||
purchaseCategoryID, err := uuid.Parse(itemReq.PurchaseCategoryID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid purchase_category_id for item: %w", err)
|
||||
}
|
||||
if err := p.validateExpensePurchaseCategory(ctx, purchaseCategoryID, organizationID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items[i] = entities.ExpenseItem{
|
||||
ChartOfAccountID: chartOfAccountID,
|
||||
PurchaseCategoryID: purchaseCategoryID,
|
||||
Item: itemReq.Item,
|
||||
Description: itemReq.Description,
|
||||
Amount: itemReq.Amount,
|
||||
}
|
||||
}
|
||||
|
||||
expenseEntity := &entities.Expense{
|
||||
OrganizationID: organizationID,
|
||||
OutletID: outletID,
|
||||
Receiver: req.Receiver,
|
||||
TransactionDate: transactionDate,
|
||||
CodeNumber: req.CodeNumber,
|
||||
Status: status,
|
||||
Description: req.Description,
|
||||
Tax: req.Tax,
|
||||
Total: req.Total,
|
||||
}
|
||||
|
||||
err = p.expenseRepo.Create(ctx, expenseEntity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create expense: %w", err)
|
||||
}
|
||||
|
||||
for i := range items {
|
||||
items[i].ExpenseID = expenseEntity.ID
|
||||
|
||||
err = p.expenseRepo.CreateItem(ctx, &items[i])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create expense item: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
created, err := p.expenseRepo.GetByID(ctx, expenseEntity.ID)
|
||||
if err != nil {
|
||||
return mappers.ExpenseEntityToResponse(expenseEntity), nil
|
||||
}
|
||||
|
||||
return mappers.ExpenseEntityToResponse(created), nil
|
||||
}
|
||||
|
||||
func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdateExpenseRequest) (*models.ExpenseResponse, error) {
|
||||
expenseEntity, err := p.expenseRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expense not found: %w", err)
|
||||
}
|
||||
|
||||
if req.Receiver != nil {
|
||||
expenseEntity.Receiver = *req.Receiver
|
||||
}
|
||||
if req.TransactionDate != nil {
|
||||
parsedDate, err := time.Parse("2006-01-02", *req.TransactionDate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid transaction_date format, expected YYYY-MM-DD: %w", err)
|
||||
}
|
||||
expenseEntity.TransactionDate = parsedDate
|
||||
}
|
||||
if req.CodeNumber != nil {
|
||||
expenseEntity.CodeNumber = *req.CodeNumber
|
||||
}
|
||||
if req.Status != nil {
|
||||
expenseEntity.Status = *req.Status
|
||||
}
|
||||
if req.OutletID != nil {
|
||||
outletID, err := uuid.Parse(*req.OutletID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid outlet_id: %w", err)
|
||||
}
|
||||
expenseEntity.OutletID = outletID
|
||||
}
|
||||
if req.Description != nil {
|
||||
expenseEntity.Description = req.Description
|
||||
}
|
||||
if req.Tax != nil {
|
||||
expenseEntity.Tax = *req.Tax
|
||||
}
|
||||
if req.Total != nil {
|
||||
expenseEntity.Total = *req.Total
|
||||
}
|
||||
if req.Reserved1 != nil {
|
||||
expenseEntity.Reserved1 = req.Reserved1
|
||||
}
|
||||
|
||||
var items []entities.ExpenseItem
|
||||
if req.Items != nil {
|
||||
items = make([]entities.ExpenseItem, len(req.Items))
|
||||
for i, itemReq := range req.Items {
|
||||
chartOfAccountID := uuid.Nil
|
||||
if itemReq.ChartOfAccountID != nil {
|
||||
chartOfAccountID, err = uuid.Parse(*itemReq.ChartOfAccountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid chart_of_account_id for item: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if itemReq.PurchaseCategoryID == nil {
|
||||
return nil, fmt.Errorf("purchase_category_id is required for item")
|
||||
}
|
||||
purchaseCategoryID, err := uuid.Parse(*itemReq.PurchaseCategoryID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid purchase_category_id for item: %w", err)
|
||||
}
|
||||
if err := p.validateExpensePurchaseCategory(ctx, purchaseCategoryID, organizationID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
amount := 0.0
|
||||
if itemReq.Amount != nil {
|
||||
amount = *itemReq.Amount
|
||||
}
|
||||
item := ""
|
||||
if itemReq.Item != nil {
|
||||
item = *itemReq.Item
|
||||
}
|
||||
|
||||
items[i] = entities.ExpenseItem{
|
||||
ExpenseID: expenseEntity.ID,
|
||||
ChartOfAccountID: chartOfAccountID,
|
||||
PurchaseCategoryID: purchaseCategoryID,
|
||||
Item: item,
|
||||
Description: itemReq.Description,
|
||||
Amount: amount,
|
||||
}
|
||||
}
|
||||
|
||||
err = p.expenseRepo.DeleteItemsByExpenseID(ctx, expenseEntity.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to delete existing items: %w", err)
|
||||
}
|
||||
|
||||
for i := range items {
|
||||
err = p.expenseRepo.CreateItem(ctx, &items[i])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create expense item: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = p.expenseRepo.Update(ctx, expenseEntity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update expense: %w", err)
|
||||
}
|
||||
|
||||
updated, err := p.expenseRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return mappers.ExpenseEntityToResponse(expenseEntity), nil
|
||||
}
|
||||
|
||||
return mappers.ExpenseEntityToResponse(updated), nil
|
||||
}
|
||||
|
||||
func (p *ExpenseProcessorImpl) DeleteExpense(ctx context.Context, id, organizationID uuid.UUID) error {
|
||||
_, err := p.expenseRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("expense not found: %w", err)
|
||||
}
|
||||
|
||||
err = p.expenseRepo.Delete(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete expense: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ExpenseProcessorImpl) GetExpenseByID(ctx context.Context, id, organizationID uuid.UUID) (*models.ExpenseResponse, error) {
|
||||
expenseEntity, err := p.expenseRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expense not found: %w", err)
|
||||
}
|
||||
|
||||
return mappers.ExpenseEntityToResponse(expenseEntity), nil
|
||||
}
|
||||
|
||||
func (p *ExpenseProcessorImpl) ListExpenses(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.ExpenseResponse, int, error) {
|
||||
offset := (page - 1) * limit
|
||||
expenseEntities, total, err := p.expenseRepo.List(ctx, organizationID, filters, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list expenses: %w", err)
|
||||
}
|
||||
|
||||
expenseResponses := mappers.ExpenseEntitiesToResponses(expenseEntities)
|
||||
totalPages := int((total + int64(limit) - 1) / int64(limit))
|
||||
|
||||
return expenseResponses, totalPages, nil
|
||||
}
|
||||
|
||||
func (p *ExpenseProcessorImpl) GetExpenseAnalytics(ctx context.Context, req *models.ExpenseAnalyticsRequest) (*models.ExpenseAnalyticsResponse, error) {
|
||||
if req.DateFrom.After(req.DateTo) {
|
||||
return nil, fmt.Errorf("date_from cannot be after date_to")
|
||||
}
|
||||
|
||||
if req.GroupBy == "" {
|
||||
req.GroupBy = "day"
|
||||
}
|
||||
|
||||
result, err := p.expenseRepo.GetAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get expense analytics: %w", err)
|
||||
}
|
||||
|
||||
data := make([]models.ExpenseAnalyticsData, len(result.Data))
|
||||
for i, item := range result.Data {
|
||||
data[i] = models.ExpenseAnalyticsData{
|
||||
Date: item.Date,
|
||||
Expenses: item.Expenses,
|
||||
ExpenseCount: item.ExpenseCount,
|
||||
Tax: item.Tax,
|
||||
Items: item.Items,
|
||||
Categories: item.Categories,
|
||||
}
|
||||
}
|
||||
|
||||
categoryData := make([]models.ExpenseAnalyticsCategoryData, len(result.CategoryData))
|
||||
for i, item := range result.CategoryData {
|
||||
categoryData[i] = models.ExpenseAnalyticsCategoryData{
|
||||
PurchaseCategoryID: item.PurchaseCategoryID,
|
||||
PurchaseCategoryName: item.PurchaseCategoryName,
|
||||
PurchaseCategoryType: item.PurchaseCategoryType,
|
||||
TotalAmount: item.TotalAmount,
|
||||
ExpenseCount: item.ExpenseCount,
|
||||
ItemCount: item.ItemCount,
|
||||
}
|
||||
}
|
||||
|
||||
chartOfAccountData := make([]models.ExpenseAnalyticsChartOfAccountData, len(result.ChartOfAccountData))
|
||||
for i, item := range result.ChartOfAccountData {
|
||||
chartOfAccountData[i] = models.ExpenseAnalyticsChartOfAccountData{
|
||||
ChartOfAccountID: item.ChartOfAccountID,
|
||||
ChartOfAccountName: item.ChartOfAccountName,
|
||||
TotalAmount: item.TotalAmount,
|
||||
ExpenseCount: item.ExpenseCount,
|
||||
ItemCount: item.ItemCount,
|
||||
}
|
||||
}
|
||||
|
||||
itemData := make([]models.ExpenseAnalyticsItemData, len(result.ItemData))
|
||||
for i, item := range result.ItemData {
|
||||
itemData[i] = models.ExpenseAnalyticsItemData{
|
||||
Item: item.Item,
|
||||
TotalAmount: item.TotalAmount,
|
||||
ExpenseCount: item.ExpenseCount,
|
||||
ItemCount: item.ItemCount,
|
||||
}
|
||||
}
|
||||
|
||||
return &models.ExpenseAnalyticsResponse{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
DateFrom: req.DateFrom,
|
||||
DateTo: req.DateTo,
|
||||
GroupBy: req.GroupBy,
|
||||
Summary: models.ExpenseAnalyticsSummary{
|
||||
TotalExpenses: result.Summary.TotalExpenses,
|
||||
TotalExpenseCount: result.Summary.TotalExpenseCount,
|
||||
TotalTax: result.Summary.TotalTax,
|
||||
AverageExpenseValue: result.Summary.AverageExpenseValue,
|
||||
TotalCategories: result.Summary.TotalCategories,
|
||||
TotalItems: result.Summary.TotalItems,
|
||||
},
|
||||
Data: data,
|
||||
CategoryData: categoryData,
|
||||
ChartOfAccountData: chartOfAccountData,
|
||||
ItemData: itemData,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *ExpenseProcessorImpl) validateExpensePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID) error {
|
||||
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("purchase category not found: %w", err)
|
||||
}
|
||||
|
||||
if !category.IsActive {
|
||||
return fmt.Errorf("purchase category is inactive")
|
||||
}
|
||||
|
||||
if category.Type != entities.PurchaseCategoryTypeExpense {
|
||||
return fmt.Errorf("purchase category must be expense")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type expenseRepositoryCaptureStub struct {
|
||||
createdExpense *entities.Expense
|
||||
createdItems []*entities.ExpenseItem
|
||||
analytics *entities.ExpenseAnalytics
|
||||
}
|
||||
|
||||
type expensePurchaseCategoryRepositoryStub struct {
|
||||
category *entities.PurchaseCategory
|
||||
}
|
||||
|
||||
func (*expensePurchaseCategoryRepositoryStub) Create(context.Context, *entities.PurchaseCategory) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *expensePurchaseCategoryRepositoryStub) GetByIDAndOrganizationID(context.Context, uuid.UUID, uuid.UUID) (*entities.PurchaseCategory, error) {
|
||||
return s.category, nil
|
||||
}
|
||||
|
||||
func (*expensePurchaseCategoryRepositoryStub) Update(context.Context, *entities.PurchaseCategory) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*expensePurchaseCategoryRepositoryStub) SoftDelete(context.Context, uuid.UUID, uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*expensePurchaseCategoryRepositoryStub) List(context.Context, uuid.UUID, map[string]interface{}, int, int) ([]*entities.PurchaseCategory, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (*expensePurchaseCategoryRepositoryStub) ExistsByCode(context.Context, uuid.UUID, string, *uuid.UUID) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func newExpensePurchaseCategoryRepo(categoryID uuid.UUID, categoryType entities.PurchaseCategoryType) *expensePurchaseCategoryRepositoryStub {
|
||||
return &expensePurchaseCategoryRepositoryStub{
|
||||
category: &entities.PurchaseCategory{
|
||||
ID: categoryID,
|
||||
Name: "Operational",
|
||||
Type: categoryType,
|
||||
IsActive: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *expenseRepositoryCaptureStub) Create(_ context.Context, expense *entities.Expense) error {
|
||||
if expense.ID == uuid.Nil {
|
||||
expense.ID = uuid.New()
|
||||
}
|
||||
s.createdExpense = expense
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *expenseRepositoryCaptureStub) GetByID(context.Context, uuid.UUID) (*entities.Expense, error) {
|
||||
if s.createdExpense == nil {
|
||||
return nil, nil
|
||||
}
|
||||
items := make([]entities.ExpenseItem, len(s.createdItems))
|
||||
for i, item := range s.createdItems {
|
||||
items[i] = *item
|
||||
}
|
||||
s.createdExpense.Items = items
|
||||
return s.createdExpense, nil
|
||||
}
|
||||
|
||||
func (*expenseRepositoryCaptureStub) GetByIDAndOrganizationID(context.Context, uuid.UUID, uuid.UUID) (*entities.Expense, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (*expenseRepositoryCaptureStub) Update(context.Context, *entities.Expense) error { return nil }
|
||||
func (*expenseRepositoryCaptureStub) Delete(context.Context, uuid.UUID) error { return nil }
|
||||
func (*expenseRepositoryCaptureStub) List(context.Context, uuid.UUID, map[string]interface{}, int, int) ([]*entities.Expense, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
func (s *expenseRepositoryCaptureStub) GetAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.ExpenseAnalytics, error) {
|
||||
return s.analytics, nil
|
||||
}
|
||||
func (s *expenseRepositoryCaptureStub) CreateItem(_ context.Context, item *entities.ExpenseItem) error {
|
||||
if item.ID == uuid.Nil {
|
||||
item.ID = uuid.New()
|
||||
}
|
||||
s.createdItems = append(s.createdItems, item)
|
||||
return nil
|
||||
}
|
||||
func (*expenseRepositoryCaptureStub) DeleteItemsByExpenseID(context.Context, uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestExpenseProcessorCreatePersistsItemName(t *testing.T) {
|
||||
repo := &expenseRepositoryCaptureStub{}
|
||||
purchaseCategoryID := uuid.New()
|
||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
|
||||
chartOfAccountID := uuid.New()
|
||||
|
||||
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
|
||||
Receiver: "Cashier",
|
||||
TransactionDate: "2026-05-29",
|
||||
CodeNumber: "EXP-001",
|
||||
OutletID: uuid.NewString(),
|
||||
Total: 10000,
|
||||
Items: []models.CreateExpenseItemRequest{
|
||||
{
|
||||
ChartOfAccountID: chartOfAccountID.String(),
|
||||
PurchaseCategoryID: purchaseCategoryID.String(),
|
||||
Item: "Cleaning supplies",
|
||||
Amount: 10000,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.Len(t, repo.createdItems, 1)
|
||||
require.Equal(t, "Cleaning supplies", repo.createdItems[0].Item)
|
||||
require.Equal(t, purchaseCategoryID, repo.createdItems[0].PurchaseCategoryID)
|
||||
require.Len(t, resp.Items, 1)
|
||||
require.Equal(t, "Cleaning supplies", resp.Items[0].Item)
|
||||
}
|
||||
|
||||
func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) {
|
||||
repo := &expenseRepositoryCaptureStub{}
|
||||
purchaseCategoryID := uuid.New()
|
||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
|
||||
|
||||
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
|
||||
Receiver: "Cashier",
|
||||
TransactionDate: "2026-05-29",
|
||||
CodeNumber: "EXP-001",
|
||||
OutletID: uuid.NewString(),
|
||||
Total: 10000,
|
||||
Items: []models.CreateExpenseItemRequest{
|
||||
{
|
||||
ChartOfAccountID: uuid.NewString(),
|
||||
PurchaseCategoryID: purchaseCategoryID.String(),
|
||||
Item: "Cleaning supplies",
|
||||
Amount: 10000,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, "draft", repo.createdExpense.Status)
|
||||
require.Equal(t, "draft", resp.Status)
|
||||
}
|
||||
|
||||
func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) {
|
||||
repo := &expenseRepositoryCaptureStub{}
|
||||
purchaseCategoryID := uuid.New()
|
||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
|
||||
status := "approved"
|
||||
|
||||
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
|
||||
Receiver: "Cashier",
|
||||
TransactionDate: "2026-05-29",
|
||||
CodeNumber: "EXP-001",
|
||||
OutletID: uuid.NewString(),
|
||||
Status: &status,
|
||||
Total: 10000,
|
||||
Items: []models.CreateExpenseItemRequest{
|
||||
{
|
||||
ChartOfAccountID: uuid.NewString(),
|
||||
PurchaseCategoryID: purchaseCategoryID.String(),
|
||||
Item: "Cleaning supplies",
|
||||
Amount: 10000,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, "approved", repo.createdExpense.Status)
|
||||
require.Equal(t, "approved", resp.Status)
|
||||
}
|
||||
|
||||
func TestExpenseProcessorCreateRejectsRawMaterialPurchaseCategory(t *testing.T) {
|
||||
repo := &expenseRepositoryCaptureStub{}
|
||||
purchaseCategoryID := uuid.New()
|
||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeRawMaterial))
|
||||
|
||||
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
|
||||
Receiver: "Cashier",
|
||||
TransactionDate: "2026-05-29",
|
||||
CodeNumber: "EXP-001",
|
||||
OutletID: uuid.NewString(),
|
||||
Total: 10000,
|
||||
Items: []models.CreateExpenseItemRequest{
|
||||
{
|
||||
ChartOfAccountID: uuid.NewString(),
|
||||
PurchaseCategoryID: purchaseCategoryID.String(),
|
||||
Item: "Cleaning supplies",
|
||||
Amount: 10000,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
require.Nil(t, resp)
|
||||
require.Contains(t, err.Error(), "expense")
|
||||
}
|
||||
|
||||
func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *testing.T) {
|
||||
coaID := uuid.New()
|
||||
purchaseCategoryID := uuid.New()
|
||||
outletID := uuid.New()
|
||||
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||
repo := &expenseRepositoryCaptureStub{
|
||||
analytics: &entities.ExpenseAnalytics{
|
||||
Summary: entities.ExpenseAnalyticsSummary{
|
||||
TotalExpenses: 100000,
|
||||
TotalExpenseCount: 2,
|
||||
TotalTax: 10000,
|
||||
AverageExpenseValue: 50000,
|
||||
TotalCategories: 1,
|
||||
TotalItems: 2,
|
||||
},
|
||||
Data: []entities.ExpenseAnalyticsData{
|
||||
{
|
||||
Date: now,
|
||||
Expenses: 100000,
|
||||
ExpenseCount: 2,
|
||||
Tax: 10000,
|
||||
Items: 2,
|
||||
Categories: 1,
|
||||
},
|
||||
},
|
||||
CategoryData: []entities.ExpenseAnalyticsCategoryData{
|
||||
{
|
||||
PurchaseCategoryID: purchaseCategoryID,
|
||||
PurchaseCategoryName: "Operational Supplies",
|
||||
PurchaseCategoryType: "expense",
|
||||
TotalAmount: 100000,
|
||||
ExpenseCount: 2,
|
||||
ItemCount: 2,
|
||||
},
|
||||
},
|
||||
ChartOfAccountData: []entities.ExpenseAnalyticsChartOfAccountData{
|
||||
{
|
||||
ChartOfAccountID: coaID,
|
||||
ChartOfAccountName: "Operational",
|
||||
TotalAmount: 100000,
|
||||
ExpenseCount: 2,
|
||||
ItemCount: 2,
|
||||
},
|
||||
},
|
||||
ItemData: []entities.ExpenseAnalyticsItemData{
|
||||
{
|
||||
Item: "Cleaning supplies",
|
||||
TotalAmount: 100000,
|
||||
ExpenseCount: 2,
|
||||
ItemCount: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
|
||||
|
||||
resp, err := p.GetExpenseAnalytics(context.Background(), &models.ExpenseAnalyticsRequest{
|
||||
OrganizationID: uuid.New(),
|
||||
OutletID: &outletID,
|
||||
DateFrom: now,
|
||||
DateTo: now,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, "day", resp.GroupBy)
|
||||
require.Equal(t, &outletID, resp.OutletID)
|
||||
require.Equal(t, float64(100000), resp.Summary.TotalExpenses)
|
||||
require.Len(t, resp.Data, 1)
|
||||
require.Equal(t, int64(2), resp.Data[0].ExpenseCount)
|
||||
require.Len(t, resp.CategoryData, 1)
|
||||
require.Equal(t, purchaseCategoryID, resp.CategoryData[0].PurchaseCategoryID)
|
||||
require.Len(t, resp.ChartOfAccountData, 1)
|
||||
require.Equal(t, coaID, resp.ChartOfAccountData[0].ChartOfAccountID)
|
||||
require.Len(t, resp.ItemData, 1)
|
||||
require.Equal(t, "Cleaning supplies", resp.ItemData[0].Item)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/entities"
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ExpenseRepository interface {
|
||||
Create(ctx context.Context, expense *entities.Expense) error
|
||||
GetByID(ctx context.Context, id uuid.UUID) (*entities.Expense, error)
|
||||
GetByIDAndOrganizationID(ctx context.Context, id, organizationID uuid.UUID) (*entities.Expense, error)
|
||||
Update(ctx context.Context, expense *entities.Expense) error
|
||||
Delete(ctx context.Context, id uuid.UUID) error
|
||||
List(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, limit, offset int) ([]*entities.Expense, int64, error)
|
||||
GetAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ExpenseAnalytics, error)
|
||||
CreateItem(ctx context.Context, item *entities.ExpenseItem) error
|
||||
DeleteItemsByExpenseID(ctx context.Context, expenseID uuid.UUID) error
|
||||
}
|
||||
@@ -27,8 +27,11 @@ func NewIngredientProcessor(ingredientRepo IngredientRepository, unitRepo UnitRe
|
||||
}
|
||||
|
||||
func (p *IngredientProcessorImpl) CreateIngredient(ctx context.Context, req *models.CreateIngredientRequest) (*models.IngredientResponse, error) {
|
||||
if _, err := p.unitRepo.GetByID(ctx, req.UnitID, req.OrganizationID); err != nil {
|
||||
return nil, err
|
||||
// The unit is optional, so it is only validated when one is supplied.
|
||||
if req.UnitID != nil {
|
||||
if _, err := p.unitRepo.GetByID(ctx, *req.UnitID, req.OrganizationID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
ingredient := &entities.Ingredient{
|
||||
@@ -107,8 +110,8 @@ func (p *IngredientProcessorImpl) UpdateIngredient(ctx context.Context, id uuid.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if req.UnitID != existing.UnitID {
|
||||
if _, err := p.unitRepo.GetByID(ctx, req.UnitID, organizationID); err != nil {
|
||||
if req.UnitID != nil && (existing.UnitID == nil || *req.UnitID != *existing.UnitID) {
|
||||
if _, err := p.unitRepo.GetByID(ctx, *req.UnitID, organizationID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,15 +266,27 @@ func (p *IngredientUnitConverterProcessorImpl) GetUnitsByIngredientID(ctx contex
|
||||
return nil, fmt.Errorf("failed to get ingredient: %w", err)
|
||||
}
|
||||
|
||||
// Get the base unit details
|
||||
baseUnit, err := p.unitRepo.GetByID(ctx, ingredient.UnitID, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get base unit: %w", err)
|
||||
response := &models.IngredientUnitsResponse{
|
||||
IngredientID: ingredientID,
|
||||
IngredientName: ingredient.Name,
|
||||
}
|
||||
|
||||
// Start with the base unit
|
||||
units := []*models.UnitResponse{
|
||||
mappers.MapUnitEntityToResponse(baseUnit),
|
||||
units := make([]*models.UnitResponse, 0)
|
||||
unitMap := make(map[uuid.UUID]bool)
|
||||
|
||||
// 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
|
||||
@@ -283,10 +295,6 @@ func (p *IngredientUnitConverterProcessorImpl) GetUnitsByIngredientID(ctx contex
|
||||
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 {
|
||||
if converter.IsActive {
|
||||
// Add FromUnit if not already added
|
||||
@@ -309,13 +317,7 @@ func (p *IngredientUnitConverterProcessorImpl) GetUnitsByIngredientID(ctx contex
|
||||
}
|
||||
}
|
||||
|
||||
response := &models.IngredientUnitsResponse{
|
||||
IngredientID: ingredientID,
|
||||
IngredientName: ingredient.Name,
|
||||
BaseUnitID: baseUnit.ID,
|
||||
BaseUnitName: baseUnit.Name,
|
||||
Units: units,
|
||||
}
|
||||
response.Units = units
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -371,8 +371,8 @@ func (p *OrderIngredientTransactionProcessorImpl) CalculateWasteQuantities(ctx c
|
||||
|
||||
// Get unit name
|
||||
unitName := "unit" // default
|
||||
if ingredient.UnitID != uuid.Nil {
|
||||
unit, err := p.unitRepo.GetByID(ctx, ingredient.UnitID, organizationID)
|
||||
if ingredient.UnitID != nil {
|
||||
unit, err := p.unitRepo.GetByID(ctx, *ingredient.UnitID, organizationID)
|
||||
if err == nil {
|
||||
unitName = unit.Name
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/constants"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -87,7 +86,7 @@ type CustomerRepository interface {
|
||||
}
|
||||
|
||||
type InventoryMovementService interface {
|
||||
CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error
|
||||
CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID, purchaseOrderItemID *uuid.UUID) error
|
||||
CreateProductMovement(ctx context.Context, productID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error
|
||||
}
|
||||
|
||||
@@ -339,7 +338,7 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
|
||||
ProductID: itemReq.ProductID,
|
||||
ProductVariantID: itemReq.ProductVariantID,
|
||||
Quantity: itemReq.Quantity,
|
||||
UnitPrice: unitPrice, // Use price from database
|
||||
UnitPrice: unitPrice,
|
||||
TotalPrice: itemTotalPrice,
|
||||
UnitCost: unitCost,
|
||||
TotalCost: itemTotalCost,
|
||||
@@ -388,31 +387,10 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
|
||||
return nil, fmt.Errorf("failed to create order item: %w", err)
|
||||
}
|
||||
|
||||
itemResponse := models.OrderItemResponse{
|
||||
ID: orderItem.ID,
|
||||
OrderID: orderItem.OrderID,
|
||||
ProductID: orderItem.ProductID,
|
||||
ProductVariantID: orderItem.ProductVariantID,
|
||||
Quantity: orderItem.Quantity,
|
||||
UnitPrice: orderItem.UnitPrice,
|
||||
TotalPrice: orderItem.TotalPrice,
|
||||
UnitCost: orderItem.UnitCost,
|
||||
TotalCost: orderItem.TotalCost,
|
||||
RefundAmount: orderItem.RefundAmount,
|
||||
RefundQuantity: orderItem.RefundQuantity,
|
||||
IsPartiallyRefunded: orderItem.IsPartiallyRefunded,
|
||||
IsFullyRefunded: orderItem.IsFullyRefunded,
|
||||
RefundReason: orderItem.RefundReason,
|
||||
RefundedAt: orderItem.RefundedAt,
|
||||
RefundedBy: orderItem.RefundedBy,
|
||||
Modifiers: []map[string]interface{}(orderItem.Modifiers),
|
||||
Notes: orderItem.Notes,
|
||||
Metadata: map[string]interface{}(orderItem.Metadata),
|
||||
Status: constants.OrderItemStatus(orderItem.Status),
|
||||
CreatedAt: orderItem.CreatedAt,
|
||||
UpdatedAt: orderItem.UpdatedAt,
|
||||
itemResponse := mappers.OrderItemEntityToResponse(orderItem, order.OutletID)
|
||||
if itemResponse != nil {
|
||||
addedItemResponses = append(addedItemResponses, *itemResponse)
|
||||
}
|
||||
addedItemResponses = append(addedItemResponses, itemResponse)
|
||||
}
|
||||
|
||||
orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, orderID)
|
||||
@@ -616,6 +594,10 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde
|
||||
return fmt.Errorf("order item does not belong to this order")
|
||||
}
|
||||
|
||||
if orderItem.Status == entities.OrderItemStatusCancelled {
|
||||
return fmt.Errorf("order item %s is already cancelled", orderItemID)
|
||||
}
|
||||
|
||||
if itemVoid.Quantity > orderItem.Quantity {
|
||||
return fmt.Errorf("void quantity cannot exceed original quantity for item %d", itemVoid.OrderItemID)
|
||||
}
|
||||
@@ -636,9 +618,15 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde
|
||||
return fmt.Errorf("outlet not found: %w", err)
|
||||
}
|
||||
|
||||
// Reload order to get latest state
|
||||
order, err = p.orderRepo.GetByID(ctx, req.OrderID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reload order: %w", err)
|
||||
}
|
||||
|
||||
order.Subtotal -= totalVoidedAmount
|
||||
order.TotalCost -= totalVoidedCost
|
||||
order.TaxAmount = order.Subtotal * outlet.TaxRate // Recalculate tax using outlet's tax rate
|
||||
order.TaxAmount = order.Subtotal * outlet.TaxRate
|
||||
order.TotalAmount = order.Subtotal + order.TaxAmount - order.DiscountAmount
|
||||
|
||||
if err := p.orderRepo.Update(ctx, order); err != nil {
|
||||
|
||||
@@ -46,9 +46,10 @@ func (p *ProductOutletPriceProcessorImpl) Upsert(ctx context.Context, req *model
|
||||
}
|
||||
|
||||
entity := &entities.ProductOutletPrice{
|
||||
ProductID: req.ProductID,
|
||||
OutletID: req.OutletID,
|
||||
Price: req.Price,
|
||||
ProductID: req.ProductID,
|
||||
OutletID: req.OutletID,
|
||||
Price: req.Price,
|
||||
PrintToChecker: req.PrintToChecker,
|
||||
}
|
||||
|
||||
if err := p.repo.Upsert(ctx, entity); err != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/mappers"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
@@ -39,6 +40,7 @@ type ProductRepository interface {
|
||||
ExistsBySKU(ctx context.Context, organizationID uuid.UUID, sku string, excludeID *uuid.UUID) (bool, error)
|
||||
GetByName(ctx context.Context, organizationID uuid.UUID, name string) (*entities.Product, error)
|
||||
ExistsByName(ctx context.Context, organizationID uuid.UUID, name string, excludeID *uuid.UUID) (bool, error)
|
||||
ExistsByNameInOutlet(ctx context.Context, organizationID uuid.UUID, outletID uuid.UUID, name string, excludeID *uuid.UUID) (bool, error)
|
||||
UpdateActiveStatus(ctx context.Context, id uuid.UUID, isActive bool) error
|
||||
GetLowCostProducts(ctx context.Context, organizationID uuid.UUID, maxCost float64) ([]*entities.Product, error)
|
||||
}
|
||||
@@ -79,12 +81,12 @@ func (p *ProductProcessorImpl) CreateProduct(ctx context.Context, req *models.Cr
|
||||
}
|
||||
}
|
||||
|
||||
exists, err := p.productRepo.ExistsByName(ctx, req.OrganizationID, req.Name, nil)
|
||||
exists, err := p.productRepo.ExistsByNameInOutlet(ctx, req.OrganizationID, req.OutletID, req.Name, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check product name uniqueness: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return nil, fmt.Errorf("product with name '%s' already exists for this organization", req.Name)
|
||||
return nil, fmt.Errorf("product with name '%s' already exists for this outlet", req.Name)
|
||||
}
|
||||
|
||||
productEntity := mappers.CreateProductRequestToEntity(req)
|
||||
@@ -122,6 +124,23 @@ func (p *ProductProcessorImpl) CreateProduct(ctx context.Context, req *models.Cr
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert outlet-specific price if outlet context is present
|
||||
if req.OutletID != uuid.Nil {
|
||||
printToChecker := true // default
|
||||
if req.PrintToChecker != nil {
|
||||
printToChecker = *req.PrintToChecker
|
||||
}
|
||||
outletPriceEntity := &entities.ProductOutletPrice{
|
||||
ProductID: productEntity.ID,
|
||||
OutletID: req.OutletID,
|
||||
Price: req.Price,
|
||||
PrintToChecker: printToChecker,
|
||||
}
|
||||
if err := p.outletPriceRepo.Upsert(ctx, outletPriceEntity); err != nil {
|
||||
return nil, fmt.Errorf("failed to assign outlet price: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
productWithCategory, err := p.productRepo.GetWithCategory(ctx, productEntity.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve created product: %w", err)
|
||||
@@ -161,12 +180,12 @@ func (p *ProductProcessorImpl) UpdateProduct(ctx context.Context, id uuid.UUID,
|
||||
}
|
||||
|
||||
if req.Name != nil && *req.Name != existingProduct.Name {
|
||||
exists, err := p.productRepo.ExistsByName(ctx, existingProduct.OrganizationID, *req.Name, &id)
|
||||
exists, err := p.productRepo.ExistsByNameInOutlet(ctx, existingProduct.OrganizationID, req.OutletID, *req.Name, &id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check product name uniqueness: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return nil, fmt.Errorf("product with name '%s' already exists for this organization", *req.Name)
|
||||
return nil, fmt.Errorf("product with name '%s' already exists for this outlet", *req.Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +202,41 @@ func (p *ProductProcessorImpl) UpdateProduct(ctx context.Context, id uuid.UUID,
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert outlet-specific price if outlet context is present and price or print_to_checker is provided
|
||||
if req.OutletID != uuid.Nil && (req.Price != nil || req.PrintToChecker != nil) {
|
||||
// Fetch existing outlet price to use as fallback for fields not provided
|
||||
existing, _ := p.outletPriceRepo.GetByProductAndOutlet(ctx, id, req.OutletID)
|
||||
|
||||
price := float64(0)
|
||||
if existing != nil {
|
||||
price = existing.Price
|
||||
}
|
||||
if req.Price != nil {
|
||||
price = *req.Price
|
||||
}
|
||||
|
||||
printToChecker := true // default
|
||||
if existing != nil {
|
||||
printToChecker = existing.PrintToChecker
|
||||
}
|
||||
if req.PrintToChecker != nil {
|
||||
printToChecker = *req.PrintToChecker
|
||||
}
|
||||
|
||||
outletPriceEntity := &entities.ProductOutletPrice{
|
||||
ProductID: id,
|
||||
OutletID: req.OutletID,
|
||||
Price: price,
|
||||
PrintToChecker: printToChecker,
|
||||
}
|
||||
logger.FromContext(ctx).Infof("ProductProcessor::UpdateProduct -> upserting outlet price: productID=%s outletID=%s price=%f printToChecker=%v", id, req.OutletID, price, printToChecker)
|
||||
if err := p.outletPriceRepo.Upsert(ctx, outletPriceEntity); err != nil {
|
||||
return nil, fmt.Errorf("failed to assign outlet price: %w", err)
|
||||
}
|
||||
} else {
|
||||
logger.FromContext(ctx).Infof("ProductProcessor::UpdateProduct -> skipping outlet price upsert: outletID=%s price=%v printToChecker=%v", req.OutletID, req.Price, req.PrintToChecker)
|
||||
}
|
||||
|
||||
productWithCategory, err := p.productRepo.GetWithCategory(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve updated product: %w", err)
|
||||
@@ -231,6 +285,7 @@ func (p *ProductProcessorImpl) GetProductByID(ctx context.Context, id uuid.UUID,
|
||||
outletPrice, err := p.outletPriceRepo.GetByProductAndOutlet(ctx, id, outletID)
|
||||
if err == nil {
|
||||
response.OutletPrice = &outletPrice.Price
|
||||
response.PrintToChecker = outletPrice.PrintToChecker
|
||||
}
|
||||
} else {
|
||||
// No outlet context — return all outlet prices for this product
|
||||
@@ -239,9 +294,10 @@ func (p *ProductProcessorImpl) GetProductByID(ctx context.Context, id uuid.UUID,
|
||||
prices := make([]models.OutletPrice, len(outletPrices))
|
||||
for i, op := range outletPrices {
|
||||
prices[i] = models.OutletPrice{
|
||||
OutletID: op.OutletID,
|
||||
OutletName: op.Outlet.Name,
|
||||
Price: op.Price,
|
||||
OutletID: op.OutletID,
|
||||
OutletName: op.Outlet.Name,
|
||||
Price: op.Price,
|
||||
PrintToChecker: op.PrintToChecker,
|
||||
}
|
||||
}
|
||||
response.OutletPrices = prices
|
||||
@@ -278,10 +334,35 @@ func (p *ProductProcessorImpl) ListProducts(ctx context.Context, filters map[str
|
||||
}
|
||||
|
||||
responses := make([]models.ProductResponse, len(productEntities))
|
||||
for i, entity := range productEntities {
|
||||
response := mappers.ProductEntityToResponse(entity)
|
||||
if response != nil {
|
||||
responses[i] = *response
|
||||
if outletID != uuid.Nil && len(productEntities) > 0 {
|
||||
// Bulk-fetch outlet prices to populate OutletPrice and PrintToChecker per product
|
||||
productIDs := make([]uuid.UUID, len(productEntities))
|
||||
for i, e := range productEntities {
|
||||
productIDs[i] = e.ID
|
||||
}
|
||||
outletPrices, opErr := p.outletPriceRepo.GetByProductsAndOutlet(ctx, productIDs, outletID)
|
||||
priceMap := make(map[uuid.UUID]*entities.ProductOutletPrice)
|
||||
if opErr == nil {
|
||||
for _, op := range outletPrices {
|
||||
priceMap[op.ProductID] = op
|
||||
}
|
||||
}
|
||||
for i, entity := range productEntities {
|
||||
response := mappers.ProductEntityToResponse(entity)
|
||||
if response != nil {
|
||||
if op, ok := priceMap[entity.ID]; ok {
|
||||
response.OutletPrice = &op.Price
|
||||
response.PrintToChecker = op.PrintToChecker
|
||||
}
|
||||
responses[i] = *response
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for i, entity := range productEntities {
|
||||
response := mappers.ProductEntityToResponse(entity)
|
||||
if response != nil {
|
||||
responses[i] = *response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -235,7 +235,7 @@ func (p *ProductRecipeProcessorImpl) entityToResponse(entity *entities.ProductRe
|
||||
CreatedAt: entity.Ingredient.CreatedAt,
|
||||
UpdatedAt: entity.Ingredient.UpdatedAt,
|
||||
}
|
||||
|
||||
|
||||
// Add unit if available
|
||||
if entity.Ingredient.Unit != nil {
|
||||
symbol := ""
|
||||
@@ -253,4 +253,4 @@ func (p *ProductRecipeProcessorImpl) entityToResponse(entity *entities.ProductRe
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/mappers"
|
||||
"apskel-pos-be/internal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PurchaseCategoryProcessor interface {
|
||||
CreatePurchaseCategory(ctx context.Context, req *models.CreatePurchaseCategoryRequest) (*models.PurchaseCategoryResponse, error)
|
||||
UpdatePurchaseCategory(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseCategoryRequest) (*models.PurchaseCategoryResponse, error)
|
||||
DeletePurchaseCategory(ctx context.Context, id, organizationID uuid.UUID) error
|
||||
GetPurchaseCategoryByID(ctx context.Context, id, organizationID uuid.UUID) (*models.PurchaseCategoryResponse, error)
|
||||
ListPurchaseCategories(ctx context.Context, req *models.ListPurchaseCategoriesRequest) ([]models.PurchaseCategoryResponse, int, error)
|
||||
}
|
||||
|
||||
type PurchaseCategoryRepository interface {
|
||||
Create(ctx context.Context, category *entities.PurchaseCategory) error
|
||||
GetByIDAndOrganizationID(ctx context.Context, id, organizationID uuid.UUID) (*entities.PurchaseCategory, error)
|
||||
Update(ctx context.Context, category *entities.PurchaseCategory) error
|
||||
SoftDelete(ctx context.Context, id, organizationID uuid.UUID) error
|
||||
List(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, limit, offset int) ([]*entities.PurchaseCategory, int64, error)
|
||||
ExistsByCode(ctx context.Context, organizationID uuid.UUID, code string, excludeID *uuid.UUID) (bool, error)
|
||||
}
|
||||
|
||||
type PurchaseCategoryProcessorImpl struct {
|
||||
purchaseCategoryRepo PurchaseCategoryRepository
|
||||
}
|
||||
|
||||
func NewPurchaseCategoryProcessorImpl(purchaseCategoryRepo PurchaseCategoryRepository) *PurchaseCategoryProcessorImpl {
|
||||
return &PurchaseCategoryProcessorImpl{purchaseCategoryRepo: purchaseCategoryRepo}
|
||||
}
|
||||
|
||||
func (p *PurchaseCategoryProcessorImpl) CreatePurchaseCategory(ctx context.Context, req *models.CreatePurchaseCategoryRequest) (*models.PurchaseCategoryResponse, error) {
|
||||
code := ""
|
||||
if req.Code != nil && strings.TrimSpace(*req.Code) != "" {
|
||||
code = normalizePurchaseCategoryCode(*req.Code)
|
||||
} else {
|
||||
code = normalizePurchaseCategoryCode(req.Name)
|
||||
}
|
||||
|
||||
if code == "" {
|
||||
return nil, fmt.Errorf("purchase category code cannot be empty")
|
||||
}
|
||||
|
||||
exists, err := p.purchaseCategoryRepo.ExistsByCode(ctx, req.OrganizationID, code, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check purchase category code uniqueness: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return nil, fmt.Errorf("purchase category with code '%s' already exists", code)
|
||||
}
|
||||
|
||||
if req.ParentID != nil {
|
||||
parent, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, *req.ParentID, req.OrganizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parent purchase category not found: %w", err)
|
||||
}
|
||||
if string(parent.Type) != req.Type {
|
||||
return nil, fmt.Errorf("parent purchase category type must match child type")
|
||||
}
|
||||
}
|
||||
|
||||
category := mappers.CreatePurchaseCategoryRequestToEntity(req)
|
||||
category.Code = code
|
||||
|
||||
if err := p.purchaseCategoryRepo.Create(ctx, category); err != nil {
|
||||
return nil, fmt.Errorf("failed to create purchase category: %w", err)
|
||||
}
|
||||
|
||||
return mappers.PurchaseCategoryEntityToResponse(category), nil
|
||||
}
|
||||
|
||||
func (p *PurchaseCategoryProcessorImpl) UpdatePurchaseCategory(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseCategoryRequest) (*models.PurchaseCategoryResponse, error) {
|
||||
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("purchase category not found: %w", err)
|
||||
}
|
||||
|
||||
newType := string(category.Type)
|
||||
if req.Type != nil {
|
||||
newType = *req.Type
|
||||
}
|
||||
|
||||
if req.ParentID != nil {
|
||||
if *req.ParentID == id {
|
||||
return nil, fmt.Errorf("purchase category cannot be its own parent")
|
||||
}
|
||||
|
||||
parent, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, *req.ParentID, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parent purchase category not found: %w", err)
|
||||
}
|
||||
if string(parent.Type) != newType {
|
||||
return nil, fmt.Errorf("parent purchase category type must match child type")
|
||||
}
|
||||
category.ParentID = req.ParentID
|
||||
}
|
||||
|
||||
if req.Code != nil {
|
||||
code := normalizePurchaseCategoryCode(*req.Code)
|
||||
if code == "" {
|
||||
return nil, fmt.Errorf("purchase category code cannot be empty")
|
||||
}
|
||||
|
||||
if code != category.Code {
|
||||
exists, err := p.purchaseCategoryRepo.ExistsByCode(ctx, organizationID, code, &id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check purchase category code uniqueness: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return nil, fmt.Errorf("purchase category with code '%s' already exists", code)
|
||||
}
|
||||
category.Code = code
|
||||
}
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
category.Name = strings.TrimSpace(*req.Name)
|
||||
}
|
||||
if req.Type != nil {
|
||||
category.Type = entities.PurchaseCategoryType(*req.Type)
|
||||
}
|
||||
if req.SortOrder != nil {
|
||||
category.SortOrder = *req.SortOrder
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
category.IsActive = *req.IsActive
|
||||
}
|
||||
|
||||
if err := p.purchaseCategoryRepo.Update(ctx, category); err != nil {
|
||||
return nil, fmt.Errorf("failed to update purchase category: %w", err)
|
||||
}
|
||||
|
||||
return mappers.PurchaseCategoryEntityToResponse(category), nil
|
||||
}
|
||||
|
||||
func (p *PurchaseCategoryProcessorImpl) DeletePurchaseCategory(ctx context.Context, id, organizationID uuid.UUID) error {
|
||||
_, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("purchase category not found: %w", err)
|
||||
}
|
||||
|
||||
if err := p.purchaseCategoryRepo.SoftDelete(ctx, id, organizationID); err != nil {
|
||||
return fmt.Errorf("failed to delete purchase category: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PurchaseCategoryProcessorImpl) GetPurchaseCategoryByID(ctx context.Context, id, organizationID uuid.UUID) (*models.PurchaseCategoryResponse, error) {
|
||||
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("purchase category not found: %w", err)
|
||||
}
|
||||
|
||||
return mappers.PurchaseCategoryEntityToResponse(category), nil
|
||||
}
|
||||
|
||||
func (p *PurchaseCategoryProcessorImpl) ListPurchaseCategories(ctx context.Context, req *models.ListPurchaseCategoriesRequest) ([]models.PurchaseCategoryResponse, int, error) {
|
||||
filters := make(map[string]interface{})
|
||||
if req.ParentID != nil {
|
||||
filters["parent_id"] = *req.ParentID
|
||||
}
|
||||
if req.Type != "" {
|
||||
filters["type"] = req.Type
|
||||
}
|
||||
if req.Search != "" {
|
||||
filters["search"] = req.Search
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
filters["is_active"] = *req.IsActive
|
||||
}
|
||||
|
||||
offset := (req.Page - 1) * req.Limit
|
||||
categories, total, err := p.purchaseCategoryRepo.List(ctx, req.OrganizationID, filters, req.Limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list purchase categories: %w", err)
|
||||
}
|
||||
|
||||
return mappers.PurchaseCategoryEntitiesToResponses(categories), int(total), nil
|
||||
}
|
||||
|
||||
func normalizePurchaseCategoryCode(value string) string {
|
||||
value = strings.TrimSpace(strings.ToLower(value))
|
||||
var builder strings.Builder
|
||||
lastUnderscore := false
|
||||
|
||||
for _, r := range value {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
builder.WriteRune(r)
|
||||
lastUnderscore = false
|
||||
continue
|
||||
}
|
||||
|
||||
if !lastUnderscore {
|
||||
builder.WriteRune('_')
|
||||
lastUnderscore = true
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Trim(builder.String(), "_")
|
||||
}
|
||||
@@ -1,32 +1,40 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/mappers"
|
||||
"apskel-pos-be/internal/models"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PurchaseOrderProcessor interface {
|
||||
CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error)
|
||||
UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error)
|
||||
CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error)
|
||||
UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, outletID *uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error)
|
||||
DeletePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID) error
|
||||
GetPurchaseOrderByID(ctx context.Context, id, organizationID uuid.UUID) (*models.PurchaseOrderResponse, error)
|
||||
ListPurchaseOrders(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.PurchaseOrderResponse, int, error)
|
||||
GetPurchaseOrdersByStatus(ctx context.Context, organizationID uuid.UUID, status string) ([]*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)
|
||||
ListPurchaseTeams(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error)
|
||||
}
|
||||
|
||||
type PurchaseOrderProcessorImpl struct {
|
||||
purchaseOrderRepo PurchaseOrderRepository
|
||||
vendorRepo VendorRepository
|
||||
ingredientRepo IngredientRepository
|
||||
purchaseCategoryRepo PurchaseCategoryRepository
|
||||
categoryRepo CategoryRepository
|
||||
unitRepo UnitRepository
|
||||
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
|
||||
unitConverterRepo IngredientUnitConverterRepository
|
||||
}
|
||||
@@ -35,6 +43,8 @@ func NewPurchaseOrderProcessorImpl(
|
||||
purchaseOrderRepo PurchaseOrderRepository,
|
||||
vendorRepo VendorRepository,
|
||||
ingredientRepo IngredientRepository,
|
||||
purchaseCategoryRepo PurchaseCategoryRepository,
|
||||
categoryRepo CategoryRepository,
|
||||
unitRepo UnitRepository,
|
||||
fileRepo FileRepository,
|
||||
inventoryMovementService InventoryMovementService,
|
||||
@@ -44,6 +54,8 @@ func NewPurchaseOrderProcessorImpl(
|
||||
purchaseOrderRepo: purchaseOrderRepo,
|
||||
vendorRepo: vendorRepo,
|
||||
ingredientRepo: ingredientRepo,
|
||||
purchaseCategoryRepo: purchaseCategoryRepo,
|
||||
categoryRepo: categoryRepo,
|
||||
unitRepo: unitRepo,
|
||||
fileRepo: fileRepo,
|
||||
inventoryMovementService: inventoryMovementService,
|
||||
@@ -51,11 +63,18 @@ func NewPurchaseOrderProcessorImpl(
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
|
||||
// Check if vendor exists and belongs to organization
|
||||
_, err := p.vendorRepo.GetByIDAndOrganizationID(ctx, req.VendorID, organizationID)
|
||||
func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
|
||||
// Check if vendor exists and belongs to organization when provided.
|
||||
if req.VendorID != nil {
|
||||
_, err := p.vendorRepo.GetByIDAndOrganizationID(ctx, *req.VendorID, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vendor not found: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
teamScope, teamCategoryID, err := p.resolvePurchaseTeam(ctx, organizationID, outletID, req.TeamScope, req.TeamCategoryID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vendor not found: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if PO number already exists in organization
|
||||
@@ -64,28 +83,53 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
|
||||
return nil, fmt.Errorf("purchase order with PO number %s already exists in this organization", req.PONumber)
|
||||
}
|
||||
|
||||
// Validate ingredients and units exist
|
||||
// Validate categories and inventory fields per item type.
|
||||
for i, item := range req.Items {
|
||||
_, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID)
|
||||
category, err := p.validatePurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ingredient not found for item %d: %w", i, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = p.unitRepo.GetByID(ctx, item.UnitID, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unit not found for item %d: %w", i, err)
|
||||
switch category.Type {
|
||||
case entities.PurchaseCategoryTypeRawMaterial:
|
||||
if item.IngredientID == nil {
|
||||
return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i)
|
||||
}
|
||||
if item.Quantity == nil {
|
||||
return nil, fmt.Errorf("quantity is required for raw_material item %d", i)
|
||||
}
|
||||
if item.UnitID == nil {
|
||||
return nil, fmt.Errorf("unit_id is required for raw_material item %d", i)
|
||||
}
|
||||
|
||||
_, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ingredient not found for item %d: %w", i, err)
|
||||
}
|
||||
|
||||
_, err = p.unitRepo.GetByID(ctx, *item.UnitID, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unit not found for item %d: %w", i, err)
|
||||
}
|
||||
case entities.PurchaseCategoryTypeExpense:
|
||||
if item.IngredientID != nil || item.Quantity != nil || item.UnitID != nil {
|
||||
return nil, fmt.Errorf("ingredient_id, quantity, and unit_id must be empty for expense item %d", i)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("purchase category for item %d has unsupported type %s", i, category.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total amount
|
||||
totalAmount := 0.0
|
||||
for _, item := range req.Items {
|
||||
totalAmount += item.Amount
|
||||
totalAmount += calculatePurchaseOrderItemTotal(item.Quantity, item.Amount)
|
||||
}
|
||||
|
||||
// Create purchase order entity
|
||||
poEntity := &entities.PurchaseOrder{
|
||||
OrganizationID: organizationID,
|
||||
OutletID: outletID,
|
||||
VendorID: req.VendorID,
|
||||
PONumber: req.PONumber,
|
||||
TransactionDate: req.TransactionDate,
|
||||
@@ -94,6 +138,8 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
|
||||
Status: "draft", // Default status
|
||||
Message: req.Message,
|
||||
TotalAmount: totalAmount,
|
||||
TeamScope: teamScope,
|
||||
TeamCategoryID: teamCategoryID,
|
||||
}
|
||||
|
||||
if req.Status != nil {
|
||||
@@ -109,12 +155,13 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
|
||||
// Create purchase order items
|
||||
for _, itemReq := range req.Items {
|
||||
itemEntity := &entities.PurchaseOrderItem{
|
||||
PurchaseOrderID: poEntity.ID,
|
||||
IngredientID: itemReq.IngredientID,
|
||||
Description: itemReq.Description,
|
||||
Quantity: itemReq.Quantity,
|
||||
UnitID: itemReq.UnitID,
|
||||
Amount: itemReq.Amount,
|
||||
PurchaseOrderID: poEntity.ID,
|
||||
IngredientID: itemReq.IngredientID,
|
||||
PurchaseCategoryID: itemReq.PurchaseCategoryID,
|
||||
Description: itemReq.Description,
|
||||
Quantity: itemReq.Quantity,
|
||||
UnitID: itemReq.UnitID,
|
||||
Amount: itemReq.Amount,
|
||||
}
|
||||
|
||||
err = p.purchaseOrderRepo.CreateItem(ctx, itemEntity)
|
||||
@@ -145,12 +192,15 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
|
||||
return mappers.PurchaseOrderEntityToResponse(createdPO), nil
|
||||
}
|
||||
|
||||
func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
|
||||
func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, outletID *uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
|
||||
// Get existing purchase order
|
||||
poEntity, err := p.purchaseOrderRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("purchase order not found: %w", err)
|
||||
}
|
||||
if poEntity.OutletID == nil && outletID != nil {
|
||||
poEntity.OutletID = outletID
|
||||
}
|
||||
|
||||
// Check if vendor exists and belongs to organization (if vendor is being updated)
|
||||
if req.VendorID != nil {
|
||||
@@ -158,7 +208,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vendor not found: %w", err)
|
||||
}
|
||||
poEntity.VendorID = *req.VendorID
|
||||
poEntity.VendorID = req.VendorID
|
||||
}
|
||||
|
||||
// Check if PO number already exists (if PO number is being updated)
|
||||
@@ -175,7 +225,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
|
||||
poEntity.TransactionDate = *req.TransactionDate
|
||||
}
|
||||
if req.DueDate != nil {
|
||||
poEntity.DueDate = *req.DueDate
|
||||
poEntity.DueDate = req.DueDate
|
||||
}
|
||||
if req.Reference != nil {
|
||||
poEntity.Reference = req.Reference
|
||||
@@ -187,70 +237,92 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
|
||||
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
|
||||
if req.Items != nil {
|
||||
// Delete existing items
|
||||
totalAmount := 0.0
|
||||
items := make([]*entities.PurchaseOrderItem, len(req.Items))
|
||||
for i, itemReq := range req.Items {
|
||||
if itemReq.PurchaseCategoryID == nil {
|
||||
return nil, fmt.Errorf("purchase_category_id is required for item %d", i)
|
||||
}
|
||||
|
||||
ingredientID := itemReq.IngredientID
|
||||
purchaseCategoryID := *itemReq.PurchaseCategoryID
|
||||
unitID := itemReq.UnitID
|
||||
quantity := itemReq.Quantity
|
||||
amount := 0.0
|
||||
if itemReq.Amount != nil {
|
||||
amount = *itemReq.Amount
|
||||
}
|
||||
description := itemReq.Description
|
||||
|
||||
category, err := p.validatePurchaseCategory(ctx, purchaseCategoryID, organizationID, i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch category.Type {
|
||||
case entities.PurchaseCategoryTypeRawMaterial:
|
||||
if ingredientID == nil {
|
||||
return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i)
|
||||
}
|
||||
if quantity == nil {
|
||||
return nil, fmt.Errorf("quantity is required for raw_material item %d", i)
|
||||
}
|
||||
if unitID == nil {
|
||||
return nil, fmt.Errorf("unit_id is required for raw_material item %d", i)
|
||||
}
|
||||
|
||||
_, err := p.ingredientRepo.GetByID(ctx, *ingredientID, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ingredient not found: %w", err)
|
||||
}
|
||||
|
||||
_, err = p.unitRepo.GetByID(ctx, *unitID, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unit not found: %w", err)
|
||||
}
|
||||
case entities.PurchaseCategoryTypeExpense:
|
||||
if ingredientID != nil || quantity != nil || unitID != nil {
|
||||
return nil, fmt.Errorf("ingredient_id, quantity, and unit_id must be empty for expense item %d", i)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("purchase category for item %d has unsupported type %s", i, category.Type)
|
||||
}
|
||||
|
||||
items[i] = &entities.PurchaseOrderItem{
|
||||
PurchaseOrderID: poEntity.ID,
|
||||
IngredientID: ingredientID,
|
||||
PurchaseCategoryID: purchaseCategoryID,
|
||||
Description: description,
|
||||
Quantity: quantity,
|
||||
UnitID: unitID,
|
||||
Amount: amount,
|
||||
}
|
||||
totalAmount += calculatePurchaseOrderItemTotal(quantity, amount)
|
||||
}
|
||||
|
||||
// Delete and recreate only after all replacement items are valid.
|
||||
err = p.purchaseOrderRepo.DeleteItemsByPurchaseOrderID(ctx, poEntity.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to delete existing items: %w", err)
|
||||
}
|
||||
|
||||
// Create new items
|
||||
totalAmount := 0.0
|
||||
for _, itemReq := range req.Items {
|
||||
// Validate ingredients and units exist
|
||||
if itemReq.IngredientID != nil {
|
||||
_, err := p.ingredientRepo.GetByID(ctx, *itemReq.IngredientID, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ingredient not found: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if itemReq.UnitID != nil {
|
||||
_, err := p.unitRepo.GetByID(ctx, *itemReq.UnitID, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unit not found: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Use existing values if not provided
|
||||
ingredientID := poEntity.Items[0].IngredientID // This is a simplified approach
|
||||
unitID := poEntity.Items[0].UnitID
|
||||
quantity := poEntity.Items[0].Quantity
|
||||
amount := poEntity.Items[0].Amount
|
||||
description := poEntity.Items[0].Description
|
||||
|
||||
if itemReq.IngredientID != nil {
|
||||
ingredientID = *itemReq.IngredientID
|
||||
}
|
||||
if itemReq.UnitID != nil {
|
||||
unitID = *itemReq.UnitID
|
||||
}
|
||||
if itemReq.Quantity != nil {
|
||||
quantity = *itemReq.Quantity
|
||||
}
|
||||
if itemReq.Amount != nil {
|
||||
amount = *itemReq.Amount
|
||||
}
|
||||
if itemReq.Description != nil {
|
||||
description = itemReq.Description
|
||||
}
|
||||
|
||||
itemEntity := &entities.PurchaseOrderItem{
|
||||
PurchaseOrderID: poEntity.ID,
|
||||
IngredientID: ingredientID,
|
||||
Description: description,
|
||||
Quantity: quantity,
|
||||
UnitID: unitID,
|
||||
Amount: amount,
|
||||
}
|
||||
|
||||
for _, itemEntity := range items {
|
||||
err = p.purchaseOrderRepo.CreateItem(ctx, itemEntity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create purchase order item: %w", err)
|
||||
}
|
||||
|
||||
totalAmount += amount
|
||||
}
|
||||
|
||||
poEntity.TotalAmount = totalAmount
|
||||
@@ -369,65 +441,19 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
|
||||
|
||||
fmt.Println("status:", po.Status)
|
||||
|
||||
// Check if status is changing to "received" and current status is not "received"
|
||||
if status == "received" && po.Status != "received" {
|
||||
// Get purchase order with items for inventory update
|
||||
poWithItems, err := p.purchaseOrderRepo.GetByID(ctx, id)
|
||||
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 {
|
||||
// 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 = 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,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create inventory movement for ingredient %s: %w", item.IngredientID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A purchase order is a record of spending only. Receiving one does not move
|
||||
// ingredient stock, does not recalculate ingredient cost, and never converts
|
||||
// units: the quantity and unit on an item are kept exactly as the user
|
||||
// entered them. Raw material items are therefore treated the same way expense
|
||||
// items already were, and the ingredient on an item is just a reference.
|
||||
|
||||
// Update the purchase order status
|
||||
err = p.purchaseOrderRepo.UpdateStatus(ctx, id, status)
|
||||
statusOutletID := po.OutletID
|
||||
if statusOutletID == nil && outletID != uuid.Nil {
|
||||
statusOutletID = &outletID
|
||||
}
|
||||
|
||||
err = p.purchaseOrderRepo.UpdateStatusAndOutlet(ctx, id, status, statusOutletID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update purchase order status: %w", err)
|
||||
}
|
||||
@@ -440,3 +466,101 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
|
||||
|
||||
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) {
|
||||
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("purchase category not found for item %d: %w", itemIndex, err)
|
||||
}
|
||||
|
||||
if !category.IsActive {
|
||||
return nil, fmt.Errorf("purchase category for item %d is inactive", itemIndex)
|
||||
}
|
||||
|
||||
if category.Type != entities.PurchaseCategoryTypeRawMaterial && category.Type != entities.PurchaseCategoryTypeExpense {
|
||||
return nil, fmt.Errorf("purchase category for item %d must be raw_material or expense", itemIndex)
|
||||
}
|
||||
|
||||
return category, nil
|
||||
}
|
||||
|
||||
func calculatePurchaseOrderItemTotal(quantity *float64, amount float64) float64 {
|
||||
if quantity == nil {
|
||||
return amount
|
||||
}
|
||||
|
||||
return *quantity * amount
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type PurchaseOrderRepository interface {
|
||||
GetByStatus(ctx context.Context, organizationID uuid.UUID, status string) ([]*entities.PurchaseOrder, error)
|
||||
GetOverdue(ctx context.Context, organizationID uuid.UUID) ([]*entities.PurchaseOrder, error)
|
||||
UpdateStatus(ctx context.Context, id uuid.UUID, status string) error
|
||||
UpdateStatusAndOutlet(ctx context.Context, id uuid.UUID, status string, outletID *uuid.UUID) error
|
||||
UpdateTotalAmount(ctx context.Context, id uuid.UUID, totalAmount float64) error
|
||||
CreateItem(ctx context.Context, item *entities.PurchaseOrderItem) error
|
||||
UpdateItem(ctx context.Context, item *entities.PurchaseOrderItem) error
|
||||
|
||||
@@ -22,8 +22,6 @@ const (
|
||||
MetadataKeyLastSplitQuantities = "last_split_quantities"
|
||||
)
|
||||
|
||||
|
||||
|
||||
type SplitBillValidation struct {
|
||||
OrderItems map[uuid.UUID]*entities.OrderItem
|
||||
PaidQuantities map[uuid.UUID]int
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user