Compare commits
45
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 |
@@ -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"
|
||||
@@ -114,7 +130,11 @@ fmt:
|
||||
@go fmt ./...
|
||||
|
||||
start:
|
||||
go run main.go --env-path .env
|
||||
ENV_MODE=$(ENV) go run cmd/server/main.go
|
||||
|
||||
.SILENT: run
|
||||
run:
|
||||
ENV_MODE=$(ENV) go run cmd/server/main.go
|
||||
|
||||
# Default
|
||||
|
||||
|
||||
+2
-1
@@ -12,13 +12,14 @@ import (
|
||||
const (
|
||||
YAML_PATH = "infra/%s"
|
||||
ENV_MODE = "ENV_MODE"
|
||||
DEFAULT_ENV_MODE = "development"
|
||||
DEFAULT_ENV_MODE = "staging"
|
||||
)
|
||||
|
||||
var (
|
||||
validEnvMode = map[string]struct{}{
|
||||
"local": {},
|
||||
"development": {},
|
||||
"staging": {},
|
||||
"production": {},
|
||||
}
|
||||
)
|
||||
|
||||
+46
-8
@@ -2,23 +2,61 @@
|
||||
set -euo pipefail
|
||||
|
||||
APP_NAME="apskel-pos"
|
||||
PORT="4000"
|
||||
|
||||
# ─── Deteksi environment dari branch aktif ───────────────────────────────────
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
case "$CURRENT_BRANCH" in
|
||||
main)
|
||||
ENV_MODE="production"
|
||||
PORT="4000"
|
||||
;;
|
||||
staging)
|
||||
ENV_MODE="staging"
|
||||
PORT="4001"
|
||||
;;
|
||||
*)
|
||||
echo "❌ Branch '$CURRENT_BRANCH' tidak dikenali untuk deployment."
|
||||
echo " Gunakan branch 'main' (production) atau 'staging' (staging)."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
CONTAINER_NAME="$APP_NAME"
|
||||
IMAGE_NAME="$APP_NAME:$ENV_MODE"
|
||||
|
||||
echo "📦 Environment : $ENV_MODE"
|
||||
echo "🌿 Branch : $CURRENT_BRANCH"
|
||||
echo "🐳 Container : $CONTAINER_NAME"
|
||||
echo "🔌 Port : $PORT"
|
||||
echo ""
|
||||
|
||||
# ─── Pastikan config file ada ─────────────────────────────────────────────────
|
||||
CONFIG_FILE="infra/$ENV_MODE.yaml"
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo "❌ Config file '$CONFIG_FILE' tidak ditemukan."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🔄 Pulling latest code..."
|
||||
git pull
|
||||
|
||||
echo "🐳 Building Docker image (production target)..."
|
||||
docker build --target production -t $APP_NAME:latest .
|
||||
echo "🐳 Building Docker image ($ENV_MODE)..."
|
||||
docker build --target production -t "$IMAGE_NAME" .
|
||||
|
||||
echo "🛑 Stopping and removing old container..."
|
||||
docker rm -f $APP_NAME 2>/dev/null || true
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
|
||||
echo "🚀 Running new container..."
|
||||
docker run -d --name $APP_NAME \
|
||||
-p $PORT:$PORT \
|
||||
docker run -d --name "$CONTAINER_NAME" \
|
||||
-p "$PORT:4000" \
|
||||
-e TZ=Asia/Jakarta \
|
||||
-e ENV_MODE="$ENV_MODE" \
|
||||
-v "$(pwd)/infra":/infra:ro \
|
||||
-v "$(pwd)/templates":/templates:ro \
|
||||
$APP_NAME:latest
|
||||
"$IMAGE_NAME"
|
||||
|
||||
echo "✅ Deployment complete."
|
||||
echo ""
|
||||
echo "✅ Deployment $ENV_MODE complete."
|
||||
echo " Container : $CONTAINER_NAME"
|
||||
echo " Port : $PORT"
|
||||
|
||||
@@ -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"
|
||||
+1
-1
@@ -372,7 +372,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo),
|
||||
productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo),
|
||||
vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo),
|
||||
purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.purchaseCategoryRepo, 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),
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +108,21 @@ 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 {
|
||||
@@ -115,6 +136,7 @@ type PurchasingSummary struct {
|
||||
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 {
|
||||
@@ -140,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
|
||||
@@ -161,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"`
|
||||
@@ -198,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"`
|
||||
@@ -215,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
|
||||
@@ -227,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"`
|
||||
@@ -237,12 +391,15 @@ type DashboardAnalyticsResponse struct {
|
||||
|
||||
// DashboardOverview represents the overview data for dashboard
|
||||
type DashboardOverview struct {
|
||||
TotalSales float64 `json:"total_sales"`
|
||||
TotalOrders int64 `json:"total_orders"`
|
||||
AverageOrderValue float64 `json:"average_order_value"`
|
||||
TotalCustomers int64 `json:"total_customers"`
|
||||
VoidedOrders int64 `json:"voided_orders"`
|
||||
RefundedOrders int64 `json:"refunded_orders"`
|
||||
TotalSales float64 `json:"total_sales"`
|
||||
TotalOrders int64 `json:"total_orders"`
|
||||
AverageOrderValue float64 `json:"average_order_value"`
|
||||
TotalCustomers int64 `json:"total_customers"`
|
||||
VoidedOrders int64 `json:"voided_orders"`
|
||||
RefundedOrders int64 `json:"refunded_orders"`
|
||||
TotalItemSold int64 `json:"total_item_sold"`
|
||||
TotalLowStock int64 `json:"total_low_stock"`
|
||||
TotalProductActive int64 `json:"total_product_active"`
|
||||
}
|
||||
|
||||
type ProfitLossAnalyticsRequest struct {
|
||||
@@ -256,6 +413,7 @@ type ProfitLossAnalyticsRequest struct {
|
||||
type ProfitLossAnalyticsResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
GroupBy string `json:"group_by"`
|
||||
@@ -263,10 +421,28 @@ type ProfitLossAnalyticsResponse struct {
|
||||
Data []ProfitLossData `json:"data"`
|
||||
ProductData []ProductProfitData `json:"product_data"`
|
||||
MainSummary []ProfitLossSummaryRow `json:"main_summary"`
|
||||
Purchasing ProfitLossPurchasing `json:"purchasing"`
|
||||
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
|
||||
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
||||
}
|
||||
|
||||
type ProfitLossPurchasing struct {
|
||||
TodayTotal float64 `json:"today_total"`
|
||||
MtdTotal float64 `json:"mtd_total"`
|
||||
TodayRawMaterial float64 `json:"today_raw_material"`
|
||||
MtdRawMaterial float64 `json:"mtd_raw_material"`
|
||||
TodayExpense float64 `json:"today_expense"`
|
||||
MtdExpense float64 `json:"mtd_expense"`
|
||||
Items []ProfitLossPurchasingItem `json:"items"`
|
||||
}
|
||||
|
||||
type ProfitLossPurchasingItem struct {
|
||||
Date time.Time `json:"date"`
|
||||
Item string `json:"item"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Nominal float64 `json:"nominal"`
|
||||
}
|
||||
|
||||
type ProfitLossSummary struct {
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
@@ -324,3 +500,123 @@ 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"`
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ type CreateCategoryRequest struct {
|
||||
Description *string `json:"description,omitempty"`
|
||||
BusinessType *string `json:"business_type,omitempty"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
Order *int `json:"order,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
@@ -20,6 +21,7 @@ type UpdateCategoryRequest struct {
|
||||
Description *string `json:"description,omitempty"`
|
||||
BusinessType *string `json:"business_type,omitempty"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
Order *int `json:"order,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
@@ -27,6 +29,8 @@ type UpdateCategoryRequest struct {
|
||||
type ListCategoriesRequest struct {
|
||||
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
Type string `json:"type,omitempty" validate:"omitempty,oneof=parent child"`
|
||||
BusinessType string `json:"business_type,omitempty"`
|
||||
Search string `json:"search,omitempty"`
|
||||
Page int `json:"page" validate:"required,min=1"`
|
||||
@@ -38,6 +42,8 @@ type CategoryResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
ParentName *string `json:"parent_name,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
BusinessType string `json:"business_type"`
|
||||
|
||||
@@ -77,7 +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"`
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ type ProductRecipeIngredientResponse 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"`
|
||||
|
||||
@@ -7,13 +7,15 @@ 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,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"`
|
||||
}
|
||||
@@ -28,13 +30,16 @@ type CreatePurchaseOrderItemRequest struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
@@ -52,7 +57,8 @@ type UpdatePurchaseOrderItemRequest 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"`
|
||||
@@ -60,13 +66,29 @@ type PurchaseOrderResponse struct {
|
||||
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"`
|
||||
@@ -92,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"`
|
||||
|
||||
+130
-16
@@ -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,6 +42,22 @@ 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 {
|
||||
@@ -47,6 +71,7 @@ type PurchasingSummary struct {
|
||||
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 {
|
||||
@@ -72,12 +97,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"`
|
||||
}
|
||||
|
||||
type ProductAnalytics struct {
|
||||
@@ -112,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"`
|
||||
@@ -120,19 +178,36 @@ type DashboardOverview struct {
|
||||
TotalCustomers int64 `json:"total_customers"`
|
||||
VoidedOrders int64 `json:"voided_orders"`
|
||||
RefundedOrders int64 `json:"refunded_orders"`
|
||||
TotalItemSold int64 `json:"total_item_sold"`
|
||||
TotalLowStock int64 `json:"total_low_stock"`
|
||||
TotalProductActive int64 `json:"total_product_active"`
|
||||
}
|
||||
|
||||
type ProfitLossAnalytics struct {
|
||||
Summary ProfitLossSummary
|
||||
Data []ProfitLossData
|
||||
ProductData []ProductProfitData
|
||||
TodayRevenue float64
|
||||
TodayCost float64
|
||||
MtdRevenue float64
|
||||
MtdCost float64
|
||||
TodayExpenseByCategory []ExpenseCategoryTotal
|
||||
MtdExpenseByCategory []ExpenseCategoryTotal
|
||||
OperationalExpenseItems []OperationalExpenseItem
|
||||
Summary ProfitLossSummary
|
||||
Data []ProfitLossData
|
||||
ProductData []ProductProfitData
|
||||
TodayRevenue float64
|
||||
TodayCost float64
|
||||
MtdRevenue float64
|
||||
MtdCost float64
|
||||
TodayPurchasing float64
|
||||
MtdPurchasing float64
|
||||
TodayPurchasingRawMaterial float64
|
||||
MtdPurchasingRawMaterial float64
|
||||
TodayPurchasingExpense float64
|
||||
MtdPurchasingExpense float64
|
||||
PurchasingItems []PurchasingItemDetail
|
||||
TodayExpenseByCategory []ExpenseCategoryTotal
|
||||
MtdExpenseByCategory []ExpenseCategoryTotal
|
||||
OperationalExpenseItems []OperationalExpenseItem
|
||||
}
|
||||
|
||||
type PurchasingItemDetail struct {
|
||||
Date time.Time
|
||||
Item string
|
||||
Quantity float64
|
||||
Amount float64
|
||||
}
|
||||
|
||||
type ProfitLossSummary struct {
|
||||
@@ -186,3 +261,42 @@ 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
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ type Category struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
|
||||
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"`
|
||||
ParentID *uuid.UUID `gorm:"type:uuid;index" json:"parent_id"`
|
||||
Parent *Category `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
|
||||
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
|
||||
Description *string `gorm:"type:text" json:"description"`
|
||||
Order int `gorm:"default:0" json:"order"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -11,7 +11,8 @@ 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"`
|
||||
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"`
|
||||
@@ -19,11 +20,17 @@ type PurchaseOrder struct {
|
||||
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"`
|
||||
// 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"`
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -191,6 +191,18 @@ func (h *CategoryHandler) ListCategories(c *gin.Context) {
|
||||
req.OutletID = &outletID
|
||||
}
|
||||
}
|
||||
if parentIDStr := c.Query("parent_id"); parentIDStr != "" {
|
||||
if parentID, err := uuid.Parse(parentIDStr); err == nil {
|
||||
req.ParentID = &parentID
|
||||
}
|
||||
}
|
||||
|
||||
// type=parent -> top level categories only
|
||||
// type=child -> leaf categories (sub categories + top level ones without children)
|
||||
if categoryType := c.Query("type"); categoryType != "" {
|
||||
req.Type = categoryType
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.categoryValidator.ValidateListCategoriesRequest(req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(ctx).WithError(validationError).Error("CategoryHandler::ListCategories -> request validation failed")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ func CreateCategoryRequestToEntity(req *models.CreateCategoryRequest) *entities.
|
||||
return &entities.Category{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
ParentID: req.ParentID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Order: req.Order,
|
||||
@@ -85,10 +86,19 @@ func CategoryEntityToResponse(entity *entities.Category) *models.CategoryRespons
|
||||
}
|
||||
}
|
||||
|
||||
// Parent name is only available when the Parent association is preloaded
|
||||
var parentName *string
|
||||
if entity.Parent != nil {
|
||||
name := entity.Parent.Name
|
||||
parentName = &name
|
||||
}
|
||||
|
||||
return &models.CategoryResponse{
|
||||
ID: entity.ID,
|
||||
OrganizationID: entity.OrganizationID,
|
||||
OutletID: entity.OutletID,
|
||||
ParentID: entity.ParentID,
|
||||
ParentName: parentName,
|
||||
Name: entity.Name,
|
||||
Description: entity.Description,
|
||||
ImageURL: imageURL,
|
||||
@@ -127,6 +137,10 @@ func UpdateCategoryEntityFromRequest(entity *entities.Category, req *models.Upda
|
||||
if req.OutletID != nil {
|
||||
entity.OutletID = req.OutletID
|
||||
}
|
||||
|
||||
if req.ParentID != nil {
|
||||
entity.ParentID = req.ParentID
|
||||
}
|
||||
}
|
||||
|
||||
func CategoryEntitiesToModels(entities []*entities.Category) []*models.Category {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+336
-15
@@ -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,6 +140,20 @@ 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
|
||||
@@ -122,6 +168,7 @@ type PurchasingSummary struct {
|
||||
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
|
||||
@@ -150,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
|
||||
@@ -171,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"`
|
||||
@@ -208,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"`
|
||||
@@ -225,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"`
|
||||
@@ -237,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"`
|
||||
@@ -247,12 +426,15 @@ type DashboardAnalyticsResponse struct {
|
||||
|
||||
// DashboardOverview represents the overview data for dashboard
|
||||
type DashboardOverview struct {
|
||||
TotalSales float64 `json:"total_sales"`
|
||||
TotalOrders int64 `json:"total_orders"`
|
||||
AverageOrderValue float64 `json:"average_order_value"`
|
||||
TotalCustomers int64 `json:"total_customers"`
|
||||
VoidedOrders int64 `json:"voided_orders"`
|
||||
RefundedOrders int64 `json:"refunded_orders"`
|
||||
TotalSales float64 `json:"total_sales"`
|
||||
TotalOrders int64 `json:"total_orders"`
|
||||
AverageOrderValue float64 `json:"average_order_value"`
|
||||
TotalCustomers int64 `json:"total_customers"`
|
||||
VoidedOrders int64 `json:"voided_orders"`
|
||||
RefundedOrders int64 `json:"refunded_orders"`
|
||||
TotalItemSold int64 `json:"total_item_sold"`
|
||||
TotalLowStock int64 `json:"total_low_stock"`
|
||||
TotalProductActive int64 `json:"total_product_active"`
|
||||
}
|
||||
|
||||
type ProfitLossAnalyticsRequest struct {
|
||||
@@ -266,6 +448,7 @@ type ProfitLossAnalyticsRequest struct {
|
||||
type ProfitLossAnalyticsResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
GroupBy string `json:"group_by"`
|
||||
@@ -273,10 +456,28 @@ type ProfitLossAnalyticsResponse struct {
|
||||
Data []ProfitLossData `json:"data"`
|
||||
ProductData []ProductProfitData `json:"product_data"`
|
||||
MainSummary []ProfitLossSummaryRow `json:"main_summary"`
|
||||
Purchasing ProfitLossPurchasing `json:"purchasing"`
|
||||
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
|
||||
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
||||
}
|
||||
|
||||
type ProfitLossPurchasing struct {
|
||||
TodayTotal float64 `json:"today_total"`
|
||||
MtdTotal float64 `json:"mtd_total"`
|
||||
TodayRawMaterial float64 `json:"today_raw_material"`
|
||||
MtdRawMaterial float64 `json:"mtd_raw_material"`
|
||||
TodayExpense float64 `json:"today_expense"`
|
||||
MtdExpense float64 `json:"mtd_expense"`
|
||||
Items []ProfitLossPurchasingItem `json:"items"`
|
||||
}
|
||||
|
||||
type ProfitLossPurchasingItem struct {
|
||||
Date time.Time `json:"date"`
|
||||
Item string `json:"item"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Nominal float64 `json:"nominal"`
|
||||
}
|
||||
|
||||
type ProfitLossSummary struct {
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
@@ -334,3 +535,123 @@ 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"`
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ type Category struct {
|
||||
type CreateCategoryRequest struct {
|
||||
OrganizationID uuid.UUID `validate:"required"`
|
||||
OutletID *uuid.UUID
|
||||
ParentID *uuid.UUID
|
||||
Name string `validate:"required,min=1,max=255"`
|
||||
Description *string `validate:"omitempty,max=1000"`
|
||||
ImageURL *string `validate:"omitempty,url"`
|
||||
@@ -33,6 +34,7 @@ type UpdateCategoryRequest struct {
|
||||
Description *string `validate:"omitempty,max=1000"`
|
||||
ImageURL *string `validate:"omitempty,url"`
|
||||
OutletID *uuid.UUID
|
||||
ParentID *uuid.UUID
|
||||
Order *int `validate:"omitempty,min=0"`
|
||||
IsActive *bool
|
||||
}
|
||||
@@ -41,6 +43,8 @@ type CategoryResponse struct {
|
||||
ID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
OutletID *uuid.UUID
|
||||
ParentID *uuid.UUID
|
||||
ParentName *string
|
||||
Name string
|
||||
Description *string
|
||||
ImageURL *string
|
||||
|
||||
@@ -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,7 +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"`
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import (
|
||||
type PurchaseOrder 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"`
|
||||
@@ -17,10 +18,20 @@ type PurchaseOrder struct {
|
||||
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"`
|
||||
@@ -44,7 +55,8 @@ 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"`
|
||||
@@ -52,8 +64,11 @@ type PurchaseOrderResponse struct {
|
||||
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"`
|
||||
@@ -84,13 +99,16 @@ 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,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"`
|
||||
}
|
||||
@@ -112,6 +130,8 @@ 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"`
|
||||
}
|
||||
@@ -127,13 +147,20 @@ type UpdatePurchaseOrderItemRequest 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"`
|
||||
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]
|
||||
|
||||
@@ -6,8 +6,12 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type AnalyticsProcessor interface {
|
||||
@@ -16,8 +20,13 @@ 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 {
|
||||
@@ -32,6 +41,18 @@ func NewAnalyticsProcessorImpl(analyticsRepo repository.AnalyticsRepository, exp
|
||||
}
|
||||
}
|
||||
|
||||
// resolveOutletName fetches the outlet name from the database if outletID is provided
|
||||
func (p *AnalyticsProcessorImpl) resolveOutletName(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) *string {
|
||||
if outletID == nil {
|
||||
return nil
|
||||
}
|
||||
name, err := p.analyticsRepo.GetOutletName(ctx, organizationID, *outletID)
|
||||
if err != nil || name == "" {
|
||||
return nil
|
||||
}
|
||||
return &name
|
||||
}
|
||||
|
||||
func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) {
|
||||
if req.DateFrom.After(req.DateTo) {
|
||||
return nil, fmt.Errorf("date_from cannot be after date_to")
|
||||
@@ -86,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,
|
||||
@@ -160,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,
|
||||
@@ -177,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)
|
||||
}
|
||||
@@ -222,10 +250,26 @@ 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,
|
||||
@@ -240,10 +284,12 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
|
||||
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
|
||||
}
|
||||
|
||||
@@ -291,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,
|
||||
@@ -328,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) {
|
||||
@@ -389,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,
|
||||
@@ -600,9 +888,20 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
|
||||
opsTotal += item.Amount
|
||||
}
|
||||
|
||||
purchasingItems := make([]models.ProfitLossPurchasingItem, len(result.PurchasingItems))
|
||||
for i, item := range result.PurchasingItems {
|
||||
purchasingItems[i] = models.ProfitLossPurchasingItem{
|
||||
Date: item.Date,
|
||||
Item: item.Item,
|
||||
Quantity: item.Quantity,
|
||||
Nominal: item.Amount,
|
||||
}
|
||||
}
|
||||
|
||||
return &models.ProfitLossAnalyticsResponse{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||
DateFrom: req.DateFrom,
|
||||
DateTo: req.DateTo,
|
||||
GroupBy: req.GroupBy,
|
||||
@@ -619,9 +918,18 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
|
||||
AverageProfit: result.Summary.AverageProfit,
|
||||
ProfitabilityRatio: result.Summary.ProfitabilityRatio,
|
||||
},
|
||||
Data: data,
|
||||
ProductData: productData,
|
||||
MainSummary: mainSummary,
|
||||
Data: data,
|
||||
ProductData: productData,
|
||||
MainSummary: mainSummary,
|
||||
Purchasing: models.ProfitLossPurchasing{
|
||||
TodayTotal: result.TodayPurchasing,
|
||||
MtdTotal: result.MtdPurchasing,
|
||||
TodayRawMaterial: result.TodayPurchasingRawMaterial,
|
||||
MtdRawMaterial: result.MtdPurchasingRawMaterial,
|
||||
TodayExpense: result.TodayPurchasingExpense,
|
||||
MtdExpense: result.MtdPurchasingExpense,
|
||||
Items: purchasingItems,
|
||||
},
|
||||
OperationalExpenses: opsItems,
|
||||
OperationalExpensesTotal: opsTotal,
|
||||
}, nil
|
||||
@@ -651,3 +959,280 @@ func slugify(s string) string {
|
||||
}
|
||||
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,9 +14,16 @@ import (
|
||||
)
|
||||
|
||||
type analyticsRepositoryStub struct {
|
||||
purchasingResult *entities.PurchasingAnalytics
|
||||
profitLossResult *entities.ProfitLossAnalytics
|
||||
profitLossGroup string
|
||||
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) {
|
||||
@@ -26,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
|
||||
}
|
||||
|
||||
@@ -38,6 +47,18 @@ func (analyticsRepositoryStub) GetProductAnalyticsPerCategory(context.Context, u
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (analyticsRepositoryStub) GetProductAnalyticsPerParentCategory(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.ProductAnalyticsPerParentCategory, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (analyticsRepositoryStub) GetParentCategoryAnalyticsDetail(context.Context, uuid.UUID, *uuid.UUID, uuid.UUID, time.Time, time.Time) (*entities.ParentCategoryAnalyticsDetail, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s analyticsRepositoryStub) GetBudgetCutOffWeekly(context.Context, uuid.UUID, *uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.BudgetCutOffWeek, error) {
|
||||
return s.budgetCutOffWeeks, nil
|
||||
}
|
||||
|
||||
func (analyticsRepositoryStub) GetDashboardOverview(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) (*entities.DashboardOverview, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -47,6 +68,26 @@ func (s analyticsRepositoryStub) GetProfitLossAnalytics(_ context.Context, _ uui
|
||||
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 }
|
||||
@@ -71,7 +112,7 @@ 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{
|
||||
@@ -120,11 +161,115 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T)
|
||||
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{
|
||||
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
|
||||
profitLossResult: &entities.ProfitLossAnalytics{
|
||||
Summary: entities.ProfitLossSummary{
|
||||
TotalRevenue: 1000,
|
||||
@@ -196,7 +341,7 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *
|
||||
|
||||
func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *testing.T) {
|
||||
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||
processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{
|
||||
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
|
||||
profitLossResult: &entities.ProfitLossAnalytics{
|
||||
Summary: entities.ProfitLossSummary{
|
||||
TotalRevenue: 10000,
|
||||
@@ -273,3 +418,155 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *tes
|
||||
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)
|
||||
|
||||
|
||||
@@ -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,24 +1,27 @@
|
||||
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 {
|
||||
@@ -26,8 +29,12 @@ type PurchaseOrderProcessorImpl struct {
|
||||
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
|
||||
}
|
||||
@@ -37,6 +44,7 @@ func NewPurchaseOrderProcessorImpl(
|
||||
vendorRepo VendorRepository,
|
||||
ingredientRepo IngredientRepository,
|
||||
purchaseCategoryRepo PurchaseCategoryRepository,
|
||||
categoryRepo CategoryRepository,
|
||||
unitRepo UnitRepository,
|
||||
fileRepo FileRepository,
|
||||
inventoryMovementService InventoryMovementService,
|
||||
@@ -47,6 +55,7 @@ func NewPurchaseOrderProcessorImpl(
|
||||
vendorRepo: vendorRepo,
|
||||
ingredientRepo: ingredientRepo,
|
||||
purchaseCategoryRepo: purchaseCategoryRepo,
|
||||
categoryRepo: categoryRepo,
|
||||
unitRepo: unitRepo,
|
||||
fileRepo: fileRepo,
|
||||
inventoryMovementService: inventoryMovementService,
|
||||
@@ -54,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
|
||||
@@ -107,12 +123,13 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
|
||||
// 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,
|
||||
@@ -121,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 {
|
||||
@@ -173,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 {
|
||||
@@ -186,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)
|
||||
@@ -215,6 +237,16 @@ 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 {
|
||||
totalAmount := 0.0
|
||||
@@ -277,7 +309,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
|
||||
UnitID: unitID,
|
||||
Amount: amount,
|
||||
}
|
||||
totalAmount += amount
|
||||
totalAmount += calculatePurchaseOrderItemTotal(quantity, amount)
|
||||
}
|
||||
|
||||
// Delete and recreate only after all replacement items are valid.
|
||||
@@ -409,74 +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 {
|
||||
if item.PurchaseCategory != nil && item.PurchaseCategory.Type == entities.PurchaseCategoryTypeExpense {
|
||||
continue
|
||||
}
|
||||
|
||||
if item.IngredientID == nil || item.UnitID == nil || item.Quantity == nil {
|
||||
return nil, fmt.Errorf("purchase order item %s is missing raw material inventory fields", item.ID)
|
||||
}
|
||||
|
||||
// Get ingredient to find its base unit
|
||||
ingredient, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get ingredient %s: %w", *item.IngredientID, err)
|
||||
}
|
||||
|
||||
// Convert quantity to ingredient's base unit if needed
|
||||
quantityToAdd := *item.Quantity
|
||||
if *item.UnitID != ingredient.UnitID {
|
||||
// Convert from purchase unit to ingredient's base unit
|
||||
convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, *item.IngredientID, *item.UnitID, ingredient.UnitID, organizationID, *item.Quantity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", *item.IngredientID, *item.UnitID, ingredient.UnitID, err)
|
||||
}
|
||||
quantityToAdd = convertedQuantity
|
||||
}
|
||||
|
||||
// Calculate unit cost in ingredient's base unit
|
||||
unitCost := 0.0
|
||||
if quantityToAdd > 0 {
|
||||
unitCost = item.Amount / quantityToAdd
|
||||
}
|
||||
|
||||
// Create inventory movement for ingredient purchase
|
||||
reason := fmt.Sprintf("Purchase order %s received", po.PONumber)
|
||||
referenceType := entities.InventoryMovementReferenceTypePurchaseOrder
|
||||
referenceID := &id
|
||||
|
||||
err = p.inventoryMovementService.CreateIngredientMovement(
|
||||
ctx,
|
||||
*item.IngredientID,
|
||||
organizationID,
|
||||
outletID,
|
||||
userID,
|
||||
entities.InventoryMovementTypePurchase,
|
||||
quantityToAdd,
|
||||
unitCost,
|
||||
reason,
|
||||
&referenceType,
|
||||
referenceID,
|
||||
&item.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create inventory movement for ingredient %s: %w", *item.IngredientID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
@@ -490,6 +467,79 @@ 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 {
|
||||
@@ -506,3 +556,11 @@ func (p *PurchaseOrderProcessorImpl) validatePurchaseCategory(ctx context.Contex
|
||||
|
||||
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type CategoryRepositoryImpl struct {
|
||||
@@ -25,7 +26,7 @@ func (r *CategoryRepositoryImpl) Create(ctx context.Context, category *entities.
|
||||
|
||||
func (r *CategoryRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.Category, error) {
|
||||
var category entities.Category
|
||||
err := r.db.WithContext(ctx).First(&category, "id = ?", id).Error
|
||||
err := r.db.WithContext(ctx).Preload("Parent").First(&category, "id = ?", id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -47,6 +48,26 @@ func (r *CategoryRepositoryImpl) GetByOrganization(ctx context.Context, organiza
|
||||
return categories, err
|
||||
}
|
||||
|
||||
// ListParentCategories returns the top-level categories of an organization. These are
|
||||
// the buckets the parent category reports roll up to via COALESCE(parent_id, id), so
|
||||
// the list is deliberately every top-level category, not only those with children —
|
||||
// otherwise a team could show up in a report but not be selectable on a purchase.
|
||||
// Categories with no outlet of their own are shared, so they are always included.
|
||||
func (r *CategoryRepositoryImpl) ListParentCategories(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]*entities.Category, error) {
|
||||
var categories []*entities.Category
|
||||
|
||||
query := r.db.WithContext(ctx).
|
||||
Where("organization_id = ?", organizationID).
|
||||
Where("parent_id IS NULL")
|
||||
|
||||
if outletID != nil {
|
||||
query = query.Where("outlet_id = ? OR outlet_id IS NULL", *outletID)
|
||||
}
|
||||
|
||||
err := query.Order("\"order\" ASC, name ASC").Find(&categories).Error
|
||||
return categories, err
|
||||
}
|
||||
|
||||
func (r *CategoryRepositoryImpl) GetByBusinessType(ctx context.Context, businessType string) ([]*entities.Category, error) {
|
||||
var categories []*entities.Category
|
||||
err := r.db.WithContext(ctx).Where("business_type = ?", businessType).Find(&categories).Error
|
||||
@@ -54,13 +75,29 @@ func (r *CategoryRepositoryImpl) GetByBusinessType(ctx context.Context, business
|
||||
}
|
||||
|
||||
func (r *CategoryRepositoryImpl) Update(ctx context.Context, category *entities.Category) error {
|
||||
return r.db.WithContext(ctx).Save(category).Error
|
||||
// Omit associations so a preloaded Parent is not upserted back over parent_id
|
||||
return r.db.WithContext(ctx).Omit(clause.Associations).Save(category).Error
|
||||
}
|
||||
|
||||
func (r *CategoryRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
return r.db.WithContext(ctx).Delete(&entities.Category{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
// applyCategoryTypeFilter narrows the query by position in the category tree.
|
||||
// - "parent": top level categories only (no parent of their own)
|
||||
// - "child": leaf categories — sub categories plus top level categories that
|
||||
// have no sub categories, i.e. everything a product can be assigned to
|
||||
func applyCategoryTypeFilter(query *gorm.DB, value interface{}) *gorm.DB {
|
||||
switch value {
|
||||
case "parent":
|
||||
return query.Where("parent_id IS NULL")
|
||||
case "child":
|
||||
return query.Where("NOT EXISTS (SELECT 1 FROM categories AS sub WHERE sub.parent_id = categories.id)")
|
||||
default:
|
||||
return query
|
||||
}
|
||||
}
|
||||
|
||||
func (r *CategoryRepositoryImpl) List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.Category, int64, error) {
|
||||
var categories []*entities.Category
|
||||
var total int64
|
||||
@@ -75,6 +112,8 @@ func (r *CategoryRepositoryImpl) List(ctx context.Context, filters map[string]in
|
||||
case "outlet_id":
|
||||
// Include outlet-specific categories AND global categories (outlet_id IS NULL)
|
||||
query = query.Where("outlet_id = ? OR outlet_id IS NULL", value)
|
||||
case "type":
|
||||
query = applyCategoryTypeFilter(query, value)
|
||||
default:
|
||||
query = query.Where(key+" = ?", value)
|
||||
}
|
||||
@@ -84,7 +123,7 @@ func (r *CategoryRepositoryImpl) List(ctx context.Context, filters map[string]in
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
err := query.Order("\"order\" ASC").Limit(limit).Offset(offset).Find(&categories).Error
|
||||
err := query.Preload("Parent").Order("\"order\" ASC").Limit(limit).Offset(offset).Find(&categories).Error
|
||||
return categories, total, err
|
||||
}
|
||||
|
||||
@@ -97,6 +136,10 @@ func (r *CategoryRepositoryImpl) Count(ctx context.Context, filters map[string]i
|
||||
case "search":
|
||||
searchValue := "%" + value.(string) + "%"
|
||||
query = query.Where("name ILIKE ? OR description ILIKE ?", searchValue, searchValue)
|
||||
case "outlet_id":
|
||||
query = query.Where("outlet_id = ? OR outlet_id IS NULL", value)
|
||||
case "type":
|
||||
query = applyCategoryTypeFilter(query, value)
|
||||
default:
|
||||
query = query.Where(key+" = ?", value)
|
||||
}
|
||||
|
||||
@@ -101,6 +101,8 @@ func (r *ProductRepositoryImpl) List(ctx context.Context, filters map[string]int
|
||||
query = query.Where("price >= ?", value)
|
||||
case "price_max":
|
||||
query = query.Where("price <= ?", value)
|
||||
case "category_id":
|
||||
query = query.Where("category_id IN (?)", r.categoryAndChildrenIDs(value))
|
||||
default:
|
||||
query = query.Where(key+" = ?", value)
|
||||
}
|
||||
@@ -110,10 +112,21 @@ func (r *ProductRepositoryImpl) List(ctx context.Context, filters map[string]int
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
err := query.Limit(limit).Offset(offset).Find(&products).Error
|
||||
// id is a tie-breaker so LIMIT/OFFSET paging stays stable when several products
|
||||
// share the same created_at.
|
||||
err := query.Order("created_at DESC, id DESC").Limit(limit).Offset(offset).Find(&products).Error
|
||||
return products, total, err
|
||||
}
|
||||
|
||||
// categoryAndChildrenIDs builds a subquery resolving to the category itself plus its
|
||||
// direct children, so filtering by a parent category also returns the children's
|
||||
// products. For a category without children it resolves to just that category.
|
||||
func (r *ProductRepositoryImpl) categoryAndChildrenIDs(categoryID interface{}) *gorm.DB {
|
||||
return r.db.Model(&entities.Category{}).
|
||||
Select("id").
|
||||
Where("id = ? OR parent_id = ?", categoryID, categoryID)
|
||||
}
|
||||
|
||||
func (r *ProductRepositoryImpl) Count(ctx context.Context, filters map[string]interface{}) (int64, error) {
|
||||
var count int64
|
||||
query := r.db.WithContext(ctx).Model(&entities.Product{})
|
||||
@@ -127,6 +140,8 @@ func (r *ProductRepositoryImpl) Count(ctx context.Context, filters map[string]in
|
||||
query = query.Where("price >= ?", value)
|
||||
case "price_max":
|
||||
query = query.Where("price <= ?", value)
|
||||
case "category_id":
|
||||
query = query.Where("category_id IN (?)", r.categoryAndChildrenIDs(value))
|
||||
default:
|
||||
query = query.Where(key+" = ?", value)
|
||||
}
|
||||
@@ -232,6 +247,8 @@ func (r *ProductRepositoryImpl) ListWithOutletPrice(ctx context.Context, filters
|
||||
query = query.Where("products.price >= ?", value)
|
||||
case "price_max":
|
||||
query = query.Where("products.price <= ?", value)
|
||||
case "category_id":
|
||||
query = query.Where("products.category_id IN (?)", r.categoryAndChildrenIDs(value))
|
||||
default:
|
||||
query = query.Where("products."+key+" = ?", value)
|
||||
}
|
||||
@@ -250,6 +267,8 @@ func (r *ProductRepositoryImpl) ListWithOutletPrice(ctx context.Context, filters
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
err := query.Limit(limit).Offset(offset).Find(&products).Error
|
||||
// Columns are qualified because the outlet join brings a second created_at/id
|
||||
// into scope. id is a tie-breaker for stable LIMIT/OFFSET paging.
|
||||
err := query.Order("products.created_at DESC, products.id DESC").Limit(limit).Offset(offset).Find(&products).Error
|
||||
return products, total, err
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"apskel-pos-be/internal/entities"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type PurchaseOrderRepositoryImpl struct {
|
||||
@@ -30,6 +31,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID)
|
||||
var po entities.PurchaseOrder
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Vendor").
|
||||
Preload("TeamCategory").
|
||||
Preload("Items.Ingredient").
|
||||
Preload("Items.PurchaseCategory").
|
||||
Preload("Items.Unit").
|
||||
@@ -45,6 +47,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByIDAndOrganizationID(ctx context.Conte
|
||||
var po entities.PurchaseOrder
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Vendor").
|
||||
Preload("TeamCategory").
|
||||
Preload("Items.Ingredient").
|
||||
Preload("Items.PurchaseCategory").
|
||||
Preload("Items.Unit").
|
||||
@@ -58,7 +61,10 @@ func (r *PurchaseOrderRepositoryImpl) GetByIDAndOrganizationID(ctx context.Conte
|
||||
}
|
||||
|
||||
func (r *PurchaseOrderRepositoryImpl) Update(ctx context.Context, po *entities.PurchaseOrder) error {
|
||||
return r.db.WithContext(ctx).Save(po).Error
|
||||
// Omit associations so preloaded relations are not upserted back. Items and
|
||||
// attachments are rewritten explicitly by the processor, and without this a
|
||||
// preloaded TeamCategory would be written over the category row itself.
|
||||
return r.db.WithContext(ctx).Omit(clause.Associations).Save(po).Error
|
||||
}
|
||||
|
||||
func (r *PurchaseOrderRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
@@ -87,6 +93,18 @@ func (r *PurchaseOrderRepositoryImpl) List(ctx context.Context, organizationID u
|
||||
if vendorID, ok := value.(uuid.UUID); ok {
|
||||
query = query.Where("vendor_id = ?", vendorID)
|
||||
}
|
||||
case "team_scope":
|
||||
if teamScope, ok := value.(string); ok && teamScope != "" {
|
||||
query = query.Where("team_scope = ?", teamScope)
|
||||
}
|
||||
case "team_category_id":
|
||||
if teamCategoryID, ok := value.(uuid.UUID); ok {
|
||||
query = query.Where("team_category_id = ?", teamCategoryID)
|
||||
}
|
||||
case "team_unassigned":
|
||||
if unassigned, ok := value.(bool); ok && unassigned {
|
||||
query = query.Where("team_scope IS NULL")
|
||||
}
|
||||
case "start_date":
|
||||
if startDate, ok := value.(time.Time); ok {
|
||||
query = query.Where("transaction_date >= ?", startDate)
|
||||
@@ -106,6 +124,7 @@ func (r *PurchaseOrderRepositoryImpl) List(ctx context.Context, organizationID u
|
||||
|
||||
err := query.
|
||||
Preload("Vendor").
|
||||
Preload("TeamCategory").
|
||||
Preload("Items.Ingredient").
|
||||
Preload("Items.PurchaseCategory").
|
||||
Preload("Items.Unit").
|
||||
@@ -137,6 +156,18 @@ func (r *PurchaseOrderRepositoryImpl) Count(ctx context.Context, organizationID
|
||||
if vendorID, ok := value.(uuid.UUID); ok {
|
||||
query = query.Where("vendor_id = ?", vendorID)
|
||||
}
|
||||
case "team_scope":
|
||||
if teamScope, ok := value.(string); ok && teamScope != "" {
|
||||
query = query.Where("team_scope = ?", teamScope)
|
||||
}
|
||||
case "team_category_id":
|
||||
if teamCategoryID, ok := value.(uuid.UUID); ok {
|
||||
query = query.Where("team_category_id = ?", teamCategoryID)
|
||||
}
|
||||
case "team_unassigned":
|
||||
if unassigned, ok := value.(bool); ok && unassigned {
|
||||
query = query.Where("team_scope IS NULL")
|
||||
}
|
||||
case "start_date":
|
||||
if startDate, ok := value.(time.Time); ok {
|
||||
query = query.Where("transaction_date >= ?", startDate)
|
||||
@@ -170,6 +201,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByStatus(ctx context.Context, organizat
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("organization_id = ? AND status = ?", organizationID, status).
|
||||
Preload("Vendor").
|
||||
Preload("TeamCategory").
|
||||
Preload("Items.Ingredient").
|
||||
Preload("Items.PurchaseCategory").
|
||||
Preload("Items.Unit").
|
||||
@@ -182,6 +214,7 @@ func (r *PurchaseOrderRepositoryImpl) GetOverdue(ctx context.Context, organizati
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("organization_id = ? AND due_date < ? AND status IN (?)", organizationID, time.Now(), []string{"draft", "sent", "approved"}).
|
||||
Preload("Vendor").
|
||||
Preload("TeamCategory").
|
||||
Preload("Items.Ingredient").
|
||||
Preload("Items.PurchaseCategory").
|
||||
Preload("Items.Unit").
|
||||
@@ -196,6 +229,18 @@ func (r *PurchaseOrderRepositoryImpl) UpdateStatus(ctx context.Context, id uuid.
|
||||
Update("status", status).Error
|
||||
}
|
||||
|
||||
func (r *PurchaseOrderRepositoryImpl) UpdateStatusAndOutlet(ctx context.Context, id uuid.UUID, status string, outletID *uuid.UUID) error {
|
||||
updates := map[string]interface{}{"status": status}
|
||||
if outletID != nil {
|
||||
updates["outlet_id"] = *outletID
|
||||
}
|
||||
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&entities.PurchaseOrder{}).
|
||||
Where("id = ?", id).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
func (r *PurchaseOrderRepositoryImpl) UpdateTotalAmount(ctx context.Context, id uuid.UUID, totalAmount float64) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&entities.PurchaseOrder{}).
|
||||
|
||||
@@ -335,8 +335,13 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
analytics.GET("/purchasing", r.analyticsHandler.GetPurchasingAnalytics)
|
||||
analytics.GET("/products", r.analyticsHandler.GetProductAnalytics)
|
||||
analytics.GET("/categories", r.analyticsHandler.GetProductAnalyticsPerCategory)
|
||||
analytics.GET("/parent-categories", r.analyticsHandler.GetProductAnalyticsPerParentCategory)
|
||||
analytics.GET("/parent-categories/:parent_category_id", r.analyticsHandler.GetParentCategoryAnalyticsDetail)
|
||||
analytics.GET("/dashboard", r.analyticsHandler.GetDashboardAnalytics)
|
||||
analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics)
|
||||
analytics.GET("/exclusive-summary/period", r.analyticsHandler.GetExclusiveSummaryPeriod)
|
||||
analytics.GET("/exclusive-summary/monthly", r.analyticsHandler.GetExclusiveSummaryMonthly)
|
||||
analytics.GET("/exclusive-summary/mtd", r.analyticsHandler.GetExclusiveSummaryMTD)
|
||||
}
|
||||
|
||||
tables := protected.Group("/tables")
|
||||
@@ -353,7 +358,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
}
|
||||
|
||||
ingredients := protected.Group("/ingredients")
|
||||
ingredients.Use(r.authMiddleware.RequireAdminOrManager())
|
||||
ingredients.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
|
||||
{
|
||||
ingredients.POST("", r.ingredientHandler.Create)
|
||||
ingredients.GET("", r.ingredientHandler.GetAll)
|
||||
@@ -366,7 +371,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
}
|
||||
|
||||
vendors := protected.Group("/vendors")
|
||||
vendors.Use(r.authMiddleware.RequireAdminOrManager())
|
||||
vendors.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
|
||||
{
|
||||
vendors.POST("", r.vendorHandler.CreateVendor)
|
||||
vendors.GET("", r.vendorHandler.ListVendors)
|
||||
@@ -377,12 +382,13 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
}
|
||||
|
||||
purchaseOrders := protected.Group("/purchase-orders")
|
||||
purchaseOrders.Use(r.authMiddleware.RequireAdminOrManager())
|
||||
purchaseOrders.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
|
||||
{
|
||||
purchaseOrders.POST("", r.purchaseOrderHandler.CreatePurchaseOrder)
|
||||
purchaseOrders.GET("", r.purchaseOrderHandler.ListPurchaseOrders)
|
||||
purchaseOrders.GET("/status/:status", r.purchaseOrderHandler.GetPurchaseOrdersByStatus)
|
||||
purchaseOrders.GET("/overdue", r.purchaseOrderHandler.GetOverduePurchaseOrders)
|
||||
purchaseOrders.GET("/teams", r.purchaseOrderHandler.ListPurchaseTeams)
|
||||
purchaseOrders.GET("/:id", r.purchaseOrderHandler.GetPurchaseOrder)
|
||||
purchaseOrders.PUT("/:id", r.purchaseOrderHandler.UpdatePurchaseOrder)
|
||||
purchaseOrders.PUT("/:id/status/:status", r.purchaseOrderHandler.UpdatePurchaseOrderStatus)
|
||||
@@ -390,7 +396,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
}
|
||||
|
||||
purchaseCategories := protected.Group("/purchase-categories")
|
||||
purchaseCategories.Use(r.authMiddleware.RequireAdminOrManager())
|
||||
purchaseCategories.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
|
||||
{
|
||||
purchaseCategories.POST("", r.purchaseCategoryHandler.CreatePurchaseCategory)
|
||||
purchaseCategories.GET("", r.purchaseCategoryHandler.ListPurchaseCategories)
|
||||
@@ -400,7 +406,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
}
|
||||
|
||||
unitConverters := protected.Group("/unit-converters")
|
||||
unitConverters.Use(r.authMiddleware.RequireAdminOrManager())
|
||||
unitConverters.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
|
||||
{
|
||||
unitConverters.POST("", r.unitConverterHandler.CreateIngredientUnitConverter)
|
||||
unitConverters.GET("", r.unitConverterHandler.ListIngredientUnitConverters)
|
||||
@@ -462,7 +468,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
}
|
||||
|
||||
expenses := protected.Group("/expenses")
|
||||
expenses.Use(r.authMiddleware.RequireAdminOrManager())
|
||||
expenses.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
|
||||
{
|
||||
expenses.POST("", r.expenseHandler.CreateExpense)
|
||||
expenses.GET("", r.expenseHandler.ListExpenses)
|
||||
@@ -617,6 +623,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
outlets.GET("/:outlet_id/tables/occupied", r.tableHandler.GetOccupiedTables)
|
||||
// Reports
|
||||
outlets.GET("/:outlet_id/reports/daily-transaction.pdf", r.reportHandler.GetDailyTransactionReportPDF)
|
||||
outlets.GET("/:outlet_id/reports/profit-loss.pdf", r.reportHandler.GetProfitLossReportPDF)
|
||||
}
|
||||
|
||||
// User device routes - accessible by authenticated users for their own devices
|
||||
|
||||
@@ -16,8 +16,13 @@ type AnalyticsService interface {
|
||||
GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error)
|
||||
GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error)
|
||||
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error)
|
||||
GetProductAnalyticsPerParentCategory(ctx context.Context, req *models.ProductAnalyticsPerParentCategoryRequest) (*models.ProductAnalyticsPerParentCategoryResponse, error)
|
||||
GetParentCategoryAnalyticsDetail(ctx context.Context, req *models.ParentCategoryAnalyticsDetailRequest) (*models.ParentCategoryAnalyticsDetailResponse, error)
|
||||
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
|
||||
GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error)
|
||||
GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error)
|
||||
GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error)
|
||||
GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error)
|
||||
}
|
||||
|
||||
type AnalyticsServiceImpl struct {
|
||||
@@ -101,6 +106,36 @@ func (s *AnalyticsServiceImpl) GetProductAnalyticsPerCategory(ctx context.Contex
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsServiceImpl) GetProductAnalyticsPerParentCategory(ctx context.Context, req *models.ProductAnalyticsPerParentCategoryRequest) (*models.ProductAnalyticsPerParentCategoryResponse, error) {
|
||||
// Validate request
|
||||
if err := s.validateProductAnalyticsPerParentCategoryRequest(req); err != nil {
|
||||
return nil, fmt.Errorf("validation error: %w", err)
|
||||
}
|
||||
|
||||
// Process analytics request
|
||||
response, err := s.analyticsProcessor.GetProductAnalyticsPerParentCategory(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get product analytics per parent category: %w", err)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsServiceImpl) GetParentCategoryAnalyticsDetail(ctx context.Context, req *models.ParentCategoryAnalyticsDetailRequest) (*models.ParentCategoryAnalyticsDetailResponse, error) {
|
||||
// Validate request
|
||||
if err := s.validateParentCategoryAnalyticsDetailRequest(req); err != nil {
|
||||
return nil, fmt.Errorf("validation error: %w", err)
|
||||
}
|
||||
|
||||
// Process analytics request
|
||||
response, err := s.analyticsProcessor.GetParentCategoryAnalyticsDetail(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get parent category analytics detail: %w", err)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsServiceImpl) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) {
|
||||
// Validate request
|
||||
if err := s.validateDashboardAnalyticsRequest(req); err != nil {
|
||||
@@ -203,6 +238,10 @@ func (s *AnalyticsServiceImpl) validatePurchasingAnalyticsRequest(req *models.Pu
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := models.ParsePurchaseTeamFilter(req.Team); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -250,6 +289,50 @@ func (s *AnalyticsServiceImpl) validateProductAnalyticsPerCategoryRequest(req *m
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsServiceImpl) validateProductAnalyticsPerParentCategoryRequest(req *models.ProductAnalyticsPerParentCategoryRequest) error {
|
||||
if req.OrganizationID == uuid.Nil {
|
||||
return fmt.Errorf("organization ID is required")
|
||||
}
|
||||
|
||||
if req.DateFrom.IsZero() {
|
||||
return fmt.Errorf("date_from is required")
|
||||
}
|
||||
|
||||
if req.DateTo.IsZero() {
|
||||
return fmt.Errorf("date_to is required")
|
||||
}
|
||||
|
||||
if req.DateFrom.After(req.DateTo) {
|
||||
return fmt.Errorf("date_from cannot be after date_to")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsServiceImpl) validateParentCategoryAnalyticsDetailRequest(req *models.ParentCategoryAnalyticsDetailRequest) error {
|
||||
if req.OrganizationID == uuid.Nil {
|
||||
return fmt.Errorf("organization ID is required")
|
||||
}
|
||||
|
||||
if req.ParentCategoryID == uuid.Nil {
|
||||
return fmt.Errorf("parent category ID is required")
|
||||
}
|
||||
|
||||
if req.DateFrom.IsZero() {
|
||||
return fmt.Errorf("date_from is required")
|
||||
}
|
||||
|
||||
if req.DateTo.IsZero() {
|
||||
return fmt.Errorf("date_to is required")
|
||||
}
|
||||
|
||||
if req.DateFrom.After(req.DateTo) {
|
||||
return fmt.Errorf("date_from cannot be after date_to")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsServiceImpl) validateDashboardAnalyticsRequest(req *models.DashboardAnalyticsRequest) error {
|
||||
if req.OrganizationID == uuid.Nil {
|
||||
return fmt.Errorf("organization ID is required")
|
||||
@@ -320,3 +403,98 @@ func (s *AnalyticsServiceImpl) validateProfitLossAnalyticsRequest(req *models.Pr
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsServiceImpl) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
|
||||
if err := s.validateExclusiveSummaryPeriodRequest(req); err != nil {
|
||||
return nil, fmt.Errorf("validation error: %w", err)
|
||||
}
|
||||
|
||||
response, err := s.analyticsProcessor.GetExclusiveSummaryPeriod(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get exclusive summary period: %w", err)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsServiceImpl) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) {
|
||||
if err := s.validateExclusiveSummaryMonthlyRequest(req); err != nil {
|
||||
return nil, fmt.Errorf("validation error: %w", err)
|
||||
}
|
||||
|
||||
response, err := s.analyticsProcessor.GetExclusiveSummaryMonthly(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get exclusive summary monthly: %w", err)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsServiceImpl) GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
|
||||
if err := s.validateExclusiveSummaryMTDRequest(req); err != nil {
|
||||
return nil, fmt.Errorf("validation error: %w", err)
|
||||
}
|
||||
|
||||
response, err := s.analyticsProcessor.GetExclusiveSummaryMTD(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get exclusive summary mtd: %w", err)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsServiceImpl) validateExclusiveSummaryPeriodRequest(req *models.ExclusiveSummaryPeriodRequest) error {
|
||||
if req == nil {
|
||||
return fmt.Errorf("request cannot be nil")
|
||||
}
|
||||
|
||||
if req.OrganizationID == uuid.Nil {
|
||||
return fmt.Errorf("organization_id is required")
|
||||
}
|
||||
|
||||
if req.DateFrom.IsZero() {
|
||||
return fmt.Errorf("date_from is required")
|
||||
}
|
||||
|
||||
if req.DateTo.IsZero() {
|
||||
return fmt.Errorf("date_to is required")
|
||||
}
|
||||
|
||||
if req.DateFrom.After(req.DateTo) {
|
||||
return fmt.Errorf("date_from cannot be after date_to")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsServiceImpl) validateExclusiveSummaryMTDRequest(req *models.ExclusiveSummaryMTDRequest) error {
|
||||
if req == nil {
|
||||
return fmt.Errorf("request cannot be nil")
|
||||
}
|
||||
|
||||
if req.OrganizationID == uuid.Nil {
|
||||
return fmt.Errorf("organization_id is required")
|
||||
}
|
||||
|
||||
if req.DateTo.IsZero() {
|
||||
return fmt.Errorf("date_to is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsServiceImpl) validateExclusiveSummaryMonthlyRequest(req *models.ExclusiveSummaryMonthlyRequest) error {
|
||||
if req == nil {
|
||||
return fmt.Errorf("request cannot be nil")
|
||||
}
|
||||
|
||||
if req.OrganizationID == uuid.Nil {
|
||||
return fmt.Errorf("organization_id is required")
|
||||
}
|
||||
|
||||
if req.Month.IsZero() {
|
||||
return fmt.Errorf("month is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -33,6 +33,14 @@ func (analyticsProcessorStub) GetProductAnalyticsPerCategory(context.Context, *m
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (analyticsProcessorStub) GetProductAnalyticsPerParentCategory(context.Context, *models.ProductAnalyticsPerParentCategoryRequest) (*models.ProductAnalyticsPerParentCategoryResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (analyticsProcessorStub) GetParentCategoryAnalyticsDetail(context.Context, *models.ParentCategoryAnalyticsDetailRequest) (*models.ParentCategoryAnalyticsDetailResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (analyticsProcessorStub) GetDashboardAnalytics(context.Context, *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -41,6 +49,18 @@ func (analyticsProcessorStub) GetProfitLossAnalytics(context.Context, *models.Pr
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (analyticsProcessorStub) GetExclusiveSummaryPeriod(context.Context, *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
|
||||
return &models.ExclusiveSummaryPeriodResponse{}, nil
|
||||
}
|
||||
|
||||
func (analyticsProcessorStub) GetExclusiveSummaryMonthly(context.Context, *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) {
|
||||
return &models.ExclusiveSummaryMonthlyResponse{}, nil
|
||||
}
|
||||
|
||||
func (analyticsProcessorStub) GetExclusiveSummaryMTD(context.Context, *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
|
||||
return &models.ExclusiveSummaryPeriodResponse{}, nil
|
||||
}
|
||||
|
||||
func TestAnalyticsServiceGetPurchasingAnalyticsValidation(t *testing.T) {
|
||||
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
|
||||
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||
@@ -93,6 +113,16 @@ func TestAnalyticsServiceGetPurchasingAnalyticsValidation(t *testing.T) {
|
||||
},
|
||||
wantErr: "invalid group_by value: quarter",
|
||||
},
|
||||
{
|
||||
name: "unknown team",
|
||||
req: &models.PurchasingAnalyticsRequest{
|
||||
OrganizationID: uuid.New(),
|
||||
DateFrom: now,
|
||||
DateTo: now,
|
||||
Team: "marketing",
|
||||
},
|
||||
wantErr: "team must be one of",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -190,3 +220,100 @@ func TestAnalyticsServiceGetProfitLossAnalyticsAllowsEmptyGroupBy(t *testing.T)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, resp)
|
||||
}
|
||||
|
||||
func TestAnalyticsServiceGetExclusiveSummaryPeriodValidation(t *testing.T) {
|
||||
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
|
||||
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
req *models.ExclusiveSummaryPeriodRequest
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "nil request",
|
||||
req: nil,
|
||||
wantErr: "request cannot be nil",
|
||||
},
|
||||
{
|
||||
name: "missing organization",
|
||||
req: &models.ExclusiveSummaryPeriodRequest{
|
||||
DateFrom: now,
|
||||
DateTo: now,
|
||||
},
|
||||
wantErr: "organization_id is required",
|
||||
},
|
||||
{
|
||||
name: "reversed dates",
|
||||
req: &models.ExclusiveSummaryPeriodRequest{
|
||||
OrganizationID: uuid.New(),
|
||||
DateFrom: now.AddDate(0, 0, 1),
|
||||
DateTo: now,
|
||||
},
|
||||
wantErr: "date_from cannot be after date_to",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resp, err := service.GetExclusiveSummaryPeriod(context.Background(), tt.req)
|
||||
|
||||
require.Nil(t, resp)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsServiceGetExclusiveSummaryMonthlyValidation(t *testing.T) {
|
||||
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
|
||||
|
||||
resp, err := service.GetExclusiveSummaryMonthly(context.Background(), &models.ExclusiveSummaryMonthlyRequest{
|
||||
OrganizationID: uuid.New(),
|
||||
})
|
||||
|
||||
require.Nil(t, resp)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "month is required")
|
||||
}
|
||||
|
||||
func TestAnalyticsServiceGetExclusiveSummaryMTDValidation(t *testing.T) {
|
||||
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
|
||||
now := time.Date(2026, 6, 18, 23, 59, 59, 0, time.UTC)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
req *models.ExclusiveSummaryMTDRequest
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "nil request",
|
||||
req: nil,
|
||||
wantErr: "request cannot be nil",
|
||||
},
|
||||
{
|
||||
name: "missing organization",
|
||||
req: &models.ExclusiveSummaryMTDRequest{
|
||||
DateTo: now,
|
||||
},
|
||||
wantErr: "organization_id is required",
|
||||
},
|
||||
{
|
||||
name: "missing date_to",
|
||||
req: &models.ExclusiveSummaryMTDRequest{
|
||||
OrganizationID: uuid.New(),
|
||||
},
|
||||
wantErr: "date_to is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resp, err := service.GetExclusiveSummaryMTD(context.Background(), tt.req)
|
||||
|
||||
require.Nil(t, resp)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,12 @@ func (s *CategoryServiceImpl) ListCategories(ctx context.Context, req *contract.
|
||||
if req.BusinessType != "" {
|
||||
filters["business_type"] = req.BusinessType
|
||||
}
|
||||
if req.ParentID != nil {
|
||||
filters["parent_id"] = *req.ParentID
|
||||
}
|
||||
if req.Type != "" {
|
||||
filters["type"] = req.Type
|
||||
}
|
||||
if req.Search != "" {
|
||||
filters["search"] = req.Search
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type PurchaseOrderService interface {
|
||||
GetPurchaseOrdersByStatus(ctx context.Context, apctx *appcontext.ContextInfo, status string) *contract.Response
|
||||
GetOverduePurchaseOrders(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response
|
||||
UpdatePurchaseOrderStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, status string) *contract.Response
|
||||
ListPurchaseTeams(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response
|
||||
}
|
||||
|
||||
type PurchaseOrderServiceImpl struct {
|
||||
@@ -40,7 +41,12 @@ func (s *PurchaseOrderServiceImpl) CreatePurchaseOrder(ctx context.Context, apct
|
||||
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
||||
}
|
||||
|
||||
poResponse, err := s.purchaseOrderProcessor.CreatePurchaseOrder(ctx, apctx.OrganizationID, modelReq)
|
||||
var outletID *uuid.UUID
|
||||
if apctx.OutletID != uuid.Nil {
|
||||
outletID = &apctx.OutletID
|
||||
}
|
||||
|
||||
poResponse, err := s.purchaseOrderProcessor.CreatePurchaseOrder(ctx, apctx.OrganizationID, outletID, modelReq)
|
||||
if err != nil {
|
||||
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseOrderServiceEntity, err.Error())
|
||||
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
||||
@@ -57,7 +63,12 @@ func (s *PurchaseOrderServiceImpl) UpdatePurchaseOrder(ctx context.Context, apct
|
||||
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
||||
}
|
||||
|
||||
poResponse, err := s.purchaseOrderProcessor.UpdatePurchaseOrder(ctx, id, apctx.OrganizationID, modelReq)
|
||||
var outletID *uuid.UUID
|
||||
if apctx.OutletID != uuid.Nil {
|
||||
outletID = &apctx.OutletID
|
||||
}
|
||||
|
||||
poResponse, err := s.purchaseOrderProcessor.UpdatePurchaseOrder(ctx, id, apctx.OrganizationID, outletID, modelReq)
|
||||
if err != nil {
|
||||
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseOrderServiceEntity, err.Error())
|
||||
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
||||
@@ -103,6 +114,26 @@ func (s *PurchaseOrderServiceImpl) ListPurchaseOrders(ctx context.Context, apctx
|
||||
if modelReq.VendorID != nil {
|
||||
filters["vendor_id"] = *modelReq.VendorID
|
||||
}
|
||||
if modelReq.TeamScope != "" {
|
||||
filters["team_scope"] = modelReq.TeamScope
|
||||
}
|
||||
if modelReq.TeamCategoryID != nil {
|
||||
filters["team_category_id"] = *modelReq.TeamCategoryID
|
||||
}
|
||||
// team spells out the same two filters in one value; the validator has already
|
||||
// ruled out sending it together with them.
|
||||
switch modelReq.Team {
|
||||
case "":
|
||||
case constants.PurchaseTeamNone:
|
||||
filters["team_unassigned"] = true
|
||||
case constants.PurchaseTeamScopeCentral:
|
||||
filters["team_scope"] = constants.PurchaseTeamScopeCentral
|
||||
default:
|
||||
if teamCategoryID, err := uuid.Parse(modelReq.Team); err == nil {
|
||||
filters["team_scope"] = constants.PurchaseTeamScopeCategory
|
||||
filters["team_category_id"] = teamCategoryID
|
||||
}
|
||||
}
|
||||
if modelReq.StartDate != nil {
|
||||
filters["start_date"] = *modelReq.StartDate
|
||||
}
|
||||
@@ -135,6 +166,21 @@ func (s *PurchaseOrderServiceImpl) ListPurchaseOrders(ctx context.Context, apctx
|
||||
return contract.BuildSuccessResponse(response)
|
||||
}
|
||||
|
||||
func (s *PurchaseOrderServiceImpl) ListPurchaseTeams(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response {
|
||||
var outletID *uuid.UUID
|
||||
if apctx.OutletID != uuid.Nil {
|
||||
outletID = &apctx.OutletID
|
||||
}
|
||||
|
||||
teams, err := s.purchaseOrderProcessor.ListPurchaseTeams(ctx, apctx.OrganizationID, outletID)
|
||||
if err != nil {
|
||||
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseOrderServiceEntity, err.Error())
|
||||
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
||||
}
|
||||
|
||||
return contract.BuildSuccessResponse(transformer.ListPurchaseTeamsModelResponseToResponse(teams))
|
||||
}
|
||||
|
||||
func (s *PurchaseOrderServiceImpl) GetPurchaseOrdersByStatus(ctx context.Context, apctx *appcontext.ContextInfo, status string) *contract.Response {
|
||||
poResponses, err := s.purchaseOrderProcessor.GetPurchaseOrdersByStatus(ctx, apctx.OrganizationID, status)
|
||||
if err != nil {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
type ReportService interface {
|
||||
// Returns (publicURL, fileName, error)
|
||||
GenerateDailyTransactionPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error)
|
||||
GenerateProfitLossPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error)
|
||||
}
|
||||
|
||||
type ReportServiceImpl struct {
|
||||
@@ -218,3 +219,296 @@ func getPLPctByID(rows []models.ProfitLossSummaryRow, id string) float64 {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// profitLossReportData holds data for the profit/loss PDF template
|
||||
type profitLossReportData struct {
|
||||
OrganizationName string
|
||||
MonthName string
|
||||
ReportDate string
|
||||
ReportDateUpper string
|
||||
TotalPenjualan string
|
||||
TotalBiaya string
|
||||
LabaRugi string
|
||||
LabaRugiClass string
|
||||
LabaRugiValueClass string
|
||||
LabaRugiMtd string
|
||||
LabaRugiMtdClass string
|
||||
LabaRugiMtdValueClass string
|
||||
MainSummary []profitLossSummaryRowView
|
||||
PurchasingItems []profitLossPurchasingItem
|
||||
PurchasingTotal string
|
||||
GeneratedBy string
|
||||
PrintTime string
|
||||
}
|
||||
|
||||
type profitLossSummaryRowView struct {
|
||||
Number string
|
||||
Label string
|
||||
TodayNominal string
|
||||
TodayPct string
|
||||
MtdNominal string
|
||||
MtdPct string
|
||||
RowClass string
|
||||
SubItems []profitLossSummaryRowView
|
||||
}
|
||||
|
||||
type profitLossPurchasingItem struct {
|
||||
Name string
|
||||
Amount string
|
||||
}
|
||||
|
||||
func (s *ReportServiceImpl) GenerateProfitLossPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error) {
|
||||
orgID, err := uuid.Parse(organizationID)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid organization id: %w", err)
|
||||
}
|
||||
|
||||
var outID *uuid.UUID
|
||||
if outletID != "" {
|
||||
parsed, err := uuid.Parse(outletID)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid outlet id: %w", err)
|
||||
}
|
||||
outID = &parsed
|
||||
}
|
||||
|
||||
org, err := s.organizationRepo.GetByID(ctx, orgID)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("organization not found: %w", err)
|
||||
}
|
||||
|
||||
var tzName string
|
||||
if outID != nil {
|
||||
outlet, err := s.outletRepo.GetByID(ctx, *outID)
|
||||
if err == nil && outlet.Timezone != nil && *outlet.Timezone != "" {
|
||||
tzName = *outlet.Timezone
|
||||
}
|
||||
}
|
||||
if tzName == "" {
|
||||
tzName = "Asia/Jakarta"
|
||||
}
|
||||
|
||||
loc, locErr := time.LoadLocation(tzName)
|
||||
if locErr != nil || loc == nil {
|
||||
loc = time.Local
|
||||
}
|
||||
|
||||
var day time.Time
|
||||
if reportDate != nil {
|
||||
t := reportDate.UTC()
|
||||
day = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
|
||||
} else {
|
||||
now := time.Now().In(loc)
|
||||
day = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||
}
|
||||
|
||||
dayStart := day
|
||||
dayEnd := day.Add(24*time.Hour - time.Nanosecond)
|
||||
|
||||
// MTD: from 1st of month to end of the report day
|
||||
mtdStart := time.Date(day.Year(), day.Month(), 1, 0, 0, 0, 0, loc)
|
||||
mtdEnd := dayEnd
|
||||
|
||||
// Get profit/loss analytics for the day
|
||||
plReq := &models.ProfitLossAnalyticsRequest{
|
||||
OrganizationID: orgID,
|
||||
OutletID: outID,
|
||||
DateFrom: dayStart,
|
||||
DateTo: mtdEnd,
|
||||
GroupBy: "day",
|
||||
}
|
||||
pl, err := s.analyticsService.GetProfitLossAnalytics(ctx, plReq)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("get profit/loss analytics: %w", err)
|
||||
}
|
||||
|
||||
// Get purchasing analytics for the day (Rincian Biaya / Catatan)
|
||||
purchReq := &models.PurchasingAnalyticsRequest{
|
||||
OrganizationID: orgID,
|
||||
OutletID: outID,
|
||||
DateFrom: dayStart,
|
||||
DateTo: dayEnd,
|
||||
GroupBy: "day",
|
||||
}
|
||||
purch, err := s.analyticsService.GetPurchasingAnalytics(ctx, purchReq)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("get purchasing analytics: %w", err)
|
||||
}
|
||||
|
||||
// Build summary values
|
||||
totalOmset := getPLNominalByID(pl.MainSummary, "total_omset")
|
||||
hpp := getPLNominalByID(pl.MainSummary, "hpp")
|
||||
_ = mtdStart // used above
|
||||
|
||||
// Total biaya = HPP + operational expenses for the day
|
||||
totalBiayaToday := hpp + pl.OperationalExpensesTotal
|
||||
|
||||
// Laba/Rugi today
|
||||
labaRugiToday := totalOmset - totalBiayaToday
|
||||
|
||||
// MTD values
|
||||
mtdOmset := getMtdNominalByID(pl.MainSummary, "total_omset")
|
||||
mtdCost := getMtdNominalByID(pl.MainSummary, "hpp")
|
||||
mtdOps := getMtdNominalByID(pl.MainSummary, "biaya_ops")
|
||||
mtdGaji := getMtdNominalByID(pl.MainSummary, "biaya_gaji")
|
||||
labaRugiMtd := mtdOmset - mtdCost - mtdOps - mtdGaji
|
||||
|
||||
// Month name in Indonesian
|
||||
monthNames := []string{"", "Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli", "Agustus", "September", "Oktober", "November", "Desember"}
|
||||
monthName := fmt.Sprintf("%s %d", monthNames[day.Month()], day.Year())
|
||||
|
||||
reportDateStr := fmt.Sprintf("%d %s %d", day.Day(), monthNames[day.Month()], day.Year())
|
||||
reportDateUpper := fmt.Sprintf("%d %s %d", day.Day(), strings.ToUpper(monthNames[day.Month()]), day.Year())
|
||||
|
||||
// Build main summary rows
|
||||
mainSummaryRows := buildProfitLossSummaryRows(pl.MainSummary)
|
||||
|
||||
// Build purchasing items from ingredient data
|
||||
purchItems := make([]profitLossPurchasingItem, 0)
|
||||
var purchTotal float64
|
||||
for _, item := range purch.IngredientData {
|
||||
purchItems = append(purchItems, profitLossPurchasingItem{
|
||||
Name: item.IngredientName,
|
||||
Amount: formatCurrency(item.TotalCost),
|
||||
})
|
||||
purchTotal += item.TotalCost
|
||||
}
|
||||
|
||||
// Determine highlight classes
|
||||
labaRugiClass := ""
|
||||
labaRugiValueClass := ""
|
||||
if labaRugiToday < 0 {
|
||||
labaRugiClass = "highlight-red"
|
||||
labaRugiValueClass = "negative"
|
||||
} else {
|
||||
labaRugiClass = "highlight-green"
|
||||
labaRugiValueClass = "positive"
|
||||
}
|
||||
|
||||
labaRugiMtdClass := ""
|
||||
labaRugiMtdValueClass := ""
|
||||
if labaRugiMtd < 0 {
|
||||
labaRugiMtdClass = "highlight-red"
|
||||
labaRugiMtdValueClass = "negative"
|
||||
} else {
|
||||
labaRugiMtdClass = "highlight-green"
|
||||
labaRugiMtdValueClass = "positive"
|
||||
}
|
||||
|
||||
data := profitLossReportData{
|
||||
OrganizationName: org.Name,
|
||||
MonthName: monthName,
|
||||
ReportDate: reportDateStr,
|
||||
ReportDateUpper: reportDateUpper,
|
||||
TotalPenjualan: formatCurrency(totalOmset),
|
||||
TotalBiaya: formatCurrency(totalBiayaToday),
|
||||
LabaRugi: formatCurrencySigned(labaRugiToday),
|
||||
LabaRugiClass: labaRugiClass,
|
||||
LabaRugiValueClass: labaRugiValueClass,
|
||||
LabaRugiMtd: formatCurrencySigned(labaRugiMtd),
|
||||
LabaRugiMtdClass: labaRugiMtdClass,
|
||||
LabaRugiMtdValueClass: labaRugiMtdValueClass,
|
||||
MainSummary: mainSummaryRows,
|
||||
PurchasingItems: purchItems,
|
||||
PurchasingTotal: formatCurrency(purchTotal),
|
||||
GeneratedBy: generatedBy,
|
||||
PrintTime: time.Now().In(loc).Format("02/01/2006 15:04:05"),
|
||||
}
|
||||
|
||||
templatePath := filepath.Join("templates", "profit_loss_report.html")
|
||||
pdfBytes, err := renderTemplateToPDF(templatePath, data)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("render pdf: %w", err)
|
||||
}
|
||||
|
||||
safeOrg := orgID.String()
|
||||
safeOutlet := "all"
|
||||
if outID != nil {
|
||||
safeOutlet = outID.String()
|
||||
}
|
||||
|
||||
fileName := fmt.Sprintf("laporan-laba-rugi-%s-%s.pdf", day.Format("2006-01-02"), time.Now().Format("20060102-150405"))
|
||||
objectKey := fmt.Sprintf("/reports/%s/%s/%s", safeOrg, safeOutlet, fileName)
|
||||
publicURL, err := s.fileClient.UploadFile(ctx, objectKey, pdfBytes)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("upload pdf: %w", err)
|
||||
}
|
||||
|
||||
return publicURL, fileName, nil
|
||||
}
|
||||
|
||||
func getMtdNominalByID(rows []models.ProfitLossSummaryRow, id string) float64 {
|
||||
for _, row := range rows {
|
||||
if row.ID == id {
|
||||
return row.MtdNominal
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func buildProfitLossSummaryRows(rows []models.ProfitLossSummaryRow) []profitLossSummaryRowView {
|
||||
result := make([]profitLossSummaryRowView, 0, len(rows))
|
||||
for i, row := range rows {
|
||||
rowClass := ""
|
||||
if row.IsBold {
|
||||
rowClass = "highlight-green-row"
|
||||
}
|
||||
// Highlight laba kotor row
|
||||
if row.ID == "laba_kotor" {
|
||||
rowClass = "highlight-row"
|
||||
}
|
||||
|
||||
number := ""
|
||||
if row.ID != "" {
|
||||
number = fmt.Sprintf("%d", i+1)
|
||||
}
|
||||
|
||||
subItems := make([]profitLossSummaryRowView, 0)
|
||||
for _, sub := range row.SubItems {
|
||||
subItems = append(subItems, profitLossSummaryRowView{
|
||||
Label: sub.Label,
|
||||
TodayNominal: formatCurrencyOrDash(sub.TodayNominal),
|
||||
TodayPct: formatPct(sub.TodayPct),
|
||||
MtdNominal: formatCurrencyOrDash(sub.MtdNominal),
|
||||
MtdPct: formatPct(sub.MtdPct),
|
||||
RowClass: "",
|
||||
})
|
||||
}
|
||||
|
||||
result = append(result, profitLossSummaryRowView{
|
||||
Number: number,
|
||||
Label: row.Label,
|
||||
TodayNominal: formatCurrencyOrDash(row.TodayNominal),
|
||||
TodayPct: formatPct(row.TodayPct),
|
||||
MtdNominal: formatCurrencyOrDash(row.MtdNominal),
|
||||
MtdPct: formatPct(row.MtdPct),
|
||||
RowClass: rowClass,
|
||||
SubItems: subItems,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func formatCurrencyOrDash(amount float64) string {
|
||||
if amount == 0 {
|
||||
return "-"
|
||||
}
|
||||
if amount < 0 {
|
||||
return formatCurrencySigned(amount)
|
||||
}
|
||||
return formatCurrency(amount)
|
||||
}
|
||||
|
||||
func formatCurrencySigned(amount float64) string {
|
||||
if amount < 0 {
|
||||
return "(Rp " + addThousandsSep(fmt.Sprintf("%.0f", -amount)) + ")"
|
||||
}
|
||||
return "Rp " + addThousandsSep(fmt.Sprintf("%.0f", amount))
|
||||
}
|
||||
|
||||
func formatPct(pct float64) string {
|
||||
if pct == 0 {
|
||||
return "0%"
|
||||
}
|
||||
return fmt.Sprintf("%.0f%%", pct)
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ func PaymentMethodAnalyticsModelToContract(resp *models.PaymentMethodAnalyticsRe
|
||||
return &contract.PaymentMethodAnalyticsResponse{
|
||||
OrganizationID: resp.OrganizationID,
|
||||
OutletID: resp.OutletID,
|
||||
OutletName: resp.OutletName,
|
||||
DateFrom: resp.DateFrom,
|
||||
DateTo: resp.DateTo,
|
||||
GroupBy: resp.GroupBy,
|
||||
@@ -122,6 +123,7 @@ func SalesAnalyticsModelToContract(resp *models.SalesAnalyticsResponse) *contrac
|
||||
return &contract.SalesAnalyticsResponse{
|
||||
OrganizationID: resp.OrganizationID,
|
||||
OutletID: resp.OutletID,
|
||||
OutletName: resp.OutletName,
|
||||
DateFrom: resp.DateFrom,
|
||||
DateTo: resp.DateTo,
|
||||
GroupBy: resp.GroupBy,
|
||||
@@ -154,6 +156,7 @@ func PurchasingAnalyticsContractToModel(req *contract.PurchasingAnalyticsRequest
|
||||
return &models.PurchasingAnalyticsRequest{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: parseOutletID(req.OutletID),
|
||||
Team: req.Team,
|
||||
DateFrom: dateFrom,
|
||||
DateTo: dateTo,
|
||||
GroupBy: req.GroupBy,
|
||||
@@ -206,10 +209,26 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse
|
||||
}
|
||||
}
|
||||
|
||||
teamData := make([]contract.PurchasingTeamData, len(resp.TeamData))
|
||||
for i, item := range resp.TeamData {
|
||||
teamData[i] = contract.PurchasingTeamData{
|
||||
Scope: item.Scope,
|
||||
CategoryID: item.CategoryID,
|
||||
Name: item.Name,
|
||||
TotalPurchases: item.TotalPurchases,
|
||||
RawMaterialPurchases: item.RawMaterialPurchases,
|
||||
ExpensePurchases: item.ExpensePurchases,
|
||||
PurchaseOrderCount: item.PurchaseOrderCount,
|
||||
Quantity: item.Quantity,
|
||||
Percentage: item.Percentage,
|
||||
}
|
||||
}
|
||||
|
||||
return &contract.PurchasingAnalyticsResponse{
|
||||
OrganizationID: resp.OrganizationID,
|
||||
OutletID: resp.OutletID,
|
||||
OutletName: resp.OutletName,
|
||||
Team: resp.Team,
|
||||
DateFrom: resp.DateFrom,
|
||||
DateTo: resp.DateTo,
|
||||
GroupBy: resp.GroupBy,
|
||||
@@ -224,10 +243,12 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse
|
||||
AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue,
|
||||
TotalIngredients: resp.Summary.TotalIngredients,
|
||||
TotalVendors: resp.Summary.TotalVendors,
|
||||
TotalTeams: resp.Summary.TotalTeams,
|
||||
},
|
||||
Data: data,
|
||||
IngredientData: ingredientData,
|
||||
VendorData: vendorData,
|
||||
TeamData: teamData,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +306,7 @@ func ProductAnalyticsModelToContract(resp *models.ProductAnalyticsResponse) *con
|
||||
return &contract.ProductAnalyticsResponse{
|
||||
OrganizationID: resp.OrganizationID,
|
||||
OutletID: resp.OutletID,
|
||||
OutletName: resp.OutletName,
|
||||
DateFrom: resp.DateFrom,
|
||||
DateTo: resp.DateTo,
|
||||
Data: data,
|
||||
@@ -337,12 +359,202 @@ func ProductAnalyticsPerCategoryModelToContract(resp *models.ProductAnalyticsPer
|
||||
return &contract.ProductAnalyticsPerCategoryResponse{
|
||||
OrganizationID: resp.OrganizationID,
|
||||
OutletID: resp.OutletID,
|
||||
OutletName: resp.OutletName,
|
||||
DateFrom: resp.DateFrom,
|
||||
DateTo: resp.DateTo,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
// ProductAnalyticsPerParentCategoryContractToModel converts contract request to model
|
||||
func ProductAnalyticsPerParentCategoryContractToModel(req *contract.ProductAnalyticsPerParentCategoryRequest) *models.ProductAnalyticsPerParentCategoryRequest {
|
||||
var dateFrom, dateTo time.Time
|
||||
|
||||
// Parse date range using utility function
|
||||
if fromTime, toTime, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo); err == nil {
|
||||
if fromTime != nil {
|
||||
dateFrom = *fromTime
|
||||
}
|
||||
if toTime != nil {
|
||||
dateTo = *toTime
|
||||
}
|
||||
}
|
||||
|
||||
return &models.ProductAnalyticsPerParentCategoryRequest{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: parseOutletID(req.OutletID),
|
||||
DateFrom: dateFrom,
|
||||
DateTo: dateTo,
|
||||
}
|
||||
}
|
||||
|
||||
// ProductAnalyticsPerParentCategoryModelToContract converts model response to contract
|
||||
func ProductAnalyticsPerParentCategoryModelToContract(resp *models.ProductAnalyticsPerParentCategoryResponse) *contract.ProductAnalyticsPerParentCategoryResponse {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var data []contract.ProductAnalyticsPerParentCategoryData
|
||||
for _, item := range resp.Data {
|
||||
data = append(data, contract.ProductAnalyticsPerParentCategoryData{
|
||||
ParentCategoryID: item.ParentCategoryID,
|
||||
ParentCategoryName: item.ParentCategoryName,
|
||||
TotalRevenue: item.TotalRevenue,
|
||||
TotalQuantity: item.TotalQuantity,
|
||||
CategoryCount: item.CategoryCount,
|
||||
ProductCount: item.ProductCount,
|
||||
OrderCount: item.OrderCount,
|
||||
TotalStandardHpp: item.TotalStandardHpp,
|
||||
TotalFifoHpp: item.TotalFifoHpp,
|
||||
TotalMovingAverageHpp: item.TotalMovingAverageHpp,
|
||||
})
|
||||
}
|
||||
|
||||
return &contract.ProductAnalyticsPerParentCategoryResponse{
|
||||
OrganizationID: resp.OrganizationID,
|
||||
OutletID: resp.OutletID,
|
||||
OutletName: resp.OutletName,
|
||||
DateFrom: resp.DateFrom,
|
||||
DateTo: resp.DateTo,
|
||||
Data: data,
|
||||
Budget: BudgetCutOffModelToContract(resp.Budget),
|
||||
}
|
||||
}
|
||||
|
||||
// budgetPeriodModelToContract converts one budget period to contract
|
||||
func budgetPeriodModelToContract(period models.BudgetPeriod) contract.BudgetPeriod {
|
||||
return contract.BudgetPeriod{
|
||||
PeriodStart: period.PeriodStart,
|
||||
PeriodEnd: period.PeriodEnd,
|
||||
Revenue: period.Revenue,
|
||||
OrderCount: period.OrderCount,
|
||||
LimitPurchase: period.LimitPurchase,
|
||||
LimitOwner: period.LimitOwner,
|
||||
LimitTeam: period.LimitTeam,
|
||||
}
|
||||
}
|
||||
|
||||
// BudgetCutOffModelToContract converts the budget cut-off block to contract
|
||||
func BudgetCutOffModelToContract(budget models.BudgetCutOff) contract.BudgetCutOff {
|
||||
weekly := make([]contract.BudgetPeriod, 0, len(budget.Weekly))
|
||||
for _, week := range budget.Weekly {
|
||||
weekly = append(weekly, budgetPeriodModelToContract(week))
|
||||
}
|
||||
|
||||
monthly := make([]contract.BudgetMonthPeriod, 0, len(budget.Monthly))
|
||||
for _, month := range budget.Monthly {
|
||||
monthly = append(monthly, contract.BudgetMonthPeriod{
|
||||
Month: month.Month,
|
||||
WeekCount: month.WeekCount,
|
||||
BudgetPeriod: budgetPeriodModelToContract(month.BudgetPeriod),
|
||||
})
|
||||
}
|
||||
|
||||
return contract.BudgetCutOff{
|
||||
Percentages: contract.BudgetPercentages{
|
||||
Purchase: budget.Percentages.Purchase,
|
||||
Owner: budget.Percentages.Owner,
|
||||
Team: budget.Percentages.Team,
|
||||
},
|
||||
CutOffFrom: budget.CutOffFrom,
|
||||
CutOffTo: budget.CutOffTo,
|
||||
Total: budgetPeriodModelToContract(budget.Total),
|
||||
Weekly: weekly,
|
||||
Monthly: monthly,
|
||||
}
|
||||
}
|
||||
|
||||
// ParentCategoryAnalyticsDetailContractToModel converts contract request to model
|
||||
func ParentCategoryAnalyticsDetailContractToModel(req *contract.ParentCategoryAnalyticsDetailRequest) *models.ParentCategoryAnalyticsDetailRequest {
|
||||
var dateFrom, dateTo time.Time
|
||||
|
||||
// Parse date range using utility function
|
||||
if fromTime, toTime, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo); err == nil {
|
||||
if fromTime != nil {
|
||||
dateFrom = *fromTime
|
||||
}
|
||||
if toTime != nil {
|
||||
dateTo = *toTime
|
||||
}
|
||||
}
|
||||
|
||||
// An unparseable id stays uuid.Nil and is rejected by the service validator
|
||||
parentCategoryID, _ := uuid.Parse(req.ParentCategoryID)
|
||||
|
||||
return &models.ParentCategoryAnalyticsDetailRequest{
|
||||
OrganizationID: req.OrganizationID,
|
||||
ParentCategoryID: parentCategoryID,
|
||||
OutletID: parseOutletID(req.OutletID),
|
||||
DateFrom: dateFrom,
|
||||
DateTo: dateTo,
|
||||
}
|
||||
}
|
||||
|
||||
// ParentCategoryAnalyticsDetailModelToContract converts model response to contract
|
||||
func ParentCategoryAnalyticsDetailModelToContract(resp *models.ParentCategoryAnalyticsDetailResponse) *contract.ParentCategoryAnalyticsDetailResponse {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
categories := make([]contract.ParentCategoryAnalyticsDetailData, 0, len(resp.Categories))
|
||||
for _, category := range resp.Categories {
|
||||
products := make([]contract.ParentCategoryAnalyticsProductData, 0, len(category.Products))
|
||||
for _, product := range category.Products {
|
||||
products = append(products, contract.ParentCategoryAnalyticsProductData{
|
||||
ProductID: product.ProductID,
|
||||
ProductName: product.ProductName,
|
||||
ProductSku: product.ProductSku,
|
||||
ProductPrice: product.ProductPrice,
|
||||
QuantitySold: product.QuantitySold,
|
||||
Revenue: product.Revenue,
|
||||
AveragePrice: product.AveragePrice,
|
||||
OrderCount: product.OrderCount,
|
||||
StandardHppPerUnit: product.StandardHppPerUnit,
|
||||
StandardHppTotal: product.StandardHppTotal,
|
||||
FifoHppPerUnit: product.FifoHppPerUnit,
|
||||
FifoHppTotal: product.FifoHppTotal,
|
||||
MovingAverageHppPerUnit: product.MovingAverageHppPerUnit,
|
||||
MovingAverageHppTotal: product.MovingAverageHppTotal,
|
||||
})
|
||||
}
|
||||
|
||||
categories = append(categories, contract.ParentCategoryAnalyticsDetailData{
|
||||
CategoryID: category.CategoryID,
|
||||
CategoryName: category.CategoryName,
|
||||
TotalRevenue: category.TotalRevenue,
|
||||
TotalQuantity: category.TotalQuantity,
|
||||
ProductCount: category.ProductCount,
|
||||
OrderCount: category.OrderCount,
|
||||
TotalStandardHpp: category.TotalStandardHpp,
|
||||
TotalFifoHpp: category.TotalFifoHpp,
|
||||
TotalMovingAverageHpp: category.TotalMovingAverageHpp,
|
||||
Products: products,
|
||||
})
|
||||
}
|
||||
|
||||
return &contract.ParentCategoryAnalyticsDetailResponse{
|
||||
OrganizationID: resp.OrganizationID,
|
||||
OutletID: resp.OutletID,
|
||||
OutletName: resp.OutletName,
|
||||
DateFrom: resp.DateFrom,
|
||||
DateTo: resp.DateTo,
|
||||
ParentCategoryID: resp.ParentCategoryID,
|
||||
ParentCategoryName: resp.ParentCategoryName,
|
||||
Summary: contract.ParentCategoryAnalyticsDetailSummary{
|
||||
TotalRevenue: resp.Summary.TotalRevenue,
|
||||
TotalQuantity: resp.Summary.TotalQuantity,
|
||||
CategoryCount: resp.Summary.CategoryCount,
|
||||
ProductCount: resp.Summary.ProductCount,
|
||||
OrderCount: resp.Summary.OrderCount,
|
||||
TotalStandardHpp: resp.Summary.TotalStandardHpp,
|
||||
TotalFifoHpp: resp.Summary.TotalFifoHpp,
|
||||
TotalMovingAverageHpp: resp.Summary.TotalMovingAverageHpp,
|
||||
},
|
||||
Categories: categories,
|
||||
Budget: BudgetCutOffModelToContract(resp.Budget),
|
||||
}
|
||||
}
|
||||
|
||||
// DashboardAnalyticsContractToModel converts contract request to model
|
||||
func DashboardAnalyticsContractToModel(req *contract.DashboardAnalyticsRequest) *models.DashboardAnalyticsRequest {
|
||||
var dateFrom, dateTo time.Time
|
||||
@@ -421,15 +633,19 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse)
|
||||
return &contract.DashboardAnalyticsResponse{
|
||||
OrganizationID: resp.OrganizationID,
|
||||
OutletID: resp.OutletID,
|
||||
OutletName: resp.OutletName,
|
||||
DateFrom: resp.DateFrom,
|
||||
DateTo: resp.DateTo,
|
||||
Overview: contract.DashboardOverview{
|
||||
TotalSales: resp.Overview.TotalSales,
|
||||
TotalOrders: resp.Overview.TotalOrders,
|
||||
AverageOrderValue: resp.Overview.AverageOrderValue,
|
||||
TotalCustomers: resp.Overview.TotalCustomers,
|
||||
VoidedOrders: resp.Overview.VoidedOrders,
|
||||
RefundedOrders: resp.Overview.RefundedOrders,
|
||||
TotalSales: resp.Overview.TotalSales,
|
||||
TotalOrders: resp.Overview.TotalOrders,
|
||||
AverageOrderValue: resp.Overview.AverageOrderValue,
|
||||
TotalCustomers: resp.Overview.TotalCustomers,
|
||||
VoidedOrders: resp.Overview.VoidedOrders,
|
||||
RefundedOrders: resp.Overview.RefundedOrders,
|
||||
TotalItemSold: resp.Overview.TotalItemSold,
|
||||
TotalLowStock: resp.Overview.TotalLowStock,
|
||||
TotalProductActive: resp.Overview.TotalProductActive,
|
||||
},
|
||||
TopProducts: topProducts,
|
||||
PaymentMethods: paymentMethods,
|
||||
@@ -516,9 +732,20 @@ func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse
|
||||
}
|
||||
}
|
||||
|
||||
purchasingItems := make([]contract.ProfitLossPurchasingItem, len(resp.Purchasing.Items))
|
||||
for i, item := range resp.Purchasing.Items {
|
||||
purchasingItems[i] = contract.ProfitLossPurchasingItem{
|
||||
Date: item.Date,
|
||||
Item: item.Item,
|
||||
Quantity: item.Quantity,
|
||||
Nominal: item.Nominal,
|
||||
}
|
||||
}
|
||||
|
||||
return &contract.ProfitLossAnalyticsResponse{
|
||||
OrganizationID: resp.OrganizationID,
|
||||
OutletID: resp.OutletID,
|
||||
OutletName: resp.OutletName,
|
||||
DateFrom: resp.DateFrom,
|
||||
DateTo: resp.DateTo,
|
||||
GroupBy: resp.GroupBy,
|
||||
@@ -535,9 +762,18 @@ func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse
|
||||
AverageProfit: resp.Summary.AverageProfit,
|
||||
ProfitabilityRatio: resp.Summary.ProfitabilityRatio,
|
||||
},
|
||||
Data: data,
|
||||
ProductData: productData,
|
||||
MainSummary: mainSummary,
|
||||
Data: data,
|
||||
ProductData: productData,
|
||||
MainSummary: mainSummary,
|
||||
Purchasing: contract.ProfitLossPurchasing{
|
||||
TodayTotal: resp.Purchasing.TodayTotal,
|
||||
MtdTotal: resp.Purchasing.MtdTotal,
|
||||
TodayRawMaterial: resp.Purchasing.TodayRawMaterial,
|
||||
MtdRawMaterial: resp.Purchasing.MtdRawMaterial,
|
||||
TodayExpense: resp.Purchasing.TodayExpense,
|
||||
MtdExpense: resp.Purchasing.MtdExpense,
|
||||
Items: purchasingItems,
|
||||
},
|
||||
OperationalExpenses: opsItems,
|
||||
OperationalExpensesTotal: resp.OperationalExpensesTotal,
|
||||
}
|
||||
@@ -559,3 +795,268 @@ func profitLossSummaryRowModelToContract(row models.ProfitLossSummaryRow) contra
|
||||
SubItems: subItems,
|
||||
}
|
||||
}
|
||||
|
||||
func ExclusiveSummaryPeriodContractToModel(req *contract.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodRequest, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("request cannot be nil")
|
||||
}
|
||||
|
||||
dateFrom, dateTo, err := parseFlexibleDateRangeToJakartaTime(req.DateFrom, req.DateTo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid date range: %w", err)
|
||||
}
|
||||
|
||||
if dateFrom == nil {
|
||||
return nil, fmt.Errorf("date_from is required")
|
||||
}
|
||||
|
||||
if dateTo == nil {
|
||||
return nil, fmt.Errorf("date_to is required")
|
||||
}
|
||||
|
||||
return &models.ExclusiveSummaryPeriodRequest{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: parseOutletID(req.OutletID),
|
||||
DateFrom: *dateFrom,
|
||||
DateTo: *dateTo,
|
||||
ExcludeGajiStaffFromReimburse: req.ExcludeGajiStaffFromReimburse,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ExclusiveSummaryMonthlyContractToModel(req *contract.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyRequest, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("request cannot be nil")
|
||||
}
|
||||
|
||||
month, err := parseMonthToJakartaTime(req.Month)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid month: %w", err)
|
||||
}
|
||||
|
||||
return &models.ExclusiveSummaryMonthlyRequest{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: parseOutletID(req.OutletID),
|
||||
Month: month,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ExclusiveSummaryMTDContractToModel(req *contract.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryMTDRequest, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("request cannot be nil")
|
||||
}
|
||||
|
||||
dateTo, err := parseFlexibleDateToJakartaTime(req.DateTo, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid date_to: %w", err)
|
||||
}
|
||||
if dateTo == nil {
|
||||
return nil, fmt.Errorf("date_to is required")
|
||||
}
|
||||
|
||||
return &models.ExclusiveSummaryMTDRequest{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: parseOutletID(req.OutletID),
|
||||
DateTo: *dateTo,
|
||||
ExcludeGajiStaffFromReimburse: req.ExcludeGajiStaffFromReimburse,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ExclusiveSummaryPeriodModelToContract(resp *models.ExclusiveSummaryPeriodResponse) *contract.ExclusiveSummaryPeriodResponse {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
hppBreakdown := make([]contract.ExclusiveSummaryCategoryBreakdown, len(resp.HPPBreakdown))
|
||||
for i, item := range resp.HPPBreakdown {
|
||||
hppBreakdown[i] = exclusiveSummaryCategoryBreakdownModelToContract(item)
|
||||
}
|
||||
|
||||
operationalBreakdown := make([]contract.ExclusiveSummaryCategoryBreakdown, len(resp.OperationalExpenseBreakdown))
|
||||
for i, item := range resp.OperationalExpenseBreakdown {
|
||||
operationalBreakdown[i] = exclusiveSummaryCategoryBreakdownModelToContract(item)
|
||||
}
|
||||
|
||||
dailySummary := make([]contract.ExclusiveSummaryDailySummary, len(resp.DailySummary))
|
||||
for i, item := range resp.DailySummary {
|
||||
dailySummary[i] = contract.ExclusiveSummaryDailySummary{
|
||||
Date: item.Date,
|
||||
TransactionCount: item.TransactionCount,
|
||||
TotalCost: item.TotalCost,
|
||||
}
|
||||
}
|
||||
|
||||
dailyTransactions := make([]contract.ExclusiveSummaryDailyTransaction, len(resp.DailyTransactions))
|
||||
for i, item := range resp.DailyTransactions {
|
||||
dailyTransactions[i] = contract.ExclusiveSummaryDailyTransaction{
|
||||
Date: item.Date,
|
||||
CategoryCode: item.CategoryCode,
|
||||
CategoryName: item.CategoryName,
|
||||
Description: item.Description,
|
||||
Amount: item.Amount,
|
||||
Source: item.Source,
|
||||
}
|
||||
}
|
||||
|
||||
return &contract.ExclusiveSummaryPeriodResponse{
|
||||
OrganizationID: resp.OrganizationID,
|
||||
OutletID: resp.OutletID,
|
||||
OutletName: resp.OutletName,
|
||||
Period: contract.ExclusiveSummaryPeriodRange{
|
||||
DateFrom: resp.Period.DateFrom,
|
||||
DateTo: resp.Period.DateTo,
|
||||
},
|
||||
Summary: contract.ExclusiveSummaryPeriodSummary{
|
||||
Sales: resp.Summary.Sales,
|
||||
HPP: resp.Summary.HPP,
|
||||
GrossProfit: resp.Summary.GrossProfit,
|
||||
SalaryTotal: resp.Summary.SalaryTotal,
|
||||
SalaryDW: resp.Summary.SalaryDW,
|
||||
SalaryStaff: resp.Summary.SalaryStaff,
|
||||
SalaryOther: resp.Summary.SalaryOther,
|
||||
OtherOperationalExpenses: resp.Summary.OtherOperationalExpenses,
|
||||
OperationalExpensesTotal: resp.Summary.OperationalExpensesTotal,
|
||||
TotalCost: resp.Summary.TotalCost,
|
||||
NetProfit: resp.Summary.NetProfit,
|
||||
},
|
||||
Reimburse: contract.ExclusiveSummaryReimburse{
|
||||
TotalCost: resp.Reimburse.TotalCost,
|
||||
ExcludedSalaryStaff: resp.Reimburse.ExcludedSalaryStaff,
|
||||
TotalReimburse: resp.Reimburse.TotalReimburse,
|
||||
},
|
||||
HPPBreakdown: hppBreakdown,
|
||||
OperationalExpenseBreakdown: operationalBreakdown,
|
||||
DailySummary: dailySummary,
|
||||
DailyTransactions: dailyTransactions,
|
||||
}
|
||||
}
|
||||
|
||||
func ExclusiveSummaryMonthlyModelToContract(resp *models.ExclusiveSummaryMonthlyResponse) *contract.ExclusiveSummaryMonthlyResponse {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
periods := make([]contract.ExclusiveSummaryMonthlyPeriod, len(resp.Periods))
|
||||
for i, item := range resp.Periods {
|
||||
periods[i] = contract.ExclusiveSummaryMonthlyPeriod{
|
||||
Label: item.Label,
|
||||
DateFrom: item.DateFrom,
|
||||
DateTo: item.DateTo,
|
||||
Sales: item.Sales,
|
||||
HPP: item.HPP,
|
||||
GrossProfit: item.GrossProfit,
|
||||
GrossMargin: item.GrossMargin,
|
||||
}
|
||||
}
|
||||
|
||||
bankBalance := make([]contract.ExclusiveSummaryBankBalance, len(resp.BankBalance))
|
||||
for i, item := range resp.BankBalance {
|
||||
bankBalance[i] = contract.ExclusiveSummaryBankBalance{
|
||||
Bank: item.Bank,
|
||||
OpeningBalance: item.OpeningBalance,
|
||||
IncomingMutation: item.IncomingMutation,
|
||||
OutgoingMutation: item.OutgoingMutation,
|
||||
ClosingBalance: item.ClosingBalance,
|
||||
Notes: item.Notes,
|
||||
}
|
||||
}
|
||||
|
||||
return &contract.ExclusiveSummaryMonthlyResponse{
|
||||
OrganizationID: resp.OrganizationID,
|
||||
OutletID: resp.OutletID,
|
||||
OutletName: resp.OutletName,
|
||||
Month: resp.Month,
|
||||
Summary: contract.ExclusiveSummaryMonthlySummary{
|
||||
TotalSales: resp.Summary.TotalSales,
|
||||
HPP: resp.Summary.HPP,
|
||||
GrossProfit: resp.Summary.GrossProfit,
|
||||
OperationalExpensesTotal: resp.Summary.OperationalExpensesTotal,
|
||||
TotalCost: resp.Summary.TotalCost,
|
||||
NetProfit: resp.Summary.NetProfit,
|
||||
NetProfitMargin: resp.Summary.NetProfitMargin,
|
||||
},
|
||||
Periods: periods,
|
||||
BankBalance: bankBalance,
|
||||
}
|
||||
}
|
||||
|
||||
func exclusiveSummaryCategoryBreakdownModelToContract(item models.ExclusiveSummaryCategoryBreakdown) contract.ExclusiveSummaryCategoryBreakdown {
|
||||
return contract.ExclusiveSummaryCategoryBreakdown{
|
||||
CategoryCode: item.CategoryCode,
|
||||
CategoryName: item.CategoryName,
|
||||
Amount: item.Amount,
|
||||
Percentage: item.Percentage,
|
||||
}
|
||||
}
|
||||
|
||||
func parseFlexibleDateRangeToJakartaTime(dateFrom, dateTo string) (*time.Time, *time.Time, error) {
|
||||
fromTime, toTime, err := util.ParseDateRangeToJakartaTime(dateFrom, dateTo)
|
||||
if err == nil {
|
||||
return fromTime, toTime, nil
|
||||
}
|
||||
|
||||
fromTime, err = parseISODateToJakartaTime(dateFrom, false)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
toTime, err = parseISODateToJakartaTime(dateTo, true)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return fromTime, toTime, nil
|
||||
}
|
||||
|
||||
func parseISODateToJakartaTime(dateStr string, endOfDay bool) (*time.Time, error) {
|
||||
if dateStr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
date, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
location, err := time.LoadLocation("Asia/Jakarta")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if endOfDay {
|
||||
result := time.Date(date.Year(), date.Month(), date.Day(), 23, 59, 59, 999999999, location)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
result := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func parseFlexibleDateToJakartaTime(dateStr string, endOfDay bool) (*time.Time, error) {
|
||||
if dateStr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
fromTime, toTime, err := util.ParseDateRangeToJakartaTime(dateStr, dateStr)
|
||||
if err == nil {
|
||||
if endOfDay {
|
||||
return toTime, nil
|
||||
}
|
||||
return fromTime, nil
|
||||
}
|
||||
|
||||
return parseISODateToJakartaTime(dateStr, endOfDay)
|
||||
}
|
||||
|
||||
func parseMonthToJakartaTime(month string) (time.Time, error) {
|
||||
location, err := time.LoadLocation("Asia/Jakarta")
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
|
||||
parsed, err := time.ParseInLocation("2006-01", month, location)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
|
||||
return time.Date(parsed.Year(), parsed.Month(), 1, 0, 0, 0, 0, location), nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/models"
|
||||
|
||||
@@ -95,6 +96,49 @@ func TestPurchasingAnalyticsModelToContractCopiesOutletName(t *testing.T) {
|
||||
require.Equal(t, float64(175), result.Data[0].ExpensePurchases)
|
||||
}
|
||||
|
||||
func TestPurchasingAnalyticsModelToContractCopiesTeamData(t *testing.T) {
|
||||
categoryID := uuid.New()
|
||||
|
||||
result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{
|
||||
OrganizationID: uuid.New(),
|
||||
Team: categoryID.String(),
|
||||
Summary: models.PurchasingSummary{TotalPurchases: 300, TotalTeams: 2},
|
||||
TeamData: []models.PurchasingTeamData{
|
||||
{
|
||||
Scope: constants.PurchaseTeamScopeCategory,
|
||||
CategoryID: &categoryID,
|
||||
Name: "Kitchen",
|
||||
TotalPurchases: 200,
|
||||
RawMaterialPurchases: 150,
|
||||
ExpensePurchases: 50,
|
||||
PurchaseOrderCount: 2,
|
||||
Quantity: 12,
|
||||
Percentage: 66.67,
|
||||
},
|
||||
{
|
||||
Scope: constants.PurchaseTeamNone,
|
||||
Name: constants.PurchaseTeamNoneName,
|
||||
TotalPurchases: 100,
|
||||
PurchaseOrderCount: 1,
|
||||
Percentage: 33.33,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, categoryID.String(), result.Team)
|
||||
require.Equal(t, int64(2), result.Summary.TotalTeams)
|
||||
require.Len(t, result.TeamData, 2)
|
||||
require.Equal(t, constants.PurchaseTeamScopeCategory, result.TeamData[0].Scope)
|
||||
require.Equal(t, &categoryID, result.TeamData[0].CategoryID)
|
||||
require.Equal(t, "Kitchen", result.TeamData[0].Name)
|
||||
require.Equal(t, float64(200), result.TeamData[0].TotalPurchases)
|
||||
require.Equal(t, 66.67, result.TeamData[0].Percentage)
|
||||
require.Equal(t, constants.PurchaseTeamNone, result.TeamData[1].Scope)
|
||||
require.Nil(t, result.TeamData[1].CategoryID)
|
||||
require.Equal(t, constants.PurchaseTeamNoneName, result.TeamData[1].Name)
|
||||
}
|
||||
|
||||
func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) {
|
||||
result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{
|
||||
OrganizationID: uuid.New(),
|
||||
@@ -182,3 +226,43 @@ func TestProfitLossAnalyticsModelToContractCopiesDateRange(t *testing.T) {
|
||||
require.Len(t, result.MainSummary, 1)
|
||||
require.Equal(t, "total_omset", result.MainSummary[0].ID)
|
||||
}
|
||||
|
||||
func TestExclusiveSummaryPeriodContractToModelParsesFlexibleDates(t *testing.T) {
|
||||
orgID := uuid.New()
|
||||
outletID := uuid.New().String()
|
||||
|
||||
result, err := ExclusiveSummaryPeriodContractToModel(&contract.ExclusiveSummaryPeriodRequest{
|
||||
OrganizationID: orgID,
|
||||
OutletID: &outletID,
|
||||
DateFrom: "2026-05-26",
|
||||
DateTo: "2026-05-31",
|
||||
ExcludeGajiStaffFromReimburse: true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, orgID, result.OrganizationID)
|
||||
require.NotNil(t, result.OutletID)
|
||||
require.Equal(t, outletID, result.OutletID.String())
|
||||
require.True(t, result.ExcludeGajiStaffFromReimburse)
|
||||
|
||||
location, err := time.LoadLocation("Asia/Jakarta")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, time.Date(2026, 5, 26, 0, 0, 0, 0, location), result.DateFrom)
|
||||
require.Equal(t, time.Date(2026, 5, 31, 23, 59, 59, int(time.Second-time.Nanosecond), location), result.DateTo)
|
||||
}
|
||||
|
||||
func TestExclusiveSummaryMonthlyContractToModelParsesMonth(t *testing.T) {
|
||||
orgID := uuid.New()
|
||||
|
||||
result, err := ExclusiveSummaryMonthlyContractToModel(&contract.ExclusiveSummaryMonthlyRequest{
|
||||
OrganizationID: orgID,
|
||||
Month: "2026-05",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, orgID, result.OrganizationID)
|
||||
|
||||
location, err := time.LoadLocation("Asia/Jakarta")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, time.Date(2026, 5, 1, 0, 0, 0, 0, location), result.Month)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ func CreateCategoryRequestToModel(apctx *appcontext.ContextInfo, req *contract.C
|
||||
return &models.CreateCategoryRequest{
|
||||
OrganizationID: apctx.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
ParentID: req.ParentID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
ImageURL: nil,
|
||||
@@ -27,6 +28,7 @@ func UpdateCategoryRequestToModel(req *contract.UpdateCategoryRequest) *models.U
|
||||
Description: req.Description,
|
||||
ImageURL: nil,
|
||||
OutletID: req.OutletID,
|
||||
ParentID: req.ParentID,
|
||||
Order: req.Order,
|
||||
IsActive: nil,
|
||||
}
|
||||
@@ -41,6 +43,8 @@ func CategoryModelResponseToResponse(cat *models.CategoryResponse) *contract.Cat
|
||||
ID: cat.ID,
|
||||
OrganizationID: cat.OrganizationID,
|
||||
OutletID: cat.OutletID,
|
||||
ParentID: cat.ParentID,
|
||||
ParentName: cat.ParentName,
|
||||
Name: cat.Name,
|
||||
Description: cat.Description,
|
||||
BusinessType: "restaurant",
|
||||
|
||||
@@ -44,6 +44,8 @@ func CreatePurchaseOrderRequestToModel(req *contract.CreatePurchaseOrderRequest)
|
||||
Reference: req.Reference,
|
||||
Status: req.Status,
|
||||
Message: req.Message,
|
||||
TeamScope: req.TeamScope,
|
||||
TeamCategoryID: req.TeamCategoryID,
|
||||
Items: items,
|
||||
AttachmentFileIDs: req.AttachmentFileIDs,
|
||||
}, nil
|
||||
@@ -94,6 +96,8 @@ func UpdatePurchaseOrderRequestToModel(req *contract.UpdatePurchaseOrderRequest)
|
||||
Reference: req.Reference,
|
||||
Status: req.Status,
|
||||
Message: req.Message,
|
||||
TeamScope: req.TeamScope,
|
||||
TeamCategoryID: req.TeamCategoryID,
|
||||
Items: items,
|
||||
AttachmentFileIDs: req.AttachmentFileIDs,
|
||||
}, nil
|
||||
@@ -101,16 +105,40 @@ func UpdatePurchaseOrderRequestToModel(req *contract.UpdatePurchaseOrderRequest)
|
||||
|
||||
func ListPurchaseOrdersRequestToModel(req *contract.ListPurchaseOrdersRequest) *models.ListPurchaseOrdersRequest {
|
||||
return &models.ListPurchaseOrdersRequest{
|
||||
Page: req.Page,
|
||||
Limit: req.Limit,
|
||||
Search: req.Search,
|
||||
Status: req.Status,
|
||||
VendorID: req.VendorID,
|
||||
StartDate: req.StartDate,
|
||||
EndDate: req.EndDate,
|
||||
Page: req.Page,
|
||||
Limit: req.Limit,
|
||||
Search: req.Search,
|
||||
Status: req.Status,
|
||||
VendorID: req.VendorID,
|
||||
Team: req.Team,
|
||||
TeamScope: req.TeamScope,
|
||||
TeamCategoryID: req.TeamCategoryID,
|
||||
StartDate: req.StartDate,
|
||||
EndDate: req.EndDate,
|
||||
}
|
||||
}
|
||||
|
||||
func PurchaseTeamModelToResponse(team *models.PurchaseTeam) *contract.PurchaseTeamResponse {
|
||||
if team == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &contract.PurchaseTeamResponse{
|
||||
Scope: team.Scope,
|
||||
CategoryID: team.CategoryID,
|
||||
Name: team.Name,
|
||||
}
|
||||
}
|
||||
|
||||
func ListPurchaseTeamsModelResponseToResponse(resp *models.ListPurchaseTeamsResponse) *contract.ListPurchaseTeamsResponse {
|
||||
teams := make([]contract.PurchaseTeamResponse, len(resp.Teams))
|
||||
for i, team := range resp.Teams {
|
||||
teams[i] = *PurchaseTeamModelToResponse(&team)
|
||||
}
|
||||
|
||||
return &contract.ListPurchaseTeamsResponse{Teams: teams}
|
||||
}
|
||||
|
||||
// Model to Contract conversions
|
||||
func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *contract.PurchaseOrderResponse {
|
||||
if po == nil {
|
||||
@@ -120,6 +148,7 @@ func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *con
|
||||
response := &contract.PurchaseOrderResponse{
|
||||
ID: po.ID,
|
||||
OrganizationID: po.OrganizationID,
|
||||
OutletID: po.OutletID,
|
||||
VendorID: po.VendorID,
|
||||
PONumber: po.PONumber,
|
||||
TransactionDate: po.TransactionDate,
|
||||
@@ -128,8 +157,11 @@ func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *con
|
||||
Status: po.Status,
|
||||
Message: po.Message,
|
||||
TotalAmount: po.TotalAmount,
|
||||
TeamScope: po.TeamScope,
|
||||
TeamCategoryID: po.TeamCategoryID,
|
||||
CreatedAt: po.CreatedAt,
|
||||
UpdatedAt: po.UpdatedAt,
|
||||
Team: PurchaseTeamModelToResponse(po.Team),
|
||||
}
|
||||
|
||||
// Map vendor if present
|
||||
|
||||
@@ -12,12 +12,13 @@ import (
|
||||
)
|
||||
|
||||
func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) {
|
||||
vendorID := uuid.New()
|
||||
ingredientID := uuid.New()
|
||||
quantity := 1.0
|
||||
unitID := uuid.New()
|
||||
|
||||
result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{
|
||||
VendorID: uuid.New(),
|
||||
VendorID: &vendorID,
|
||||
PONumber: "PO-001",
|
||||
TransactionDate: "2026-05-29",
|
||||
Items: []contract.CreatePurchaseOrderItemRequest{
|
||||
@@ -35,9 +36,10 @@ func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPurchaseOrderModelResponseToResponseIncludesNullDueDate(t *testing.T) {
|
||||
vendorID := uuid.New()
|
||||
result := PurchaseOrderModelResponseToResponse(&models.PurchaseOrderResponse{
|
||||
ID: uuid.New(),
|
||||
VendorID: uuid.New(),
|
||||
VendorID: &vendorID,
|
||||
PONumber: "PO-001",
|
||||
})
|
||||
|
||||
@@ -45,3 +47,19 @@ func TestPurchaseOrderModelResponseToResponseIncludesNullDueDate(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(payload), `"due_date":null`)
|
||||
}
|
||||
|
||||
func TestCreatePurchaseOrderRequestToModelAllowsMissingVendor(t *testing.T) {
|
||||
result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{
|
||||
PONumber: "PO-001",
|
||||
TransactionDate: "2026-05-29",
|
||||
Items: []contract.CreatePurchaseOrderItemRequest{
|
||||
{
|
||||
PurchaseCategoryID: uuid.New(),
|
||||
Amount: 1000,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, result.VendorID)
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ func (v *CategoryValidatorImpl) ValidateUpdateCategoryRequest(req *contract.Upda
|
||||
}
|
||||
|
||||
// At least one field should be provided for update
|
||||
if req.Name == nil && req.Description == nil && req.BusinessType == nil && req.Metadata == nil {
|
||||
if req.Name == nil && req.Description == nil && req.BusinessType == nil && req.ParentID == nil && req.Metadata == nil {
|
||||
return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode
|
||||
}
|
||||
|
||||
@@ -118,5 +118,9 @@ func (v *CategoryValidatorImpl) ValidateListCategoriesRequest(req *contract.List
|
||||
}
|
||||
}
|
||||
|
||||
if req.Type != "" && req.Type != "parent" && req.Type != "child" {
|
||||
return errors.New("type must be either 'parent' or 'child'"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ func (v *PurchaseOrderValidatorImpl) ValidateCreatePurchaseOrderRequest(req *con
|
||||
return errors.New("request body is required"), constants.MissingFieldErrorCode
|
||||
}
|
||||
|
||||
if req.VendorID == uuid.Nil {
|
||||
return errors.New("vendor_id is required"), constants.MissingFieldErrorCode
|
||||
if req.VendorID != nil && *req.VendorID == uuid.Nil {
|
||||
return errors.New("vendor_id cannot be empty"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.PONumber) == "" {
|
||||
@@ -76,6 +76,10 @@ func (v *PurchaseOrderValidatorImpl) ValidateCreatePurchaseOrderRequest(req *con
|
||||
}
|
||||
}
|
||||
|
||||
if err, code := validatePurchaseTeamSelection(req.TeamScope, req.TeamCategoryID, false); err != nil {
|
||||
return err, code
|
||||
}
|
||||
|
||||
if len(req.Items) == 0 {
|
||||
return errors.New("at least one item is required"), constants.MissingFieldErrorCode
|
||||
}
|
||||
@@ -139,6 +143,10 @@ func (v *PurchaseOrderValidatorImpl) ValidateUpdatePurchaseOrderRequest(req *con
|
||||
}
|
||||
}
|
||||
|
||||
if err, code := validatePurchaseTeamSelection(req.TeamScope, req.TeamCategoryID, true); err != nil {
|
||||
return err, code
|
||||
}
|
||||
|
||||
// Validate items if provided
|
||||
if req.Items != nil {
|
||||
for i, item := range req.Items {
|
||||
@@ -151,6 +159,55 @@ func (v *PurchaseOrderValidatorImpl) ValidateUpdatePurchaseOrderRequest(req *con
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// validatePurchaseTeamSelection keeps team_scope and team_category_id in step with
|
||||
// the database check constraint: a category team needs a category, Pusat must not
|
||||
// carry one. allowClear lets an update send an empty scope to drop the team.
|
||||
func validatePurchaseTeamSelection(scope *string, categoryID *uuid.UUID, allowClear bool) (error, string) {
|
||||
if scope == nil {
|
||||
if categoryID != nil {
|
||||
return errors.New("team_scope is required when team_category_id is provided"), constants.MissingFieldErrorCode
|
||||
}
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(*scope) {
|
||||
case "":
|
||||
if !allowClear {
|
||||
return errors.New("team_scope must be one of: category, central"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
if categoryID != nil {
|
||||
return errors.New("team_category_id must be empty when clearing the team"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
case constants.PurchaseTeamScopeCategory:
|
||||
if categoryID == nil || *categoryID == uuid.Nil {
|
||||
return errors.New("team_category_id is required when team_scope is category"), constants.MissingFieldErrorCode
|
||||
}
|
||||
case constants.PurchaseTeamScopeCentral:
|
||||
if categoryID != nil {
|
||||
return errors.New("team_category_id must be empty when team_scope is central"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
default:
|
||||
return errors.New("team_scope must be one of: category, central"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// validatePurchaseTeamFilter accepts the values the team picker hands back: Pusat,
|
||||
// no team at all, or the id of the parent category a purchase is charged to.
|
||||
func validatePurchaseTeamFilter(team string) (error, string) {
|
||||
switch team {
|
||||
case constants.PurchaseTeamScopeCentral, constants.PurchaseTeamNone:
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
if categoryID, err := uuid.Parse(team); err != nil || categoryID == uuid.Nil {
|
||||
return errors.New("team must be one of: central, none, or a category id"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *contract.ListPurchaseOrdersRequest) (error, string) {
|
||||
if req == nil {
|
||||
return errors.New("request body is required"), constants.MissingFieldErrorCode
|
||||
@@ -171,6 +228,31 @@ func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *cont
|
||||
}
|
||||
}
|
||||
|
||||
if req.Team != "" {
|
||||
if req.TeamScope != "" || req.TeamCategoryID != nil {
|
||||
return errors.New("team cannot be combined with team_scope or team_category_id"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
|
||||
if err, code := validatePurchaseTeamFilter(req.Team); err != nil {
|
||||
return err, code
|
||||
}
|
||||
}
|
||||
|
||||
if req.TeamScope != "" {
|
||||
validScopes := []string{constants.PurchaseTeamScopeCategory, constants.PurchaseTeamScopeCentral}
|
||||
if !contains(validScopes, req.TeamScope) {
|
||||
return errors.New("team_scope must be one of: category, central"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
|
||||
if req.TeamScope == constants.PurchaseTeamScopeCentral && req.TeamCategoryID != nil {
|
||||
return errors.New("team_category_id must be empty when team_scope is central"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
}
|
||||
|
||||
if req.TeamCategoryID != nil && *req.TeamCategoryID == uuid.Nil {
|
||||
return errors.New("team_category_id cannot be empty"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
|
||||
if req.StartDate != nil && req.EndDate != nil {
|
||||
if req.EndDate.Before(*req.StartDate) {
|
||||
return errors.New("end_date must be after start_date"), constants.MalformedFieldErrorCode
|
||||
|
||||
@@ -11,12 +11,13 @@ import (
|
||||
)
|
||||
|
||||
func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest {
|
||||
vendorID := uuid.New()
|
||||
ingredientID := uuid.New()
|
||||
quantity := 1.0
|
||||
unitID := uuid.New()
|
||||
|
||||
return &contract.CreatePurchaseOrderRequest{
|
||||
VendorID: uuid.New(),
|
||||
VendorID: &vendorID,
|
||||
PONumber: "PO-001",
|
||||
TransactionDate: "2026-05-29",
|
||||
Items: []contract.CreatePurchaseOrderItemRequest{
|
||||
@@ -31,6 +32,30 @@ func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorCreateAllowsMissingVendor(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
req := validCreatePurchaseOrderRequest()
|
||||
req.VendorID = nil
|
||||
|
||||
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, code)
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorCreateRejectsEmptyVendor(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
req := validCreatePurchaseOrderRequest()
|
||||
vendorID := uuid.Nil
|
||||
req.VendorID = &vendorID
|
||||
|
||||
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||
require.Contains(t, err.Error(), "vendor_id cannot be empty")
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorCreateAllowsMissingDueDate(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
|
||||
@@ -65,3 +90,164 @@ func TestPurchaseOrderValidatorCreateRejectsDueDateBeforeTransactionDate(t *test
|
||||
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||
require.Contains(t, err.Error(), "due_date must be after transaction_date")
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorCreateAllowsCentralTeam(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
req := validCreatePurchaseOrderRequest()
|
||||
scope := constants.PurchaseTeamScopeCentral
|
||||
req.TeamScope = &scope
|
||||
|
||||
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, code)
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorCreateRejectsCentralTeamWithCategory(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
req := validCreatePurchaseOrderRequest()
|
||||
scope := constants.PurchaseTeamScopeCentral
|
||||
categoryID := uuid.New()
|
||||
req.TeamScope = &scope
|
||||
req.TeamCategoryID = &categoryID
|
||||
|
||||
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||
require.Contains(t, err.Error(), "team_category_id must be empty")
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorCreateRejectsCategoryTeamWithoutCategory(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
req := validCreatePurchaseOrderRequest()
|
||||
scope := constants.PurchaseTeamScopeCategory
|
||||
req.TeamScope = &scope
|
||||
|
||||
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, constants.MissingFieldErrorCode, code)
|
||||
require.Contains(t, err.Error(), "team_category_id is required")
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorCreateRejectsCategoryWithoutScope(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
req := validCreatePurchaseOrderRequest()
|
||||
categoryID := uuid.New()
|
||||
req.TeamCategoryID = &categoryID
|
||||
|
||||
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, constants.MissingFieldErrorCode, code)
|
||||
require.Contains(t, err.Error(), "team_scope is required")
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorCreateRejectsUnknownTeamScope(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
req := validCreatePurchaseOrderRequest()
|
||||
scope := "outlet"
|
||||
req.TeamScope = &scope
|
||||
|
||||
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||
require.Contains(t, err.Error(), "team_scope must be one of")
|
||||
}
|
||||
|
||||
// An update may clear the team with an empty scope; a create may not, because
|
||||
// leaving the field out already means "no team".
|
||||
func TestPurchaseOrderValidatorUpdateAllowsClearingTeam(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
scope := ""
|
||||
|
||||
err, code := validator.ValidateUpdatePurchaseOrderRequest(&contract.UpdatePurchaseOrderRequest{TeamScope: &scope})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, code)
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorCreateRejectsEmptyTeamScope(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
req := validCreatePurchaseOrderRequest()
|
||||
scope := ""
|
||||
req.TeamScope = &scope
|
||||
|
||||
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorUpdateRejectsClearingTeamWithCategory(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
scope := ""
|
||||
categoryID := uuid.New()
|
||||
|
||||
err, code := validator.ValidateUpdatePurchaseOrderRequest(&contract.UpdatePurchaseOrderRequest{
|
||||
TeamScope: &scope,
|
||||
TeamCategoryID: &categoryID,
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorListAcceptsTeamFilter(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
|
||||
for _, team := range []string{constants.PurchaseTeamScopeCentral, constants.PurchaseTeamNone, uuid.New().String()} {
|
||||
err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{
|
||||
Page: 1,
|
||||
Limit: 10,
|
||||
Team: team,
|
||||
})
|
||||
|
||||
require.NoError(t, err, team)
|
||||
require.Empty(t, code, team)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorListRejectsUnknownTeamFilter(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
|
||||
err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{
|
||||
Page: 1,
|
||||
Limit: 10,
|
||||
Team: "marketing",
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorListRejectsTeamWithScope(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
|
||||
err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{
|
||||
Page: 1,
|
||||
Limit: 10,
|
||||
Team: constants.PurchaseTeamNone,
|
||||
TeamScope: constants.PurchaseTeamScopeCentral,
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorListRejectsCentralScopeWithCategory(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
categoryID := uuid.New()
|
||||
|
||||
err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{
|
||||
Page: 1,
|
||||
Limit: 10,
|
||||
TeamScope: constants.PurchaseTeamScopeCentral,
|
||||
TeamCategoryID: &categoryID,
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||
}
|
||||
|
||||
@@ -140,10 +140,12 @@ func (v *UserValidatorImpl) ValidateUserID(userID uuid.UUID) (error, string) {
|
||||
|
||||
func isValidUserRole(role string) bool {
|
||||
validRoles := map[string]bool{
|
||||
string(constants.RoleAdmin): true,
|
||||
string(constants.RoleManager): true,
|
||||
string(constants.RoleCashier): true,
|
||||
string(constants.RoleWaiter): true,
|
||||
string(constants.RoleAdmin): true,
|
||||
string(constants.RoleManager): true,
|
||||
string(constants.RoleCashier): true,
|
||||
string(constants.RoleWaiter): true,
|
||||
string(constants.RoleOwner): true,
|
||||
string(constants.RolePurchasing): true,
|
||||
}
|
||||
return validRoles[role]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE purchase_orders
|
||||
ALTER COLUMN vendor_id SET NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE purchase_orders
|
||||
ALTER COLUMN vendor_id DROP NOT NULL;
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP INDEX IF EXISTS idx_purchase_orders_outlet_id;
|
||||
|
||||
ALTER TABLE purchase_orders
|
||||
DROP COLUMN IF EXISTS outlet_id;
|
||||
@@ -0,0 +1,66 @@
|
||||
ALTER TABLE purchase_orders
|
||||
ADD COLUMN IF NOT EXISTS outlet_id UUID REFERENCES outlets(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_purchase_orders_outlet_id
|
||||
ON purchase_orders(outlet_id);
|
||||
|
||||
WITH movement_outlets AS (
|
||||
SELECT
|
||||
poi.purchase_order_id,
|
||||
MIN(im.outlet_id::text)::uuid AS outlet_id
|
||||
FROM inventory_movements im
|
||||
JOIN purchase_order_items poi ON im.purchase_order_item_id = poi.id
|
||||
WHERE im.outlet_id IS NOT NULL
|
||||
AND im.purchase_order_item_id IS NOT NULL
|
||||
GROUP BY poi.purchase_order_id
|
||||
HAVING COUNT(DISTINCT im.outlet_id) = 1
|
||||
)
|
||||
UPDATE purchase_orders po
|
||||
SET outlet_id = movement_outlets.outlet_id
|
||||
FROM movement_outlets
|
||||
WHERE po.id = movement_outlets.purchase_order_id
|
||||
AND po.outlet_id IS NULL;
|
||||
|
||||
WITH candidate_item_outlets AS (
|
||||
SELECT
|
||||
poi.purchase_order_id,
|
||||
i.outlet_id
|
||||
FROM purchase_order_items poi
|
||||
JOIN ingredients i ON poi.ingredient_id = i.id
|
||||
WHERE i.outlet_id IS NOT NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
poi.purchase_order_id,
|
||||
u.outlet_id
|
||||
FROM purchase_order_items poi
|
||||
JOIN units u ON poi.unit_id = u.id
|
||||
WHERE u.outlet_id IS NOT NULL
|
||||
), item_outlets AS (
|
||||
SELECT
|
||||
purchase_order_id,
|
||||
MIN(outlet_id::text)::uuid AS outlet_id
|
||||
FROM candidate_item_outlets
|
||||
GROUP BY purchase_order_id
|
||||
HAVING COUNT(DISTINCT outlet_id) = 1
|
||||
)
|
||||
UPDATE purchase_orders po
|
||||
SET outlet_id = item_outlets.outlet_id
|
||||
FROM item_outlets
|
||||
WHERE po.id = item_outlets.purchase_order_id
|
||||
AND po.outlet_id IS NULL;
|
||||
|
||||
WITH single_outlet_organizations AS (
|
||||
SELECT
|
||||
organization_id,
|
||||
MIN(id::text)::uuid AS outlet_id
|
||||
FROM outlets
|
||||
GROUP BY organization_id
|
||||
HAVING COUNT(*) = 1
|
||||
)
|
||||
UPDATE purchase_orders po
|
||||
SET outlet_id = single_outlet_organizations.outlet_id
|
||||
FROM single_outlet_organizations
|
||||
WHERE po.organization_id = single_outlet_organizations.organization_id
|
||||
AND po.outlet_id IS NULL;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Revert to original roles
|
||||
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
|
||||
UPDATE users SET role = 'admin' WHERE role NOT IN ('admin', 'manager', 'cashier', 'waiter');
|
||||
ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('admin', 'manager', 'cashier', 'waiter'));
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Add 'owner' and 'purchasing' roles to users table
|
||||
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
|
||||
UPDATE users SET role = 'admin' WHERE role NOT IN ('admin', 'manager', 'cashier', 'waiter', 'owner', 'purchasing');
|
||||
ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('admin', 'manager', 'cashier', 'waiter', 'owner', 'purchasing'));
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE categories
|
||||
DROP CONSTRAINT IF EXISTS categories_parent_id_fkey;
|
||||
ALTER TABLE categories
|
||||
DROP COLUMN IF EXISTS parent_id;
|
||||
|
||||
DROP INDEX IF EXISTS idx_categories_parent_id;
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE categories
|
||||
ADD COLUMN parent_id UUID REFERENCES categories(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX idx_categories_parent_id ON categories(parent_id);
|
||||
@@ -0,0 +1,12 @@
|
||||
DROP INDEX IF EXISTS idx_purchase_orders_team_scope;
|
||||
DROP INDEX IF EXISTS idx_purchase_orders_team_category_id;
|
||||
|
||||
ALTER TABLE purchase_orders
|
||||
DROP CONSTRAINT IF EXISTS chk_purchase_orders_team;
|
||||
|
||||
ALTER TABLE purchase_orders
|
||||
DROP CONSTRAINT IF EXISTS fk_purchase_orders_team_category;
|
||||
|
||||
ALTER TABLE purchase_orders
|
||||
DROP COLUMN IF EXISTS team_category_id,
|
||||
DROP COLUMN IF EXISTS team_scope;
|
||||
@@ -0,0 +1,33 @@
|
||||
-- A purchase is charged either to a team (a parent product category) or to Pusat.
|
||||
-- Pusat has no category of its own, so it is stored as a scope rather than a row;
|
||||
-- which outlet's Pusat it is comes from purchase_orders.outlet_id.
|
||||
-- team_scope IS NULL means the team was never chosen, which is deliberately
|
||||
-- distinct from a purchase that belongs to Pusat.
|
||||
ALTER TABLE purchase_orders
|
||||
ADD COLUMN IF NOT EXISTS team_scope VARCHAR(20),
|
||||
ADD COLUMN IF NOT EXISTS team_category_id UUID;
|
||||
|
||||
ALTER TABLE purchase_orders
|
||||
ADD CONSTRAINT fk_purchase_orders_team_category
|
||||
FOREIGN KEY (team_category_id) REFERENCES categories(id) ON DELETE RESTRICT;
|
||||
|
||||
-- Deleting a category that is still charged on a purchase order must fail rather
|
||||
-- than silently drop the attribution, hence RESTRICT above and this pairing check.
|
||||
-- Written as a CASE because an OR chain would evaluate to NULL when team_scope is
|
||||
-- NULL, and a CHECK only rejects FALSE — a stray team_category_id would slip past.
|
||||
ALTER TABLE purchase_orders
|
||||
ADD CONSTRAINT chk_purchase_orders_team
|
||||
CHECK (
|
||||
CASE
|
||||
WHEN team_scope IS NULL THEN team_category_id IS NULL
|
||||
WHEN team_scope = 'category' THEN team_category_id IS NOT NULL
|
||||
WHEN team_scope = 'central' THEN team_category_id IS NULL
|
||||
ELSE false
|
||||
END
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_purchase_orders_team_category_id
|
||||
ON purchase_orders(team_category_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_purchase_orders_team_scope
|
||||
ON purchase_orders(team_scope);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Restoring NOT NULL fails if any ingredient still has a NULL unit_id. Assign a
|
||||
-- unit to those rows first:
|
||||
-- SELECT id, name FROM ingredients WHERE unit_id IS NULL;
|
||||
COMMENT ON COLUMN ingredients.unit_id IS NULL;
|
||||
|
||||
ALTER TABLE ingredients ALTER COLUMN unit_id SET NOT NULL;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- An ingredient can be registered before its unit has been decided, so unit_id
|
||||
-- is optional. Existing rows are untouched: they already have a unit.
|
||||
ALTER TABLE ingredients ALTER COLUMN unit_id DROP NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN ingredients.unit_id IS 'Base unit of the ingredient. NULL means no unit has been assigned yet.';
|
||||
@@ -0,0 +1,394 @@
|
||||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Laporan Penjualan Harian</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 10mm 12mm;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #ffffff;
|
||||
color: #2d3748;
|
||||
line-height: 1.4;
|
||||
font-size: 11px;
|
||||
}
|
||||
.report-container {
|
||||
max-width: 210mm;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.report-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.header-logo {
|
||||
display: inline-block;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
background: #dc2626;
|
||||
padding: 10px;
|
||||
}
|
||||
.header-logo svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.header-title {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.header-org {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #dc2626;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.header-period {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Summary Boxes */
|
||||
.summary-boxes {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0;
|
||||
margin-bottom: 20px;
|
||||
border: 2px solid #1a1a1a;
|
||||
}
|
||||
.summary-box {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
.summary-box:nth-child(odd) {
|
||||
border-right: 1px solid #1a1a1a;
|
||||
}
|
||||
.summary-box:nth-child(n + 3) {
|
||||
border-bottom: none;
|
||||
}
|
||||
.summary-box.highlight-green {
|
||||
background: #dcfce7;
|
||||
}
|
||||
.summary-box.highlight-red {
|
||||
background: #fee2e2;
|
||||
}
|
||||
.summary-box-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.summary-box-value {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: #1a1a1a;
|
||||
font-family: "Courier New", monospace;
|
||||
}
|
||||
.summary-box-value.negative {
|
||||
color: #dc2626;
|
||||
}
|
||||
.summary-box-value.positive {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
/* Section Title */
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: #dc2626;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* Main Summary Table */
|
||||
.summary-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 20px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.summary-table thead th {
|
||||
background: #dc2626;
|
||||
color: #ffffff;
|
||||
padding: 8px 10px;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid #dc2626;
|
||||
}
|
||||
.summary-table thead th:first-child {
|
||||
width: 30px;
|
||||
}
|
||||
.summary-table thead th:nth-child(2) {
|
||||
text-align: left;
|
||||
width: 35%;
|
||||
}
|
||||
.summary-table tbody td {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #e5e7eb;
|
||||
font-size: 11px;
|
||||
}
|
||||
.summary-table tbody td:first-child {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
.summary-table tbody td.nominal {
|
||||
text-align: right;
|
||||
font-family: "Courier New", monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
.summary-table tbody td.pct {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
.summary-table tbody tr.bold-row td {
|
||||
font-weight: 800;
|
||||
background: #f9fafb;
|
||||
}
|
||||
.summary-table tbody tr.highlight-row td {
|
||||
font-weight: 800;
|
||||
background: #fef3c7;
|
||||
}
|
||||
.summary-table tbody tr.highlight-green-row td {
|
||||
font-weight: 800;
|
||||
background: #dcfce7;
|
||||
}
|
||||
.summary-table tbody tr.sub-item td {
|
||||
padding-left: 30px;
|
||||
font-size: 10px;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
/* Rincian Biaya Table */
|
||||
.detail-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 20px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.detail-table thead th {
|
||||
background: #dc2626;
|
||||
color: #ffffff;
|
||||
padding: 8px 10px;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid #dc2626;
|
||||
}
|
||||
.detail-table thead th:first-child {
|
||||
width: 30px;
|
||||
}
|
||||
.detail-table thead th:nth-child(2) {
|
||||
text-align: left;
|
||||
}
|
||||
.detail-table thead th:last-child {
|
||||
text-align: right;
|
||||
width: 25%;
|
||||
}
|
||||
.detail-table tbody td {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #e5e7eb;
|
||||
font-size: 11px;
|
||||
}
|
||||
.detail-table tbody td:first-child {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
.detail-table tbody td:last-child {
|
||||
text-align: right;
|
||||
font-family: "Courier New", monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
.detail-table tbody tr.total-row td {
|
||||
font-weight: 800;
|
||||
background: #fef3c7;
|
||||
border-top: 2px solid #1a1a1a;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.report-footer {
|
||||
margin-top: 20px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
font-size: 9px;
|
||||
color: #9ca3af;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="report-container">
|
||||
<!-- HEADER -->
|
||||
<div class="report-header">
|
||||
<div class="header-logo">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 500 500"
|
||||
width="40"
|
||||
height="40"
|
||||
>
|
||||
<rect fill="#dc2626" x="0" y="0" width="500" height="500" rx="60" />
|
||||
<g transform="translate(250,250) scale(3.2)">
|
||||
<rect
|
||||
x="-40"
|
||||
y="-50"
|
||||
width="80"
|
||||
height="10"
|
||||
rx="3"
|
||||
fill="white"
|
||||
/>
|
||||
<rect
|
||||
x="-30"
|
||||
y="-35"
|
||||
width="60"
|
||||
height="60"
|
||||
rx="5"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<circle cx="0" cy="-55" r="8" fill="white" />
|
||||
<line
|
||||
x1="-10"
|
||||
y1="-60"
|
||||
x2="10"
|
||||
y2="-50"
|
||||
stroke="white"
|
||||
stroke-width="3"
|
||||
/>
|
||||
<line
|
||||
x1="10"
|
||||
y1="-60"
|
||||
x2="-10"
|
||||
y2="-50"
|
||||
stroke="white"
|
||||
stroke-width="3"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="header-title">LAPORAN PENJUALAN HARIAN</div>
|
||||
<div class="header-org">{{.OrganizationName}}</div>
|
||||
<div class="header-period">
|
||||
Bulan: {{.MonthName}} / Tanggal Report: {{.ReportDate}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SUMMARY BOXES -->
|
||||
<div class="summary-boxes">
|
||||
<div class="summary-box">
|
||||
<span class="summary-box-label"
|
||||
>TOTAL PENJUALAN {{.ReportDateUpper}}</span
|
||||
>
|
||||
<span class="summary-box-value">{{.TotalPenjualan}}</span>
|
||||
</div>
|
||||
<div class="summary-box">
|
||||
<span class="summary-box-label"
|
||||
>TOTAL BIAYA {{.ReportDateUpper}}</span
|
||||
>
|
||||
<span class="summary-box-value">{{.TotalBiaya}}</span>
|
||||
</div>
|
||||
<div class="summary-box {{.LabaRugiClass}}">
|
||||
<span class="summary-box-label">LABA/RUGI {{.ReportDateUpper}}</span>
|
||||
<span class="summary-box-value {{.LabaRugiValueClass}}"
|
||||
>{{.LabaRugi}}</span
|
||||
>
|
||||
</div>
|
||||
<div class="summary-box {{.LabaRugiMtdClass}}">
|
||||
<span class="summary-box-label">LABA/RUGI MTD</span>
|
||||
<span class="summary-box-value {{.LabaRugiMtdValueClass}}"
|
||||
>{{.LabaRugiMtd}}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 1. RINGKASAN LAPORAN -->
|
||||
<div class="section-title">1. Ringkasan Laporan</div>
|
||||
<table class="summary-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>NO</th>
|
||||
<th>KETERANGAN</th>
|
||||
<th>TANGGAL REPORT<br />Nominal</th>
|
||||
<th>%</th>
|
||||
<th>MTD<br />Nominal</th>
|
||||
<th>%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $i, $row := .MainSummary}}
|
||||
<tr class="{{$row.RowClass}}">
|
||||
<td>{{if $row.Number}}{{$row.Number}}{{end}}</td>
|
||||
<td>{{$row.Label}}</td>
|
||||
<td class="nominal">{{$row.TodayNominal}}</td>
|
||||
<td class="pct">{{$row.TodayPct}}</td>
|
||||
<td class="nominal">{{$row.MtdNominal}}</td>
|
||||
<td class="pct">{{$row.MtdPct}}</td>
|
||||
</tr>
|
||||
{{range $j, $sub := $row.SubItems}}
|
||||
<tr class="sub-item">
|
||||
<td></td>
|
||||
<td>{{$sub.Label}}</td>
|
||||
<td class="nominal">{{$sub.TodayNominal}}</td>
|
||||
<td class="pct">{{$sub.TodayPct}}</td>
|
||||
<td class="nominal">{{$sub.MtdNominal}}</td>
|
||||
<td class="pct">{{$sub.MtdPct}}</td>
|
||||
</tr>
|
||||
{{end}} {{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 2. RINCIAN BIAYA / CATATAN -->
|
||||
<div class="section-title">2. Rincian Biaya / Catatan</div>
|
||||
<table class="detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>NO</th>
|
||||
<th>KETERANGAN</th>
|
||||
<th>JUMLAH</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $i, $item := .PurchasingItems}}
|
||||
<tr>
|
||||
<td>{{add $i 1}}</td>
|
||||
<td>{{$item.Name}}</td>
|
||||
<td>{{$item.Amount}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
<tr class="total-row">
|
||||
<td></td>
|
||||
<td>TOTAL</td>
|
||||
<td>{{.PurchasingTotal}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<div class="report-footer">
|
||||
Dicetak oleh: {{.GeneratedBy}} | {{.PrintTime}} | Powered by APSKEL
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user