Compare commits

..
Author SHA1 Message Date
ryan 15805a4853 Implement FCM 2026-05-09 14:18:36 +07:00
259 changed files with 1409 additions and 19568 deletions
+1 -6
View File
@@ -7,9 +7,4 @@ config/env/*
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
*firebase-adminsdk*.json
-1
View File
@@ -1 +0,0 @@
{}
+1 -1
View File
@@ -1,5 +1,5 @@
# 1) Build stage
FROM golang:1.24-alpine AS build
FROM golang:1.21-alpine AS build
RUN apk --no-cache add ca-certificates tzdata git curl
WORKDIR /src
COPY go.mod go.sum ./
+7 -33
View File
@@ -1,21 +1,9 @@
#PROJECT_NAME = "enaklo-pos-backend"
# ─── 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_USERNAME :=apskel
DB_PASSWORD :=7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk
DB_HOST :=62.72.45.250
DB_PORT :=5433
DB_NAME :=apskel_pos
DB_URL = postgres://$(DB_USERNAME):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=disable
@@ -28,19 +16,15 @@ endif
.SILENT: help
help:
@echo
@echo "Usage: make [command] [ENV=staging|production]"
@echo "Usage: make [command]"
@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"
@@ -99,12 +83,6 @@ migration-up:
migration-down:
@migrate -database $(DB_URL) -path ./migrations down 1
# Force migration to specific version
.SILENT: migration-force
migration-force:
@migrate -database $(DB_URL) -path ./migrations force $(version)
.SILENT: seeder-create
seeder-create:
@migrate create -ext sql -dir ./seeders -seq $(name)
@@ -130,11 +108,7 @@ fmt:
@go fmt ./...
start:
ENV_MODE=$(ENV) go run cmd/server/main.go
.SILENT: run
run:
ENV_MODE=$(ENV) go run cmd/server/main.go
go run main.go --env-path .env
# Default
+71 -133
View File
@@ -15,19 +15,15 @@ Makefile requires installed dependecies:
```shell
$ make
Usage: make [command] [ENV=staging|production]
Usage: make [command]
Commands:
run Run server (default: staging)
run ENV=production Run server with production config
rename-project name={name} Rename project
build-http Build http server
migration-create name={name} Create migration
migration-up Up migrations
migration-up ENV=production Up migrations (production DB)
migration-down Down last migration
docker-up Up docker services
@@ -40,16 +36,24 @@ Commands:
## HTTP Server
The server takes no CLI flags. It reads `ENV_MODE` and loads the matching YAML file from
[infra/](infra/) — see [Running the Application](#running-the-application) for details.
```shell
$ ./bin/http-server --help
Usage: http-server
Flags:
-h, --help Show mycontext-sensitive help.
--env-path=STRING Path to env config file
```
**Configuration** is based on the environment variables. See [.env.template](.env).
```shell
# Build, then start with the staging config (default)
$ go build -o ./bin/http-server ./cmd/server/main.go
$ ENV_MODE=staging ./bin/http-server
# Expose env vars before and start server
$ ./bin/http-server
# Start with the production config
$ ENV_MODE=production ./bin/http-server
# Expose env vars from the file and start server
$ ./bin/http-server --env-path ./config/env/.env
```
## API Docs
@@ -120,7 +124,7 @@ Handler → Service → Processor → Repository
## API Endpoints
### Health Check
- `GET /health` - Health check endpoint (registered at the root, not under `/api/v1`)
- `GET /api/v1/health` - Health check endpoint
### Organizations
- `POST /api/v1/organizations` - Create organization
@@ -153,139 +157,73 @@ Handler → Service → Processor → Repository
- `PUT /api/v1/order-items/{id}` - Update order item
- `DELETE /api/v1/order-items/{id}` - Remove order item
## Running the Application
## Installation
### Prerequisites
1. **Clone the repository**
```bash
git clone <repository-url>
cd apskel-pos-backend
```
| Tool | Version | Needed for |
|------|---------|------------|
| [Go](https://go.dev/doc/install) | 1.24+ | building & running the server |
| [golang-migrate](https://github.com/golang-migrate/migrate) | latest | `make migration-*` targets |
| [make](https://www.gnu.org/software/make/) | any | shortcut commands (Windows: use Git Bash / WSL, see note below) |
| [docker & docker-compose](https://docs.docker.com/compose/) | optional | running Postgres/Redis locally |
| [air](https://github.com/air-verse/air) | optional | hot reload during development (`.air.toml` is already configured) |
2. **Install dependencies**
```bash
go mod tidy
```
### 1. Clone & install dependencies
3. **Set up database**
```bash
# Set your PostgreSQL database URL
export DATABASE_URL="postgres://username:password@localhost:5432/apskel_pos?sslmode=disable"
```
4. **Run migrations**
```bash
make migration-up
```
## Usage
### Development
```bash
git clone <repository-url>
cd apskel-pos-backend
go mod download
# Start the server
go run cmd/server/main.go -port 8080 -db-url "postgres://username:password@localhost:5432/apskel_pos?sslmode=disable"
# Or using environment variable
export DATABASE_URL="postgres://username:password@localhost:5432/apskel_pos?sslmode=disable"
go run cmd/server/main.go -port 8080
```
### 2. Configuration
Configuration is **not** read from `.env` files — it is loaded from YAML files in [infra/](infra/)
by [config/configs.go](config/configs.go) using viper.
The file is selected by the `ENV_MODE` environment variable:
| `ENV_MODE` | Config file loaded |
|------------|--------------------|
| `local` | `infra/local.yaml` |
| `development` | `infra/development.yaml` |
| `staging` *(default)* | `infra/staging.yaml` |
| `production` | `infra/production.yaml` |
Any other/unset value falls back to `staging`. Only `staging.yaml` and `production.yaml` are
committed — for `local`/`development` copy one of them first:
### Using Make Commands
```bash
cp infra/staging.yaml infra/local.yaml
# Run the application
make start
# Format code
make fmt
# Run tests
make test
# Build for production
make build-http
# Docker operations
make docker-up
make docker-down
# Database migrations
make migration-create name=create_users_table
make migration-up
make migration-down
```
Two important notes:
* The config path is **relative to the working directory**, so always run the server from the
repository root, otherwise viper panics with `failed to read config file`.
* Push notifications need `infra/firebase-service-account.json` (git-ignored). Without it, obtain
the file from the team before enabling FCM features.
### 3. Run migrations
The migration targets build the DB URL from the credentials at the top of the [Makefile](Makefile):
```bash
make migration-up # staging DB (default)
make migration-up ENV=production # production DB
make migration-create name=create_cash_advances_table
make migration-down # roll back the last migration
make migration-force version=87 # clear a dirty migration state
```
### 4. Start the server
```bash
make run # ENV_MODE=staging
make run ENV=production # ENV_MODE=production
```
`make run` is just a wrapper around:
```bash
ENV_MODE=staging go run cmd/server/main.go
```
The server listens on the `server.port` value from the loaded YAML (**4000** for both staging and
production). Verify it is up:
```bash
curl http://localhost:4000/health
```
All application routes live under `/api/v1` (see [internal/router/router.go](internal/router/router.go)).
#### Windows note
The `run`/`start` targets use POSIX inline env-var syntax, which `cmd.exe` and PowerShell do not
understand. Either run `make` from Git Bash / WSL, or start the server directly:
```powershell
# PowerShell
$env:ENV_MODE = "staging"; go run cmd/server/main.go
```
```cmd
:: cmd.exe
set ENV_MODE=staging && go run cmd/server/main.go
```
#### Hot reload
```bash
ENV_MODE=local air # rebuilds ./tmp/main on every .go change
```
### 5. Other commands
```bash
make # show all available targets
make fmt # go fmt ./...
make test # go test ./... -v
# Build a binary (make build-http still points at the old ./cmd/http path)
go build -o ./bin/http-server ./cmd/server/main.go
```
### Running with Docker
`docker-compose.yaml` provides Postgres (`5432`), Redis (`6379`), and the API image. See
[DOCKER.md](DOCKER.md) for the full workflow.
```bash
make docker-up # docker-compose up -d
make docker-down # docker-compose down
```
If you use the containerised Postgres/Redis, point `infra/local.yaml` at `localhost:5432` /
`localhost:6379` instead of the remote hosts baked into `staging.yaml`.
## Example API Usage
### Create Organization
```bash
curl -X POST http://localhost:4000/api/v1/organizations \
curl -X POST http://localhost:8080/api/v1/organizations \
-H "Content-Type: application/json" \
-d '{
"name": "My Restaurant",
@@ -295,7 +233,7 @@ curl -X POST http://localhost:4000/api/v1/organizations \
### Create User
```bash
curl -X POST http://localhost:4000/api/v1/users \
curl -X POST http://localhost:8080/api/v1/users \
-H "Content-Type: application/json" \
-d '{
"organization_id": "uuid-here",
@@ -309,7 +247,7 @@ curl -X POST http://localhost:4000/api/v1/users \
### Create Order with Items
```bash
curl -X POST http://localhost:4000/api/v1/orders \
curl -X POST http://localhost:8080/api/v1/orders \
-H "Content-Type: application/json" \
-d '{
"outlet_id": "uuid-here",
+4 -5
View File
@@ -12,14 +12,13 @@ import (
const (
YAML_PATH = "infra/%s"
ENV_MODE = "ENV_MODE"
DEFAULT_ENV_MODE = "staging"
DEFAULT_ENV_MODE = "development"
)
var (
validEnvMode = map[string]struct{}{
"local": {},
"development": {},
"staging": {},
"production": {},
}
)
@@ -32,7 +31,7 @@ type Config struct {
Log Log `mapstructure:"log"`
S3Config S3Config `mapstructure:"s3"`
Fonnte Fonnte `mapstructure:"fonnte"`
FCM FCM `mapstructure:"fcm"`
Firebase Firebase `mapstructure:"firebase"`
}
var (
@@ -98,6 +97,6 @@ func (c *Config) GetFonnte() *Fonnte {
return &c.Fonnte
}
func (c *Config) GetFCM() *FCM {
return &c.FCM
func (c *Config) GetFirebase() *Firebase {
return &c.Firebase
}
-14
View File
@@ -1,14 +0,0 @@
package config
type FCM struct {
CredentialsFile string `mapstructure:"credentials_file"`
ProjectID string `mapstructure:"project_id"`
}
func (f *FCM) GetCredentialsFile() string {
return f.CredentialsFile
}
func (f *FCM) GetProjectID() string {
return f.ProjectID
}
+9
View File
@@ -0,0 +1,9 @@
package config
type Firebase struct {
CredentialsFile string `mapstructure:"credentials_file"`
}
func (f *Firebase) GetCredentialsFile() string {
return f.CredentialsFile
}
-1
View File
@@ -4,5 +4,4 @@ type Server struct {
Port string `mapstructure:"port"`
BaseUrl string `mapstructure:"common-url"`
LocalUrl string `mapstructure:"local-url"`
SelfOrderUrl string `mapstructure:"self-order-url"`
}
+8 -46
View File
@@ -2,61 +2,23 @@
set -euo pipefail
APP_NAME="apskel-pos"
# ─── 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
PORT="4000"
echo "🔄 Pulling latest code..."
git pull
echo "🐳 Building Docker image ($ENV_MODE)..."
docker build --target production -t "$IMAGE_NAME" .
echo "🐳 Building Docker image (production target)..."
docker build --target production -t $APP_NAME:latest .
echo "🛑 Stopping and removing old container..."
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
docker rm -f $APP_NAME 2>/dev/null || true
echo "🚀 Running new container..."
docker run -d --name "$CONTAINER_NAME" \
-p "$PORT:4000" \
docker run -d --name $APP_NAME \
-p $PORT:$PORT \
-e TZ=Asia/Jakarta \
-e ENV_MODE="$ENV_MODE" \
-v "$(pwd)/infra":/infra:ro \
-v "$(pwd)/templates":/templates:ro \
"$IMAGE_NAME"
$APP_NAME:latest
echo ""
echo "✅ Deployment $ENV_MODE complete."
echo " Container : $CONTAINER_NAME"
echo " Port : $PORT"
echo "✅ Deployment complete."
@@ -1,7 +1,6 @@
server:
base-url:
local-url:
self-order-url:
port: 4000
jwt:
@@ -9,7 +8,7 @@ jwt:
expires-ttl: 144000
secret: "5Lm25V3Qd7aut8dr4QUxm5PZUrSFs"
refresh_token:
expires-ttl: 7776000
expires-ttl: 7776000 # 3 months in minutes (90 days * 24 hours * 60 minutes)
secret: "R3fr3sh_T0k3n_S3cr3t_K3y_2024_P0S"
customer:
expires-ttl: 7776000
@@ -21,7 +20,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
@@ -29,7 +28,7 @@ postgresql:
debug: false
redis:
host: 194.233.78.1
host: 127.0.0.1
port: 6379
password: "CmICdmnX1EZPhVBYzQPEGw==U"
db: 0
@@ -45,17 +44,16 @@ 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: "info"
log_format: 'json'
log_level: 'debug'
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"
firebase:
credentials_file: "apskel-pos-v2-firebase-adminsdk-fbsvc-ae00499526.json"
-61
View File
@@ -1,61 +0,0 @@
server:
base-url:
local-url:
self-order-url:
port: 4000
jwt:
token:
expires-ttl: 144000
secret: "eZ7LAZJuSOGSHxb1ZYaZCkrBo5YBvc"
refresh_token:
expires-ttl: 7776000
secret: "EMx2DKPtMp0jQNpLvzzCsZkoUHe0d9"
customer:
expires-ttl: 7776000
secret: "layCV2rne0X57acWzSS3NxENmYJs7B"
postgresql:
host: 62.72.45.250
port: 5433
driver: postgres
db: apskel_pos_staging
username: apskel
password: "7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk"
ssl-mode: disable
max-idle-connections-in-second: 600
max-open-connections-in-second: 600
connection-max-life-time-in-second: 600
debug: false
redis:
host: 62.72.45.250
port: 6380
password: "CmICdmnX1EZPhVBYzQPEGw==U"
db: 1
dial_timeout: 5s
read_timeout: 3s
write_timeout: 3s
pool_size: 10
min_idle_connections: 5
s3:
access_key_id: cf9a475e18bc7626cbdbf09709d82a64
access_key_secret: 91f3321294d3e23035427a0ecb893ada
endpoint: sin1.contabostorage.com
bucket_name: enaklo
log_level: Error
host_url: "https://sin1.contabostorage.com/fda98c2228f246f29a7e466b86b3b9e7:"
log:
log_format: "json"
log_level: "info"
fonnte:
api_url: "https://api.fonnte.com/send"
token: "bADQrf9NTXfLZQCK2wGg"
timeout: 30
fcm:
credentials_file: "infra/firebase-service-account.json"
project_id: "apskel-pos-v2"
+11 -117
View File
@@ -30,7 +30,6 @@ type App struct {
redisClient *redis.Client
router *router.Router
shutdown chan os.Signal
omsetScheduler *service.OmsetMilestoneScheduler
}
func NewApp(db *gorm.DB, redisClient *redis.Client) *App {
@@ -44,15 +43,6 @@ func NewApp(db *gorm.DB, redisClient *redis.Client) *App {
func (a *App) Initialize(cfg *config.Config) error {
repos := a.initRepositories()
processors := a.initProcessors(cfg, repos)
// Initialize omset milestone scheduler
a.omsetScheduler = service.NewOmsetMilestoneScheduler(
repos.organizationRepo,
repos.outletRepo,
repos.userRepo,
processors.notificationProcessor,
)
services := a.initServices(processors, repos, cfg)
validators := a.initValidators()
middleware := a.initMiddleware(services, cfg)
@@ -66,7 +56,7 @@ func (a *App) Initialize(cfg *config.Config) error {
repos.userRepo,
repos.sessionRepo,
repos.orderRepo,
services.productOutletPriceService,
processors.fcmClient,
)
a.router = router.NewRouter(
@@ -108,8 +98,6 @@ func (a *App) Initialize(cfg *config.Config) error {
validators.vendorValidator,
services.purchaseOrderService,
validators.purchaseOrderValidator,
services.purchaseCategoryService,
validators.purchaseCategoryValidator,
services.unitConverterService,
validators.unitConverterValidator,
services.chartOfAccountTypeService,
@@ -131,29 +119,13 @@ func (a *App) Initialize(cfg *config.Config) error {
services.customerPointsService,
services.spinGameService,
middleware.customerAuthMiddleware,
services.userDeviceService,
validators.userDeviceValidator,
services.notificationService,
validators.notificationValidator,
services.productOutletPriceService,
validators.productOutletPriceValidator,
selfOrderHandler,
services.expenseService,
validators.expenseValidator,
services.cashAdvanceService,
validators.cashAdvanceValidator,
a.redisClient,
)
return nil
}
func (a *App) Start(port string) error {
// Start the omset milestone scheduler (checks every 5 minutes for daily omset milestones)
if a.omsetScheduler != nil {
a.omsetScheduler.Start(5 * time.Minute)
}
engine := a.router.Init()
a.server = &http.Server{
@@ -189,9 +161,6 @@ func (a *App) Start(port string) error {
}
func (a *App) Shutdown() {
if a.omsetScheduler != nil {
a.omsetScheduler.Stop()
}
close(a.shutdown)
}
@@ -220,7 +189,6 @@ type repositories struct {
productRecipeRepo *repository.ProductRecipeRepository
vendorRepo *repository.VendorRepositoryImpl
purchaseOrderRepo *repository.PurchaseOrderRepositoryImpl
purchaseCategoryRepo *repository.PurchaseCategoryRepositoryImpl
unitConverterRepo *repository.IngredientUnitConverterRepositoryImpl
chartOfAccountTypeRepo *repository.ChartOfAccountTypeRepositoryImpl
chartOfAccountRepo *repository.ChartOfAccountRepositoryImpl
@@ -240,13 +208,6 @@ type repositories struct {
otpRepo repository.OtpRepository
sessionRepo repository.SessionRepository
txManager *repository.TxManager
userDeviceRepo *repository.UserDeviceRepositoryImpl
notificationRepo *repository.NotificationRepositoryImpl
notificationReceiverRepo *repository.NotificationReceiverRepositoryImpl
notificationDeliveryRepo *repository.NotificationDeliveryRepositoryImpl
productOutletPriceRepo *repository.ProductOutletPriceRepositoryImpl
expenseRepo *repository.ExpenseRepositoryImpl
cashAdvanceRepo *repository.CashAdvanceRepositoryImpl
}
func (a *App) initRepositories() *repositories {
@@ -275,7 +236,6 @@ func (a *App) initRepositories() *repositories {
productRecipeRepo: repository.NewProductRecipeRepository(a.db),
vendorRepo: repository.NewVendorRepositoryImpl(a.db),
purchaseOrderRepo: repository.NewPurchaseOrderRepositoryImpl(a.db),
purchaseCategoryRepo: repository.NewPurchaseCategoryRepositoryImpl(a.db),
unitConverterRepo: repository.NewIngredientUnitConverterRepositoryImpl(a.db).(*repository.IngredientUnitConverterRepositoryImpl),
chartOfAccountTypeRepo: repository.NewChartOfAccountTypeRepositoryImpl(a.db),
chartOfAccountRepo: repository.NewChartOfAccountRepositoryImpl(a.db),
@@ -295,13 +255,6 @@ func (a *App) initRepositories() *repositories {
otpRepo: repository.NewOtpRepository(a.db),
sessionRepo: repository.NewSessionRepository(a.redisClient),
txManager: repository.NewTxManager(a.db),
userDeviceRepo: repository.NewUserDeviceRepositoryImpl(a.db),
notificationRepo: repository.NewNotificationRepository(a.db),
notificationReceiverRepo: repository.NewNotificationReceiverRepository(a.db),
notificationDeliveryRepo: repository.NewNotificationDeliveryRepository(a.db),
productOutletPriceRepo: repository.NewProductOutletPriceRepositoryImpl(a.db),
expenseRepo: repository.NewExpenseRepositoryImpl(a.db),
cashAdvanceRepo: repository.NewCashAdvanceRepositoryImpl(a.db),
}
}
@@ -325,7 +278,6 @@ type processors struct {
productRecipeProcessor *processor.ProductRecipeProcessorImpl
vendorProcessor *processor.VendorProcessorImpl
purchaseOrderProcessor *processor.PurchaseOrderProcessorImpl
purchaseCategoryProcessor *processor.PurchaseCategoryProcessorImpl
unitConverterProcessor *processor.IngredientUnitConverterProcessorImpl
chartOfAccountTypeProcessor *processor.ChartOfAccountTypeProcessorImpl
chartOfAccountProcessor *processor.ChartOfAccountProcessorImpl
@@ -344,17 +296,14 @@ type processors struct {
customerPointsProcessor *processor.CustomerPointsProcessor
otpProcessor processor.OtpProcessor
fileClient processor.FileClient
fcmClient client.FcmClient
inventoryMovementService service.InventoryMovementService
userDeviceProcessor *processor.UserDeviceProcessorImpl
notificationProcessor *processor.NotificationProcessorImpl
productOutletPriceProcessor processor.ProductOutletPriceProcessor
expenseProcessor *processor.ExpenseProcessorImpl
cashAdvanceProcessor *processor.CashAdvanceProcessorImpl
}
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
fileClient := client.NewFileClient(cfg.S3Config)
fonnteClient := client.NewFonnteClient(cfg.GetFonnte())
fcmClient := client.NewFcmClient(cfg.GetFirebase())
otpProcessor := processor.NewOtpProcessor(fonnteClient, repos.otpRepo)
inventoryMovementService := service.NewInventoryMovementService(repos.inventoryMovementRepo, repos.ingredientRepo)
@@ -364,21 +313,20 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
outletProcessor: processor.NewOutletProcessorImpl(repos.outletRepo),
outletSettingProcessor: processor.NewOutletSettingProcessorImpl(repos.outletSettingRepo, repos.outletRepo),
categoryProcessor: processor.NewCategoryProcessorImpl(repos.categoryRepo),
productProcessor: processor.NewProductProcessorImpl(repos.productRepo, repos.categoryRepo, repos.productVariantRepo, repos.inventoryRepo, repos.outletRepo, repos.productOutletPriceRepo),
productProcessor: processor.NewProductProcessorImpl(repos.productRepo, repos.categoryRepo, repos.productVariantRepo, repos.inventoryRepo, repos.outletRepo),
productVariantProcessor: processor.NewProductVariantProcessorImpl(repos.productVariantRepo, repos.productRepo),
inventoryProcessor: processor.NewInventoryProcessorImpl(repos.inventoryRepo, repos.productRepo, repos.outletRepo, repos.ingredientRepo, repos.inventoryMovementRepo),
orderProcessor: processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService, repos.productOutletPriceRepo),
orderProcessor: processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService),
paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo),
fileProcessor: processor.NewFileProcessorImpl(repos.fileRepo, fileClient),
customerProcessor: processor.NewCustomerProcessor(repos.customerRepo),
analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo, repos.expenseRepo),
analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo),
tableProcessor: processor.NewTableProcessor(repos.tableRepo, repos.orderRepo),
unitProcessor: processor.NewUnitProcessor(repos.unitRepo),
ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo),
productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo),
vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo),
purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.purchaseCategoryRepo, repos.categoryRepo, repos.cashAdvanceRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo),
purchaseCategoryProcessor: processor.NewPurchaseCategoryProcessorImpl(repos.purchaseCategoryRepo),
purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo),
unitConverterProcessor: processor.NewIngredientUnitConverterProcessorImpl(repos.unitConverterRepo, repos.ingredientRepo, repos.unitRepo),
chartOfAccountTypeProcessor: processor.NewChartOfAccountTypeProcessorImpl(repos.chartOfAccountTypeRepo),
chartOfAccountProcessor: processor.NewChartOfAccountProcessorImpl(repos.chartOfAccountRepo, repos.chartOfAccountTypeRepo),
@@ -397,12 +345,8 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
customerPointsProcessor: processor.NewCustomerPointsProcessor(repos.customerPointsRepo, repos.gameRepo),
otpProcessor: otpProcessor,
fileClient: fileClient,
fcmClient: fcmClient,
inventoryMovementService: inventoryMovementService,
userDeviceProcessor: processor.NewUserDeviceProcessorImpl(repos.userDeviceRepo),
notificationProcessor: buildNotificationProcessor(cfg, repos),
productOutletPriceProcessor: processor.NewProductOutletPriceProcessorImpl(repos.productOutletPriceRepo, repos.productRepo, repos.outletRepo),
expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo, repos.cashAdvanceRepo),
cashAdvanceProcessor: processor.NewCashAdvanceProcessorImpl(repos.cashAdvanceRepo, repos.categoryRepo),
}
}
@@ -428,7 +372,6 @@ type services struct {
productRecipeService *service.ProductRecipeServiceImpl
vendorService *service.VendorServiceImpl
purchaseOrderService *service.PurchaseOrderServiceImpl
purchaseCategoryService service.PurchaseCategoryService
unitConverterService *service.IngredientUnitConverterServiceImpl
chartOfAccountTypeService service.ChartOfAccountTypeService
chartOfAccountService service.ChartOfAccountService
@@ -440,16 +383,11 @@ type services struct {
customerAuthService service.CustomerAuthService
customerPointsService service.CustomerPointsService
spinGameService service.SpinGameService
userDeviceService service.UserDeviceService
notificationService service.NotificationService
productOutletPriceService service.ProductOutletPriceService
expenseService *service.ExpenseServiceImpl
cashAdvanceService *service.CashAdvanceServiceImpl
}
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
authConfig := cfg.Auth()
authService := service.NewAuthService(processors.userProcessor, processors.userDeviceProcessor, authConfig)
authService := service.NewAuthService(processors.userProcessor, authConfig)
organizationService := service.NewOrganizationService(processors.organizationProcessor)
outletService := service.NewOutletService(processors.outletProcessor)
outletSettingService := service.NewOutletSettingService(processors.outletSettingProcessor)
@@ -457,7 +395,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
productService := service.NewProductService(processors.productProcessor)
productVariantService := service.NewProductVariantService(processors.productVariantProcessor)
inventoryService := service.NewInventoryService(processors.inventoryProcessor)
orderService := service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, nil, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager, repos.sessionRepo, processors.notificationProcessor, repos.userRepo) // Will be updated after orderIngredientTransactionService is created
orderService := service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, nil, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager, repos.sessionRepo) // Will be updated after orderIngredientTransactionService is created
paymentMethodService := service.NewPaymentMethodService(processors.paymentMethodProcessor)
fileService := service.NewFileServiceImpl(processors.fileProcessor)
var customerService service.CustomerService = service.NewCustomerService(processors.customerProcessor)
@@ -469,7 +407,6 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
productRecipeService := service.NewProductRecipeService(processors.productRecipeProcessor)
vendorService := service.NewVendorService(processors.vendorProcessor)
purchaseOrderService := service.NewPurchaseOrderService(processors.purchaseOrderProcessor)
purchaseCategoryService := service.NewPurchaseCategoryService(processors.purchaseCategoryProcessor)
unitConverterService := service.NewIngredientUnitConverterService(processors.unitConverterProcessor)
chartOfAccountTypeService := service.NewChartOfAccountTypeService(processors.chartOfAccountTypeProcessor)
chartOfAccountService := service.NewChartOfAccountService(processors.chartOfAccountProcessor)
@@ -481,11 +418,9 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
customerAuthService := service.NewCustomerAuthService(processors.customerAuthProcessor)
customerPointsService := service.NewCustomerPointsService(processors.customerPointsProcessor)
spinGameService := service.NewSpinGameService(processors.gamePlayProcessor, repos.txManager)
userDeviceService := service.NewUserDeviceService(processors.userDeviceProcessor)
notificationService := service.NewNotificationService(processors.notificationProcessor)
// Update order service with order ingredient transaction service
orderService = service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, orderIngredientTransactionService, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager, repos.sessionRepo, processors.notificationProcessor, repos.userRepo)
orderService = service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, orderIngredientTransactionService, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager, repos.sessionRepo)
return &services{
userService: service.NewUserService(processors.userProcessor),
@@ -509,7 +444,6 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
productRecipeService: productRecipeService,
vendorService: vendorService,
purchaseOrderService: purchaseOrderService,
purchaseCategoryService: purchaseCategoryService,
unitConverterService: unitConverterService,
chartOfAccountTypeService: chartOfAccountTypeService,
chartOfAccountService: chartOfAccountService,
@@ -521,11 +455,6 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
customerAuthService: customerAuthService,
customerPointsService: customerPointsService,
spinGameService: spinGameService,
userDeviceService: userDeviceService,
notificationService: notificationService,
productOutletPriceService: service.NewProductOutletPriceService(processors.productOutletPriceProcessor),
expenseService: service.NewExpenseService(processors.expenseProcessor),
cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor),
}
}
@@ -556,7 +485,6 @@ type validators struct {
tableValidator *validator.TableValidator
vendorValidator *validator.VendorValidatorImpl
purchaseOrderValidator *validator.PurchaseOrderValidatorImpl
purchaseCategoryValidator *validator.PurchaseCategoryValidatorImpl
unitConverterValidator *validator.IngredientUnitConverterValidatorImpl
chartOfAccountTypeValidator *validator.ChartOfAccountTypeValidatorImpl
chartOfAccountValidator *validator.ChartOfAccountValidatorImpl
@@ -566,11 +494,6 @@ type validators struct {
rewardValidator validator.RewardValidator
campaignValidator validator.CampaignValidator
customerAuthValidator validator.CustomerAuthValidator
userDeviceValidator *validator.UserDeviceValidatorImpl
notificationValidator *validator.NotificationValidatorImpl
productOutletPriceValidator *validator.ProductOutletPriceValidatorImpl
expenseValidator *validator.ExpenseValidatorImpl
cashAdvanceValidator *validator.CashAdvanceValidatorImpl
}
func (a *App) initValidators() *validators {
@@ -589,7 +512,6 @@ func (a *App) initValidators() *validators {
tableValidator: validator.NewTableValidator(),
vendorValidator: validator.NewVendorValidator(),
purchaseOrderValidator: validator.NewPurchaseOrderValidator(),
purchaseCategoryValidator: validator.NewPurchaseCategoryValidator(),
unitConverterValidator: validator.NewIngredientUnitConverterValidator().(*validator.IngredientUnitConverterValidatorImpl),
chartOfAccountTypeValidator: validator.NewChartOfAccountTypeValidator().(*validator.ChartOfAccountTypeValidatorImpl),
chartOfAccountValidator: validator.NewChartOfAccountValidator().(*validator.ChartOfAccountValidatorImpl),
@@ -599,33 +521,5 @@ func (a *App) initValidators() *validators {
rewardValidator: validator.NewRewardValidator(),
campaignValidator: validator.NewCampaignValidator(),
customerAuthValidator: validator.NewCustomerAuthValidator(),
userDeviceValidator: validator.NewUserDeviceValidator(),
notificationValidator: validator.NewNotificationValidator(),
productOutletPriceValidator: validator.NewProductOutletPriceValidator(),
expenseValidator: validator.NewExpenseValidator(),
cashAdvanceValidator: validator.NewCashAdvanceValidator(),
}
}
// buildNotificationProcessor creates the notification processor with FCM integration.
// If FCM is not configured, it returns a processor with a nil FCM client (FCM dispatch will be skipped).
func buildNotificationProcessor(cfg *config.Config, repos *repositories) *processor.NotificationProcessorImpl {
var fcmClient client.FCMClient
if cfg.FCM.CredentialsFile != "" {
var err error
fcmClient, err = client.NewFCMClient(&cfg.FCM)
if err != nil {
// FCM init failure is non-fatal; notifications will still be persisted.
fcmClient = nil
}
}
return processor.NewNotificationProcessor(
repos.notificationRepo,
repos.notificationReceiverRepo,
repos.notificationDeliveryRepo,
repos.userDeviceRepo,
repos.userRepo,
fcmClient,
)
}
+35 -94
View File
@@ -3,140 +3,81 @@ package client
import (
"context"
"fmt"
"log"
"apskel-pos-be/config"
firebase "firebase.google.com/go/v4"
"firebase.google.com/go/v4/messaging"
"google.golang.org/api/option"
)
type FCMConfig interface {
GetCredentialsFile() string
GetProjectID() string
}
type FCMClient interface {
SendNotification(ctx context.Context, token string, title string, body string, data map[string]string) error
SendMulticastNotification(ctx context.Context, tokens []string, title string, body string, data map[string]string) error
SendToTopic(ctx context.Context, topic string, title string, body string, data map[string]string) error
type FcmClient interface {
SendMulticastNotification(ctx context.Context, tokens []string, title string, body string) error
}
type fcmClient struct {
messaging *messaging.Client
messagingClient *messaging.Client
}
func NewFCMClient(cfg FCMConfig) (FCMClient, error) {
ctx := context.Background()
func NewFcmClient(cfg *config.Firebase) FcmClient {
if cfg == nil || cfg.GetCredentialsFile() == "" {
log.Println("FCM: credentials file not configured, FCM client is disabled")
return &fcmClient{messagingClient: nil}
}
opt := option.WithCredentialsFile(cfg.GetCredentialsFile())
app, err := firebase.NewApp(ctx, &firebase.Config{
ProjectID: cfg.GetProjectID(),
}, opt)
app, err := firebase.NewApp(context.Background(), nil, opt)
if err != nil {
return nil, fmt.Errorf("failed to initialize firebase app: %w", err)
log.Printf("FCM: failed to initialize Firebase app: %v", err)
return &fcmClient{messagingClient: nil}
}
msgClient, err := app.Messaging(ctx)
client, err := app.Messaging(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to initialize firebase messaging client: %w", err)
log.Printf("FCM: failed to create messaging client: %v", err)
return &fcmClient{messagingClient: nil}
}
return &fcmClient{
messaging: msgClient,
}, nil
log.Println("FCM: client initialized successfully")
return &fcmClient{messagingClient: client}
}
// SendNotification sends a push notification to a single device token.
func (f *fcmClient) SendNotification(ctx context.Context, token string, title string, body string, data map[string]string) error {
message := &messaging.Message{
Token: token,
Notification: &messaging.Notification{
Title: title,
Body: body,
},
Data: data,
Android: &messaging.AndroidConfig{
Priority: "high",
},
APNS: &messaging.APNSConfig{
Payload: &messaging.APNSPayload{
Aps: &messaging.Aps{
Sound: "default",
},
},
},
}
_, err := f.messaging.Send(ctx, message)
if err != nil {
return fmt.Errorf("failed to send FCM notification: %w", err)
}
func (c *fcmClient) SendMulticastNotification(ctx context.Context, tokens []string, title string, body string) error {
if c.messagingClient == nil {
log.Println("FCM: client not initialized, skipping notification")
return nil
}
}
// SendMulticastNotification sends a push notification to multiple device tokens.
func (f *fcmClient) SendMulticastNotification(ctx context.Context, tokens []string, title string, body string, data map[string]string) error {
if len(tokens) == 0 {
return nil
}
message := &messaging.MulticastMessage{
Notification: &messaging.Notification{
Title: title,
Body: body,
},
Tokens: tokens,
Notification: &messaging.Notification{
Title: title,
Body: body,
},
Data: data,
Android: &messaging.AndroidConfig{
Priority: "high",
},
APNS: &messaging.APNSConfig{
Payload: &messaging.APNSPayload{
Aps: &messaging.Aps{
Sound: "default",
},
},
},
}
batchResp, err := f.messaging.SendEachForMulticast(ctx, message)
response, err := c.messagingClient.SendMulticast(ctx, message)
if err != nil {
return fmt.Errorf("failed to send FCM multicast notification: %w", err)
return fmt.Errorf("FCM: failed to send multicast notification: %w", err)
}
if batchResp.FailureCount > 0 {
return fmt.Errorf("FCM multicast: %d/%d messages failed to send", batchResp.FailureCount, len(tokens))
if response.FailureCount > 0 {
log.Printf("FCM: %d tokens failed out of %d", response.FailureCount, len(tokens))
for i, resp := range response.Responses {
if !resp.Success {
log.Printf("FCM: token[%d] failed: %v", i, resp.Error)
}
return nil
}
// SendToTopic sends a push notification to all devices subscribed to a topic.
func (f *fcmClient) SendToTopic(ctx context.Context, topic string, title string, body string, data map[string]string) error {
message := &messaging.Message{
Topic: topic,
Notification: &messaging.Notification{
Title: title,
Body: body,
},
Data: data,
Android: &messaging.AndroidConfig{
Priority: "high",
},
APNS: &messaging.APNSConfig{
Payload: &messaging.APNSPayload{
Aps: &messaging.Aps{
Sound: "default",
},
},
},
}
_, err := f.messaging.Send(ctx, message)
if err != nil {
return fmt.Errorf("failed to send FCM topic notification: %w", err)
}
log.Printf("FCM: sent %d/%d notifications successfully", response.SuccessCount, len(tokens))
return nil
}
-9
View File
@@ -1,9 +0,0 @@
package constants
// Budget allocation of revenue used by the parent category cut-off report.
// The three shares are expected to add up to 100.
const (
BudgetLimitPurchasePercent = 60.0
BudgetLimitOwnerPercent = 20.0
BudgetLimitTeamPercent = 20.0
)
-62
View File
@@ -1,62 +0,0 @@
package constants
// A cash advance is money handed to a team so it can go shopping. Its status is
// about the document only — whether the money may leave the drawer. How much of it
// has been accounted for is a separate axis, derived from the spending charged to
// the advance rather than stored, so the two never have to be kept in step.
const (
CashAdvanceStatusDraft = "draft"
CashAdvanceStatusApproved = "approved"
CashAdvanceStatusRejected = "rejected"
CashAdvanceStatusCancelled = "cancelled"
)
// Settlement states come out of the amount, the spending charged to the advance, and
// the cash handed back. An advance the team overspent still counts as settled: the
// shortfall is owed back to the team and shows up as a negative remaining amount.
const (
CashAdvanceSettlementOpen = "open"
CashAdvanceSettlementPartial = "partial"
CashAdvanceSettlementSettled = "settled"
)
func GetAllCashAdvanceStatuses() []string {
return []string{
CashAdvanceStatusDraft,
CashAdvanceStatusApproved,
CashAdvanceStatusRejected,
CashAdvanceStatusCancelled,
}
}
func IsValidCashAdvanceStatus(status string) bool {
for _, valid := range GetAllCashAdvanceStatuses() {
if status == valid {
return true
}
}
return false
}
func GetAllCashAdvanceSettlementStatuses() []string {
return []string{
CashAdvanceSettlementOpen,
CashAdvanceSettlementPartial,
CashAdvanceSettlementSettled,
}
}
func IsValidCashAdvanceSettlementStatus(status string) bool {
for _, valid := range GetAllCashAdvanceSettlementStatuses() {
if status == valid {
return true
}
}
return false
}
// Settlement entries name where a piece of spending was recorded.
const (
CashAdvanceSettlementTypePurchaseOrder = "purchase_order"
CashAdvanceSettlementTypeExpense = "expense"
)
-7
View File
@@ -40,7 +40,6 @@ const (
OutletServiceEntity = "outlet_service"
VendorServiceEntity = "vendor_service"
PurchaseOrderServiceEntity = "purchase_order_service"
PurchaseCategoryServiceEntity = "purchase_category_service"
IngredientUnitConverterServiceEntity = "ingredient_unit_converter_service"
IngredientCompositionServiceEntity = "ingredient_composition_service"
TableEntity = "table"
@@ -57,12 +56,6 @@ const (
CampaignRuleEntity = "campaign_rule"
CustomerEntity = "customer"
SpinGameHandlerEntity = "spin_game_handler"
UserDeviceServiceEntity = "user_device_service"
NotificationServiceEntity = "notification_service"
NotificationHandlerEntity = "notification_handler"
ProductOutletPriceServiceEntity = "product_outlet_price_service"
ExpenseServiceEntity = "expense_service"
CashAdvanceServiceEntity = "cash_advance_service"
)
var HttpErrorMap = map[string]int{
-28
View File
@@ -1,28 +0,0 @@
package constants
type ExpenseStatus string
const (
ExpenseStatusDraft ExpenseStatus = "draft"
ExpenseStatusSent ExpenseStatus = "sent"
ExpenseStatusApproved ExpenseStatus = "approved"
ExpenseStatusCancel ExpenseStatus = "cancel"
)
func GetAllExpenseStatuses() []ExpenseStatus {
return []ExpenseStatus{
ExpenseStatusDraft,
ExpenseStatusSent,
ExpenseStatusApproved,
ExpenseStatusCancel,
}
}
func IsValidExpenseStatus(status ExpenseStatus) bool {
for _, validStatus := range GetAllExpenseStatuses() {
if status == validStatus {
return true
}
}
return false
}
-19
View File
@@ -1,19 +0,0 @@
package constants
// A purchase order is charged to a team. Teams come from the parent product
// categories, plus Pusat for spending that belongs to no single team.
const (
PurchaseTeamScopeCategory = "category"
PurchaseTeamScopeCentral = "central"
// PurchaseTeamCentralName is what Pusat is called in the picker. Pusat has no
// row of its own, so the name lives here rather than in the database.
PurchaseTeamCentralName = "Pusat"
// PurchaseTeamNone is the value the list filter takes to ask for purchases
// that have not been charged to any team yet.
PurchaseTeamNone = "none"
// PurchaseTeamNoneName labels those purchases in the reports.
PurchaseTeamNoneName = "Tanpa Team"
)
-4
View File
@@ -7,8 +7,6 @@ const (
RoleManager UserRole = "manager"
RoleCashier UserRole = "cashier"
RoleWaiter UserRole = "waiter"
RoleOwner UserRole = "owner"
RolePurchasing UserRole = "purchasing"
)
func GetAllUserRoles() []UserRole {
@@ -17,8 +15,6 @@ func GetAllUserRoles() []UserRole {
RoleManager,
RoleCashier,
RoleWaiter,
RoleOwner,
RolePurchasing,
}
}
+11 -387
View File
@@ -8,7 +8,7 @@ import (
type PaymentMethodAnalyticsRequest struct {
OrganizationID uuid.UUID `form:"organization_id"`
OutletID *string `form:"outlet_id,omitempty"`
OutletID *uuid.UUID `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"`
@@ -18,7 +18,6 @@ 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"`
@@ -46,7 +45,7 @@ type PaymentMethodAnalyticsData struct {
type SalesAnalyticsRequest struct {
OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"`
OutletID *uuid.UUID `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"`
@@ -55,7 +54,6 @@ 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"`
@@ -85,95 +83,10 @@ type SalesAnalyticsData struct {
NetSales float64 `json:"net_sales"`
}
type PurchasingAnalyticsRequest struct {
OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"`
// 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"`
Summary PurchasingSummary `json:"summary"`
Data []PurchasingAnalyticsData `json:"data"`
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
VendorData []PurchasingVendorData `json:"vendor_data"`
TeamData []PurchasingTeamData `json:"team_data"`
}
// PurchasingTeamData is one team's share of the purchases. Scope and CategoryID
// are exactly what the team filter takes, so a row doubles as a drill-down link.
type PurchasingTeamData struct {
Scope string `json:"scope"`
CategoryID *uuid.UUID `json:"category_id"`
Name string `json:"name"`
TotalPurchases float64 `json:"total_purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
Quantity float64 `json:"quantity"`
Percentage float64 `json:"percentage"`
}
type PurchasingSummary struct {
TotalPurchases float64 `json:"total_purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
TotalPurchaseOrders int64 `json:"total_purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
TotalQuantity float64 `json:"total_quantity"`
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"`
TotalVendors int64 `json:"total_vendors"`
TotalTeams int64 `json:"total_teams"`
}
type PurchasingAnalyticsData struct {
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
}
type PurchasingIngredientData struct {
IngredientID uuid.UUID `json:"ingredient_id"`
IngredientName string `json:"ingredient_name"`
Quantity float64 `json:"quantity"`
TotalCost float64 `json:"total_cost"`
AverageUnitCost float64 `json:"average_unit_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
}
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"`
}
// ProductAnalyticsRequest represents the request for product analytics
type ProductAnalyticsRequest struct {
OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"`
OutletID *uuid.UUID `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"`
Limit int `form:"limit,default=1000" validate:"min=1,max=1000"`
@@ -183,7 +96,6 @@ 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"`
@@ -193,7 +105,6 @@ type ProductAnalyticsData struct {
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
ProductSku string `json:"product_sku"`
ProductPrice float64 `json:"product_price"`
CategoryID uuid.UUID `json:"category_id"`
CategoryName string `json:"category_name"`
CategoryOrder int `json:"category_order"`
@@ -212,7 +123,7 @@ type ProductAnalyticsData struct {
// ProductAnalyticsPerCategoryRequest represents the request for product analytics per category
type ProductAnalyticsPerCategoryRequest struct {
OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"`
OutletID *uuid.UUID `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"`
}
@@ -221,7 +132,6 @@ 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"`
@@ -239,139 +149,10 @@ 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
OutletID *string `form:"outlet_id,omitempty"`
OutletID *uuid.UUID `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"`
}
@@ -380,7 +161,6 @@ 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"`
@@ -397,52 +177,30 @@ 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"`
}
// ProfitLossAnalyticsRequest represents the request for profit and loss analytics
type ProfitLossAnalyticsRequest struct {
OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"`
OutletID *uuid.UUID `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"`
}
// ProfitLossAnalyticsResponse represents the response for profit and loss analytics
type ProfitLossAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
Summary ProfitLossSummary `json:"summary"`
Data []ProfitLossData `json:"data"`
ProductData []ProductProfitData `json:"product_data"`
MainSummary []ProfitLossSummaryRow `json:"main_summary"`
Purchasing ProfitLossPurchasing `json:"purchasing"`
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
}
type ProfitLossPurchasing struct {
TodayTotal float64 `json:"today_total"`
MtdTotal float64 `json:"mtd_total"`
TodayRawMaterial float64 `json:"today_raw_material"`
MtdRawMaterial float64 `json:"mtd_raw_material"`
TodayExpense float64 `json:"today_expense"`
MtdExpense float64 `json:"mtd_expense"`
Items []ProfitLossPurchasingItem `json:"items"`
}
type ProfitLossPurchasingItem struct {
Date time.Time `json:"date"`
Item string `json:"item"`
Quantity float64 `json:"quantity"`
Nominal float64 `json:"nominal"`
}
// ProfitLossSummary represents the summary of profit and loss analytics
type ProfitLossSummary struct {
TotalRevenue float64 `json:"total_revenue"`
TotalCost float64 `json:"total_cost"`
@@ -457,6 +215,7 @@ type ProfitLossSummary struct {
ProfitabilityRatio float64 `json:"profitability_ratio"`
}
// ProfitLossData represents individual profit and loss data point by time period
type ProfitLossData struct {
Date time.Time `json:"date"`
Revenue float64 `json:"revenue"`
@@ -470,6 +229,7 @@ type ProfitLossData struct {
Orders int64 `json:"orders"`
}
// ProductProfitData represents profit data for individual products
type ProductProfitData struct {
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
@@ -484,139 +244,3 @@ type ProductProfitData struct {
AverageCost float64 `json:"average_cost"`
ProfitPerUnit float64 `json:"profit_per_unit"`
}
type ProfitLossSummaryRow struct {
ID string `json:"id"`
Label string `json:"label"`
IsBold bool `json:"is_bold"`
TodayNominal float64 `json:"today_nominal"`
TodayPct float64 `json:"today_pct"`
MtdNominal float64 `json:"mtd_nominal"`
MtdPct float64 `json:"mtd_pct"`
SubItems []ProfitLossSummaryRow `json:"sub_items,omitempty"`
}
type OperationalExpenseItem struct {
Item string `json:"item"`
Nominal float64 `json:"nominal"`
}
type ExclusiveSummaryPeriodRequest struct {
OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"`
ExcludeGajiStaffFromReimburse bool `form:"exclude_gaji_staff_from_reimburse"`
}
type ExclusiveSummaryMonthlyRequest struct {
OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"`
Month string `form:"month" validate:"required"`
}
type ExclusiveSummaryMTDRequest struct {
OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"`
DateTo string `form:"date_to" validate:"required"`
ExcludeGajiStaffFromReimburse bool `form:"exclude_gaji_staff_from_reimburse"`
}
type ExclusiveSummaryPeriodResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
Period ExclusiveSummaryPeriodRange `json:"period"`
Summary ExclusiveSummaryPeriodSummary `json:"summary"`
Reimburse ExclusiveSummaryReimburse `json:"reimburse"`
HPPBreakdown []ExclusiveSummaryCategoryBreakdown `json:"hpp_breakdown"`
OperationalExpenseBreakdown []ExclusiveSummaryCategoryBreakdown `json:"operational_expense_breakdown"`
DailySummary []ExclusiveSummaryDailySummary `json:"daily_summary"`
DailyTransactions []ExclusiveSummaryDailyTransaction `json:"daily_transactions"`
}
type ExclusiveSummaryPeriodRange struct {
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
}
type ExclusiveSummaryPeriodSummary struct {
Sales float64 `json:"sales"`
HPP float64 `json:"hpp"`
GrossProfit float64 `json:"gross_profit"`
SalaryTotal float64 `json:"salary_total"`
SalaryDW float64 `json:"salary_dw"`
SalaryStaff float64 `json:"salary_staff"`
SalaryOther float64 `json:"salary_other"`
OtherOperationalExpenses float64 `json:"other_operational_expenses"`
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
TotalCost float64 `json:"total_cost"`
NetProfit float64 `json:"net_profit"`
}
type ExclusiveSummaryReimburse struct {
TotalCost float64 `json:"total_cost"`
ExcludedSalaryStaff float64 `json:"excluded_salary_staff"`
TotalReimburse float64 `json:"total_reimburse"`
}
type ExclusiveSummaryCategoryBreakdown struct {
CategoryCode string `json:"category_code"`
CategoryName string `json:"category_name"`
Amount float64 `json:"amount"`
Percentage float64 `json:"percentage"`
}
type ExclusiveSummaryDailySummary struct {
Date time.Time `json:"date"`
TransactionCount int64 `json:"transaction_count"`
TotalCost float64 `json:"total_cost"`
}
type ExclusiveSummaryDailyTransaction struct {
Date time.Time `json:"date"`
CategoryCode string `json:"category_code"`
CategoryName string `json:"category_name"`
Description string `json:"description"`
Amount float64 `json:"amount"`
Source string `json:"source"`
}
type ExclusiveSummaryMonthlyResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
Month string `json:"month"`
Summary ExclusiveSummaryMonthlySummary `json:"summary"`
Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"`
BankBalance []ExclusiveSummaryBankBalance `json:"bank_balance"`
}
type ExclusiveSummaryMonthlySummary struct {
TotalSales float64 `json:"total_sales"`
HPP float64 `json:"hpp"`
GrossProfit float64 `json:"gross_profit"`
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
TotalCost float64 `json:"total_cost"`
NetProfit float64 `json:"net_profit"`
NetProfitMargin float64 `json:"net_profit_margin"`
}
type ExclusiveSummaryMonthlyPeriod struct {
Label string `json:"label"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
Sales float64 `json:"sales"`
HPP float64 `json:"hpp"`
GrossProfit float64 `json:"gross_profit"`
GrossMargin float64 `json:"gross_margin"`
}
type ExclusiveSummaryBankBalance struct {
Bank string `json:"bank"`
OpeningBalance *float64 `json:"opening_balance"`
IncomingMutation *float64 `json:"incoming_mutation"`
OutgoingMutation *float64 `json:"outgoing_mutation"`
ClosingBalance *float64 `json:"closing_balance"`
Notes *string `json:"notes"`
}
@@ -1,92 +0,0 @@
package contract
import (
"time"
"github.com/google/uuid"
)
type CreateCashAdvanceRequest struct {
// OutletID falls back to the caller's outlet when omitted; an advance is cash out
// of one drawer, so one of the two has to be known.
OutletID *uuid.UUID `json:"outlet_id,omitempty" validate:"omitempty"`
CodeNumber string `json:"code_number" validate:"required,min=1,max=50"`
TeamScope string `json:"team_scope" validate:"required,oneof=category central"`
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"`
Amount float64 `json:"amount" validate:"required,gt=0"`
IssuedDate string `json:"issued_date" validate:"required"` // Format: YYYY-MM-DD
DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft approved rejected cancelled"`
Description *string `json:"description,omitempty" validate:"omitempty"`
}
type UpdateCashAdvanceRequest struct {
CodeNumber *string `json:"code_number,omitempty" validate:"omitempty,min=1,max=50"`
// An advance always belongs to a team, so team_scope can be moved but not cleared.
TeamScope *string `json:"team_scope,omitempty" validate:"omitempty,oneof=category central"`
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"`
Amount *float64 `json:"amount,omitempty" validate:"omitempty,gt=0"`
ReturnedAmount *float64 `json:"returned_amount,omitempty" validate:"omitempty,gte=0"`
IssuedDate *string `json:"issued_date,omitempty" validate:"omitempty"`
DueDate *string `json:"due_date,omitempty" validate:"omitempty"`
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft approved rejected cancelled"`
Description *string `json:"description,omitempty" validate:"omitempty"`
}
type CashAdvanceResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID uuid.UUID `json:"outlet_id"`
CodeNumber string `json:"code_number"`
TeamScope string `json:"team_scope"`
TeamCategoryID *uuid.UUID `json:"team_category_id"`
Amount float64 `json:"amount"`
SettledAmount float64 `json:"settled_amount"`
ReturnedAmount float64 `json:"returned_amount"`
RemainingAmount float64 `json:"remaining_amount"`
SettlementStatus string `json:"settlement_status"`
IssuedDate time.Time `json:"issued_date"`
DueDate *time.Time `json:"due_date"`
Status string `json:"status"`
Description *string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Team *PurchaseTeamResponse `json:"team,omitempty"`
Settlements []CashAdvanceSettlementResponse `json:"settlements,omitempty"`
}
// CashAdvanceSettlementResponse is one purchase order or expense paid out of the
// advance.
type CashAdvanceSettlementResponse struct {
Type string `json:"type"`
ID uuid.UUID `json:"id"`
Number string `json:"number"`
Date time.Time `json:"date"`
Amount float64 `json:"amount"`
Status string `json:"status"`
}
type ListCashAdvancesRequest 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 approved rejected cancelled"`
// SettlementStatus filters on how much of the cash has been accounted for,
// which is derived from the spending charged to the advance rather than stored.
SettlementStatus string `json:"settlement_status,omitempty" validate:"omitempty,oneof=open partial settled"`
// 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 or "central" for Pusat.
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 ListCashAdvancesResponse struct {
CashAdvances []CashAdvanceResponse `json:"cash_advances"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}
-10
View File
@@ -10,8 +10,6 @@ type CreateCategoryRequest struct {
Name string `json:"name" validate:"required,min=1,max=255"`
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,17 +18,12 @@ type UpdateCategoryRequest struct {
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
Description *string `json:"description,omitempty"`
BusinessType *string `json:"business_type,omitempty"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Order *int `json:"order,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type ListCategoriesRequest struct {
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Type string `json:"type,omitempty" validate:"omitempty,oneof=parent child"`
BusinessType string `json:"business_type,omitempty"`
Search string `json:"search,omitempty"`
Page int `json:"page" validate:"required,min=1"`
@@ -41,9 +34,6 @@ type ListCategoriesRequest struct {
type CategoryResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
ParentName *string `json:"parent_name,omitempty"`
Name string `json:"name"`
Description *string `json:"description"`
BusinessType string `json:"business_type"`
-167
View File
@@ -1,167 +0,0 @@
package contract
import (
"time"
"github.com/google/uuid"
)
type CreateExpenseRequest struct {
Receiver string `json:"receiver" validate:"required"`
TransactionDate string `json:"transaction_date" validate:"required"`
CodeNumber string `json:"code_number" validate:"required"`
OutletID string `json:"outlet_id" validate:"required"`
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"`
Description *string `json:"description,omitempty"`
Tax float64 `json:"tax"`
Total float64 `json:"total" validate:"required"`
// CashAdvanceID marks the expense as paid out of cash advanced to a team, which is
// what accounts for that advance.
CashAdvanceID *string `json:"cash_advance_id,omitempty"`
Items []CreateExpenseItemRequest `json:"items" validate:"required"`
}
type CreateExpenseItemRequest struct {
ChartOfAccountID string `json:"chart_of_account_id" validate:"required"`
PurchaseCategoryID string `json:"purchase_category_id" validate:"required"`
Item string `json:"item" validate:"required"`
Description *string `json:"description,omitempty"`
Amount float64 `json:"amount" validate:"required"`
}
type UpdateExpenseRequest struct {
Receiver *string `json:"receiver,omitempty"`
TransactionDate *string `json:"transaction_date,omitempty"`
CodeNumber *string `json:"code_number,omitempty"`
OutletID *string `json:"outlet_id,omitempty"`
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"`
Description *string `json:"description,omitempty"`
Tax *float64 `json:"tax,omitempty"`
Total *float64 `json:"total,omitempty"`
Reserved1 *string `json:"reserved1,omitempty"`
// An empty string unlinks the cash advance; omitting the field leaves it untouched.
CashAdvanceID *string `json:"cash_advance_id,omitempty"`
Items []UpdateExpenseItemRequest `json:"items,omitempty"`
}
type UpdateExpenseItemRequest struct {
ChartOfAccountID *string `json:"chart_of_account_id,omitempty"`
PurchaseCategoryID *string `json:"purchase_category_id,omitempty"`
Item *string `json:"item,omitempty"`
Description *string `json:"description,omitempty"`
Amount *float64 `json:"amount,omitempty"`
}
type ExpenseResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID uuid.UUID `json:"outlet_id"`
Receiver string `json:"receiver"`
TransactionDate time.Time `json:"transaction_date"`
CodeNumber string `json:"code_number"`
Status string `json:"status"`
Description *string `json:"description"`
Tax float64 `json:"tax"`
Total float64 `json:"total"`
Reserved1 *string `json:"reserved1,omitempty"`
CashAdvanceID *uuid.UUID `json:"cash_advance_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Items []ExpenseItemResponse `json:"items,omitempty"`
}
type ExpenseItemResponse struct {
ID uuid.UUID `json:"id"`
ExpenseID uuid.UUID `json:"expense_id"`
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
ChartOfAccountName string `json:"chart_of_account_name,omitempty"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
PurchaseCategoryName string `json:"purchase_category_name,omitempty"`
PurchaseCategoryType string `json:"purchase_category_type,omitempty"`
PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
Item string `json:"item"`
Description *string `json:"description"`
Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListExpenseRequest struct {
Page int `json:"page" validate:"min=1"`
Limit int `json:"limit" validate:"min=1,max=100"`
Search string `json:"search,omitempty"`
OutletID string `json:"outlet_id,omitempty"`
Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"`
StartDate string `json:"start_date,omitempty"`
EndDate string `json:"end_date,omitempty"`
}
type ListExpenseResponse struct {
Expenses []ExpenseResponse `json:"expenses"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}
type ExpenseAnalyticsRequest struct {
OutletID *string `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"`
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
}
type ExpenseAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
Summary ExpenseAnalyticsSummary `json:"summary"`
Data []ExpenseAnalyticsData `json:"data"`
CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"`
ChartOfAccountData []ExpenseAnalyticsChartOfAccountData `json:"chart_of_account_data"`
ItemData []ExpenseAnalyticsItemData `json:"item_data"`
}
type ExpenseAnalyticsSummary struct {
TotalExpenses float64 `json:"total_expenses"`
TotalExpenseCount int64 `json:"total_expense_count"`
TotalTax float64 `json:"total_tax"`
AverageExpenseValue float64 `json:"average_expense_value"`
TotalCategories int64 `json:"total_categories"`
TotalItems int64 `json:"total_items"`
}
type ExpenseAnalyticsData struct {
Date time.Time `json:"date"`
Expenses float64 `json:"expenses"`
ExpenseCount int64 `json:"expense_count"`
Tax float64 `json:"tax"`
Items int64 `json:"items"`
Categories int64 `json:"categories"`
}
type ExpenseAnalyticsCategoryData struct {
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
PurchaseCategoryName string `json:"purchase_category_name"`
PurchaseCategoryType string `json:"purchase_category_type"`
TotalAmount float64 `json:"total_amount"`
ExpenseCount int64 `json:"expense_count"`
ItemCount int64 `json:"item_count"`
}
type ExpenseAnalyticsChartOfAccountData struct {
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
ChartOfAccountName string `json:"chart_of_account_name"`
TotalAmount float64 `json:"total_amount"`
ExpenseCount int64 `json:"expense_count"`
ItemCount int64 `json:"item_count"`
}
type ExpenseAnalyticsItemData struct {
Item string `json:"item"`
TotalAmount float64 `json:"total_amount"`
ExpenseCount int64 `json:"expense_count"`
ItemCount int64 `json:"item_count"`
}
@@ -77,7 +77,8 @@ 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"`
}
@@ -1,92 +0,0 @@
package contract
import (
"time"
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
)
// ---- Request contracts ----
type SendNotificationRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"`
Body string `json:"body" validate:"required"`
Type string `json:"type,omitempty" validate:"omitempty,max=100"`
Category string `json:"category,omitempty" validate:"omitempty,max=100"`
Priority entities.NotificationPriority `json:"priority,omitempty" validate:"omitempty,oneof=low normal high"`
ImageURL string `json:"image_url,omitempty" validate:"omitempty,max=512"`
ActionURL string `json:"action_url,omitempty" validate:"omitempty,max=512"`
NotifiableType string `json:"notifiable_type,omitempty" validate:"omitempty,max=100"`
NotifiableID *uuid.UUID `json:"notifiable_id,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
ReceiverIDs []uuid.UUID `json:"receiver_ids" validate:"required,min=1"`
ScheduledAt *time.Time `json:"scheduled_at,omitempty"`
ExpiredAt *time.Time `json:"expired_at,omitempty"`
}
type BroadcastNotificationRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"`
Body string `json:"body" validate:"required"`
Type string `json:"type,omitempty" validate:"omitempty,max=100"`
Category string `json:"category,omitempty" validate:"omitempty,max=100"`
Priority entities.NotificationPriority `json:"priority,omitempty" validate:"omitempty,oneof=low normal high"`
ImageURL string `json:"image_url,omitempty" validate:"omitempty,max=512"`
ActionURL string `json:"action_url,omitempty" validate:"omitempty,max=512"`
NotifiableType string `json:"notifiable_type,omitempty" validate:"omitempty,max=100"`
NotifiableID *uuid.UUID `json:"notifiable_id,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
ScheduledAt *time.Time `json:"scheduled_at,omitempty"`
ExpiredAt *time.Time `json:"expired_at,omitempty"`
}
type ListNotificationsRequest struct {
Page int `form:"page" validate:"min=1"`
Limit int `form:"limit" validate:"min=1,max=100"`
IsRead *bool `form:"is_read"`
}
// ---- Response contracts ----
type NotificationResponse struct {
ID uuid.UUID `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Type string `json:"type"`
Category string `json:"category"`
Priority entities.NotificationPriority `json:"priority"`
ImageURL string `json:"image_url"`
ActionURL string `json:"action_url"`
NotifiableType string `json:"notifiable_type"`
NotifiableID *uuid.UUID `json:"notifiable_id"`
Data map[string]interface{} `json:"data"`
ScheduledAt *time.Time `json:"scheduled_at"`
SentAt *time.Time `json:"sent_at"`
ExpiredAt *time.Time `json:"expired_at"`
CreatedBy *uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type NotificationReceiverResponse struct {
ID uuid.UUID `json:"id"`
NotificationID uuid.UUID `json:"notification_id"`
UserID uuid.UUID `json:"user_id"`
IsRead bool `json:"is_read"`
ReadAt *time.Time `json:"read_at"`
IsDeleted bool `json:"is_deleted"`
DeletedAt *time.Time `json:"deleted_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Notification *NotificationResponse `json:"notification,omitempty"`
}
type ListNotificationsResponse struct {
Notifications []*NotificationReceiverResponse `json:"notifications"`
TotalCount int64 `json:"total_count"`
UnreadCount int64 `json:"unread_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}
-3
View File
@@ -98,8 +98,6 @@ type OrderItemResponse struct {
ProductName string `json:"product_name"`
ProductVariantID *uuid.UUID `json:"product_variant_id"`
ProductVariantName *string `json:"product_variant_name,omitempty"`
CategoryID *uuid.UUID `json:"category_id,omitempty"`
CategoryName *string `json:"category_name,omitempty"`
Quantity int `json:"quantity"`
UnitPrice float64 `json:"unit_price"`
TotalPrice float64 `json:"total_price"`
@@ -110,7 +108,6 @@ type OrderItemResponse struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PrinterType string `json:"printer_type"`
PrintToChecker bool `json:"print_to_checker"`
PaidQuantity int `json:"paid_quantity"`
}
+5 -12
View File
@@ -8,7 +8,6 @@ import (
type CreateProductRequest struct {
CategoryID uuid.UUID `json:"category_id" validate:"required"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
SKU *string `json:"sku,omitempty"`
Name string `json:"name" validate:"required,min=1,max=255"`
Description *string `json:"description,omitempty"`
@@ -17,17 +16,15 @@ type CreateProductRequest struct {
BusinessType *string `json:"business_type,omitempty"`
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
PrintToChecker *bool `json:"print_to_checker,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
Variants []CreateProductVariantRequest `json:"variants,omitempty"`
InitialStock *int `json:"initial_stock,omitempty" validate:"omitempty,min=0"`
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"`
CreateInventory bool `json:"create_inventory,omitempty"`
InitialStock *int `json:"initial_stock,omitempty" validate:"omitempty,min=0"` // Initial stock quantity for all outlets
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"` // Reorder level for all outlets
CreateInventory bool `json:"create_inventory,omitempty"` // Whether to create inventory records for all outlets
}
type UpdateProductRequest struct {
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
CategoryID *uuid.UUID `json:"category_id,omitempty"`
SKU *string `json:"sku,omitempty"`
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
@@ -37,10 +34,10 @@ type UpdateProductRequest struct {
BusinessType *string `json:"business_type,omitempty"`
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
PrintToChecker *bool `json:"print_to_checker,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"`
// Stock management fields
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"` // Update reorder level for all existing inventory records
}
type CreateProductVariantRequest struct {
@@ -67,13 +64,10 @@ type ProductResponse struct {
Name string `json:"name"`
Description *string `json:"description"`
Price float64 `json:"price"`
OutletPrice *float64 `json:"outlet_price,omitempty"`
OutletPrices []ProductOutletPriceResponse `json:"outlet_prices,omitempty"`
Cost float64 `json:"cost"`
BusinessType string `json:"business_type"`
ImageURL *string `json:"image_url"`
PrinterType string `json:"printer_type"`
PrintToChecker bool `json:"print_to_checker"`
Metadata map[string]interface{} `json:"metadata"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
@@ -95,7 +89,6 @@ type ProductVariantResponse struct {
type ListProductsRequest struct {
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
CategoryID *uuid.UUID `json:"category_id,omitempty"`
BusinessType string `json:"business_type,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
@@ -1,46 +0,0 @@
package contract
import (
"time"
"github.com/google/uuid"
)
type CreateProductOutletPriceRequest struct {
ProductID uuid.UUID `json:"product_id" validate:"required"`
OutletID uuid.UUID `json:"outlet_id" validate:"required"`
Price float64 `json:"price" validate:"required,min=0"`
PrintToChecker bool `json:"print_to_checker"`
}
type UpdateProductOutletPriceRequest struct {
Price float64 `json:"price" validate:"required,min=0"`
PrintToChecker *bool `json:"print_to_checker"`
}
type ProductOutletPriceResponse struct {
ID uuid.UUID `json:"id,omitempty"`
ProductID uuid.UUID `json:"product_id,omitempty"`
OutletID uuid.UUID `json:"outlet_id"`
OutletName string `json:"outlet_name,omitempty"`
Price float64 `json:"price"`
PrintToChecker bool `json:"print_to_checker"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
type ListProductOutletPricesResponse struct {
Prices []ProductOutletPriceResponse `json:"prices"`
TotalCount int `json:"total_count"`
}
type BulkCreateProductOutletPriceRequest struct {
ProductID uuid.UUID `json:"product_id" validate:"required"`
Prices []CreateProductOutletPricePerOutletRequest `json:"prices" validate:"required,dive"`
}
type CreateProductOutletPricePerOutletRequest struct {
OutletID uuid.UUID `json:"outlet_id" validate:"required"`
Price float64 `json:"price" validate:"required,min=0"`
PrintToChecker bool `json:"print_to_checker"`
}
+1 -1
View File
@@ -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"`
@@ -1,57 +0,0 @@
package contract
import (
"time"
"github.com/google/uuid"
)
type CreatePurchaseCategoryRequest struct {
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Code *string `json:"code,omitempty"`
Name string `json:"name" validate:"required,min=1,max=255"`
Type string `json:"type" validate:"required,oneof=raw_material expense"`
SortOrder *int `json:"sort_order,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
}
type UpdatePurchaseCategoryRequest struct {
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Code *string `json:"code,omitempty"`
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
Type *string `json:"type,omitempty" validate:"omitempty,oneof=raw_material expense"`
SortOrder *int `json:"sort_order,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
}
type ListPurchaseCategoriesRequest struct {
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Type string `json:"type,omitempty" validate:"omitempty,oneof=raw_material expense"`
Search string `json:"search,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
Page int `json:"page" validate:"required,min=1"`
Limit int `json:"limit" validate:"required,min=1,max=100"`
}
type PurchaseCategoryResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
PresetID *uuid.UUID `json:"preset_id"`
ParentID *uuid.UUID `json:"parent_id"`
Code string `json:"code"`
Name string `json:"name"`
Type string `json:"type"`
SortOrder int `json:"sort_order"`
IsSystem bool `json:"is_system"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListPurchaseCategoriesResponse struct {
PurchaseCategories []PurchaseCategoryResponse `json:"purchase_categories"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}
+10 -49
View File
@@ -7,28 +7,22 @@ import (
)
type CreatePurchaseOrderRequest struct {
VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"`
VendorID uuid.UUID `json:"vendor_id" validate:"required"`
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
DueDate string `json:"due_date" validate:"required"` // 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"`
// CashAdvanceID marks the purchase as paid out of cash advanced to the team. Sending
// it without a team charges the purchase to the cash advance's team.
CashAdvanceID *uuid.UUID `json:"cash_advance_id,omitempty" validate:"omitempty"`
Items []CreatePurchaseOrderItemRequest `json:"items" validate:"required,min=1,dive"`
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
}
type CreatePurchaseOrderItemRequest struct {
IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id" validate:"required"`
IngredientID uuid.UUID `json:"ingredient_id" validate:"required"`
Description *string `json:"description,omitempty" validate:"omitempty"`
Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"`
UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"`
Quantity float64 `json:"quantity" validate:"required,gt=0"`
UnitID uuid.UUID `json:"unit_id" validate:"required"`
Amount float64 `json:"amount" validate:"required,gte=0"`
}
@@ -40,11 +34,6 @@ type UpdatePurchaseOrderRequest struct {
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"`
// An all-zero uuid unlinks the cash advance; omitting the field leaves it untouched.
CashAdvanceID *uuid.UUID `json:"cash_advance_id,omitempty" validate:"omitempty"`
Items []UpdatePurchaseOrderItemRequest `json:"items,omitempty" validate:"omitempty,dive"`
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
}
@@ -52,7 +41,6 @@ type UpdatePurchaseOrderRequest struct {
type UpdatePurchaseOrderItemRequest struct {
ID *uuid.UUID `json:"id,omitempty"` // For existing items
IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"`
PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty" validate:"omitempty"`
Description *string `json:"description,omitempty" validate:"omitempty"`
Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"`
UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"`
@@ -62,52 +50,32 @@ type UpdatePurchaseOrderItemRequest struct {
type PurchaseOrderResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
VendorID *uuid.UUID `json:"vendor_id"`
VendorID uuid.UUID `json:"vendor_id"`
PONumber string `json:"po_number"`
TransactionDate time.Time `json:"transaction_date"`
DueDate *time.Time `json:"due_date"`
DueDate time.Time `json:"due_date"`
Reference *string `json:"reference"`
Status string `json:"status"`
Message *string `json:"message"`
TotalAmount float64 `json:"total_amount"`
TeamScope *string `json:"team_scope"`
TeamCategoryID *uuid.UUID `json:"team_category_id"`
CashAdvanceID *uuid.UUID `json:"cash_advance_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Team *PurchaseTeamResponse `json:"team,omitempty"`
Vendor *VendorResponse `json:"vendor,omitempty"`
Items []PurchaseOrderItemResponse `json:"items,omitempty"`
Attachments []PurchaseOrderAttachmentResponse `json:"attachments,omitempty"`
}
// PurchaseTeamResponse is one entry of the team picker. Teams come from the parent
// product categories; Pusat is the extra entry that has no category behind it, so
// its CategoryID is null.
type PurchaseTeamResponse struct {
Scope string `json:"scope"`
CategoryID *uuid.UUID `json:"category_id"`
Name string `json:"name"`
}
type ListPurchaseTeamsResponse struct {
Teams []PurchaseTeamResponse `json:"teams"`
}
type PurchaseOrderItemResponse struct {
ID uuid.UUID `json:"id"`
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
IngredientID *uuid.UUID `json:"ingredient_id"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
IngredientID uuid.UUID `json:"ingredient_id"`
Description *string `json:"description"`
Quantity *float64 `json:"quantity"`
UnitID *uuid.UUID `json:"unit_id"`
Quantity float64 `json:"quantity"`
UnitID uuid.UUID `json:"unit_id"`
Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Ingredient *IngredientResponse `json:"ingredient,omitempty"`
PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
Unit *UnitResponse `json:"unit,omitempty"`
}
@@ -125,13 +93,6 @@ type ListPurchaseOrdersRequest struct {
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"`
}
+34 -4
View File
@@ -1,6 +1,8 @@
package contract
import (
"time"
"github.com/google/uuid"
)
@@ -49,8 +51,6 @@ type SelfOrderMenuVariant struct {
type SelfOrderCreateOrderRequest struct {
SessionID string `json:"session_id" validate:"required"`
CustomerName string `json:"customer_name" validate:"required"`
OrderType string `json:"order_type" validate:"required,oneof=dine_in takeaway delivery"`
OrderItems []SelfOrderCreateOrderItem `json:"order_items" validate:"required,min=1,dive"`
}
@@ -62,7 +62,7 @@ type SelfOrderCreateOrderItem struct {
}
type SelfOrderListCategoriesRequest struct {
OrganizationID string `form:"organization_id" validate:"required"`
OrganizationID string `form:"organisasi_id" validate:"required"`
OutletID string `form:"outlet_id" validate:"required"`
}
@@ -78,5 +78,35 @@ type SelfOrderListCategoriesResponse struct {
}
type SelfOrderListOrdersResponse struct {
Orders []OrderResponse `json:"orders"`
Orders []SelfOrderOrderItem `json:"orders"`
}
type SelfOrderOrderItem struct {
ID uuid.UUID `json:"id"`
OrderNumber string `json:"order_number"`
TableNumber *string `json:"table_number,omitempty"`
OrderType string `json:"order_type"`
Status string `json:"status"`
Subtotal float64 `json:"subtotal"`
TaxAmount float64 `json:"tax_amount"`
DiscountAmount float64 `json:"discount_amount"`
TotalAmount float64 `json:"total_amount"`
RemainingAmount float64 `json:"remaining_amount"`
PaymentStatus string `json:"payment_status"`
IsVoid bool `json:"is_void"`
IsRefund bool `json:"is_refund"`
Items []SelfOrderOrderLineItem `json:"items,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type SelfOrderOrderLineItem struct {
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
ProductVariantNam *string `json:"product_variant_name,omitempty"`
Quantity int `json:"quantity"`
UnitPrice float64 `json:"unit_price"`
TotalPrice float64 `json:"total_price"`
Notes *string `json:"notes,omitempty"`
Status string `json:"status"`
}
+3 -9
View File
@@ -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 owner purchasing"`
Role string `json:"role" validate:"required,oneof=admin manager cashier waiter"`
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 owner purchasing"`
Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
Permissions *map[string]interface{} `json:"permissions,omitempty"`
@@ -37,13 +37,7 @@ type UpdateUserOutletRequest struct {
type LoginRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"`
DeviceID string `json:"device_id,omitempty"`
DeviceName string `json:"device_name,omitempty"`
DeviceType string `json:"device_type,omitempty"`
Platform string `json:"platform,omitempty"`
FCMToken string `json:"fcm_token,omitempty"`
AppVersion string `json:"app_version,omitempty"`
OsVersion string `json:"os_version,omitempty"`
FcmToken *string `json:"fcm_token,omitempty"`
}
type LoginResponse struct {
-59
View File
@@ -1,59 +0,0 @@
package contract
import (
"time"
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
)
type RegisterUserDeviceRequest struct {
DeviceID string `json:"device_id" validate:"required,min=1,max=255"`
DeviceName string `json:"device_name,omitempty" validate:"omitempty,max=255"`
DeviceType entities.DeviceType `json:"device_type,omitempty" validate:"omitempty,oneof=mobile tablet desktop"`
Platform entities.DevicePlatform `json:"platform,omitempty" validate:"omitempty,oneof=android ios web"`
FCMToken string `json:"fcm_token,omitempty" validate:"omitempty,max=512"`
AppVersion string `json:"app_version,omitempty" validate:"omitempty,max=50"`
OsVersion string `json:"os_version,omitempty" validate:"omitempty,max=50"`
}
type UpdateUserDeviceRequest struct {
DeviceName string `json:"device_name,omitempty" validate:"omitempty,max=255"`
DeviceType entities.DeviceType `json:"device_type,omitempty" validate:"omitempty,oneof=mobile tablet desktop"`
Platform entities.DevicePlatform `json:"platform,omitempty" validate:"omitempty,oneof=android ios web"`
FCMToken string `json:"fcm_token,omitempty" validate:"omitempty,max=512"`
AppVersion string `json:"app_version,omitempty" validate:"omitempty,max=50"`
OsVersion string `json:"os_version,omitempty" validate:"omitempty,max=50"`
}
type UserDeviceResponse struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
DeviceType entities.DeviceType `json:"device_type"`
Platform entities.DevicePlatform `json:"platform"`
FCMToken string `json:"fcm_token"`
AppVersion string `json:"app_version"`
OsVersion string `json:"os_version"`
IPAddress string `json:"ip_address"`
LastActiveAt *time.Time `json:"last_active_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListUserDevicesRequest struct {
Page int `json:"page" validate:"min=1"`
Limit int `json:"limit" validate:"min=1,max=100"`
UserID string `json:"user_id,omitempty"`
Platform string `json:"platform,omitempty"`
}
type ListUserDevicesResponse struct {
Devices []UserDeviceResponse `json:"devices"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}
+40 -221
View File
@@ -27,89 +27,10 @@ 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"`
Summary PurchasingSummary `json:"summary"`
Data []PurchasingAnalyticsData `json:"data"`
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
VendorData []PurchasingVendorData `json:"vendor_data"`
TeamData []PurchasingTeamData `json:"team_data"`
}
// PurchasingTeamData is one team's share of the purchases: a parent category,
// Pusat, or the purchases charged to no team at all. Scope and CategoryID are
// what the team filter takes back, so a row can be clicked straight through.
type PurchasingTeamData struct {
Scope string `json:"scope"`
CategoryID *uuid.UUID `json:"category_id"`
Name string `json:"name"`
TotalPurchases float64 `json:"total_purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
Quantity float64 `json:"quantity"`
Percentage float64 `json:"percentage"`
}
type PurchasingSummary struct {
TotalPurchases float64 `json:"total_purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
TotalPurchaseOrders int64 `json:"total_purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
TotalQuantity float64 `json:"total_quantity"`
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"`
TotalVendors int64 `json:"total_vendors"`
TotalTeams int64 `json:"total_teams"`
}
type PurchasingAnalyticsData struct {
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
}
type PurchasingIngredientData struct {
IngredientID uuid.UUID `json:"ingredient_id"`
IngredientName string `json:"ingredient_name"`
Quantity float64 `json:"quantity"`
TotalCost float64 `json:"total_cost"`
AverageUnitCost float64 `json:"average_unit_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
}
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"`
}
type ProductAnalytics struct {
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
ProductSku string `json:"product_sku"`
ProductPrice float64 `json:"product_price"`
CategoryID uuid.UUID `json:"category_id"`
CategoryName string `json:"category_name"`
CategoryOrder int `json:"category_order"`
@@ -137,39 +58,6 @@ 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"`
@@ -178,125 +66,56 @@ type DashboardOverview struct {
TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_orders"`
TotalItemSold int64 `json:"total_item_sold"`
TotalLowStock int64 `json:"total_low_stock"`
TotalProductActive int64 `json:"total_product_active"`
}
// ProfitLossAnalytics represents profit and loss analytics data
type ProfitLossAnalytics struct {
Summary ProfitLossSummary
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
Summary ProfitLossSummary `json:"summary"`
Data []ProfitLossData `json:"data"`
ProductData []ProductProfitData `json:"product_data"`
}
// ProfitLossSummary represents profit and loss summary data
type ProfitLossSummary struct {
TotalRevenue float64
TotalCost float64
GrossProfit float64
GrossProfitMargin float64
TotalTax float64
TotalDiscount float64
NetProfit float64
NetProfitMargin float64
TotalOrders int64
AverageProfit float64
ProfitabilityRatio float64
TotalRevenue float64 `json:"total_revenue"`
TotalCost float64 `json:"total_cost"`
GrossProfit float64 `json:"gross_profit"`
GrossProfitMargin float64 `json:"gross_profit_margin"`
TotalTax float64 `json:"total_tax"`
TotalDiscount float64 `json:"total_discount"`
NetProfit float64 `json:"net_profit"`
NetProfitMargin float64 `json:"net_profit_margin"`
TotalOrders int64 `json:"total_orders"`
AverageProfit float64 `json:"average_profit"`
ProfitabilityRatio float64 `json:"profitability_ratio"`
}
// ProfitLossData represents profit and loss data by time period
type ProfitLossData struct {
Date time.Time
Revenue float64
Cost float64
GrossProfit float64
GrossProfitMargin float64
Tax float64
Discount float64
NetProfit float64
NetProfitMargin float64
Orders int64
Date time.Time `json:"date"`
Revenue float64 `json:"revenue"`
Cost float64 `json:"cost"`
GrossProfit float64 `json:"gross_profit"`
GrossProfitMargin float64 `json:"gross_profit_margin"`
Tax float64 `json:"tax"`
Discount float64 `json:"discount"`
NetProfit float64 `json:"net_profit"`
NetProfitMargin float64 `json:"net_profit_margin"`
Orders int64 `json:"orders"`
}
// ProductProfitData represents profit data for individual products
type ProductProfitData struct {
ProductID uuid.UUID
ProductName string
CategoryID uuid.UUID
CategoryName string
QuantitySold int64
Revenue float64
Cost float64
GrossProfit float64
GrossProfitMargin float64
AveragePrice float64
AverageCost float64
ProfitPerUnit float64
}
type ExpenseCategoryTotal struct {
CategoryName string
Amount float64
}
type OperationalExpenseItem struct {
Item string
Amount float64
}
type ExclusiveSummaryAnalytics struct {
SalesTotal float64
SalesCount int64
HPPBreakdown []ExclusiveSummaryCategoryTotal
OperationalExpenseBreakdown []ExclusiveSummaryCategoryTotal
DailySummary []ExclusiveSummaryDailySummary
DailyTransactions []ExclusiveSummaryDailyTransaction
}
type ExclusiveSummaryCategoryTotal struct {
CategoryCode string
CategoryName string
Amount float64
}
type ExclusiveSummaryDailySummary struct {
Date time.Time
TransactionCount int64
TotalCost float64
}
type ExclusiveSummaryDailyTransaction struct {
Date time.Time
CategoryCode string
CategoryName string
Description string
Amount float64
Source string
}
type ExclusiveSummaryBankBalance struct {
Bank string
OpeningBalance *float64
IncomingMutation *float64
OutgoingMutation *float64
ClosingBalance *float64
Notes *string
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
CategoryID uuid.UUID `json:"category_id"`
CategoryName string `json:"category_name"`
QuantitySold int64 `json:"quantity_sold"`
Revenue float64 `json:"revenue"`
Cost float64 `json:"cost"`
GrossProfit float64 `json:"gross_profit"`
GrossProfitMargin float64 `json:"gross_profit_margin"`
AveragePrice float64 `json:"average_price"`
AverageCost float64 `json:"average_cost"`
ProfitPerUnit float64 `json:"profit_per_unit"`
}
-63
View File
@@ -1,63 +0,0 @@
package entities
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// CashAdvance is money handed to a team up front so it can go shopping — kasbon in
// the Indonesian UI. While the cash is out it is still the outlet's, not a cost, so
// nothing about what the team bought lives here: that is read back from the purchase
// orders and expenses charged to the advance.
type CashAdvance struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"`
OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id"`
CodeNumber string `gorm:"not null;size:50" json:"code_number"`
// An advance is always handed to a team, so unlike a purchase order it has no
// "not chosen yet" state. TeamCategoryID is set only when the scope is category.
TeamScope string `gorm:"not null;size:20;index" json:"team_scope"`
TeamCategoryID *uuid.UUID `gorm:"type:uuid;index" json:"team_category_id"`
Amount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"amount"`
ReturnedAmount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"returned_amount"`
IssuedDate time.Time `gorm:"type:date;not null" json:"issued_date"`
DueDate *time.Time `gorm:"type:date" json:"due_date"`
Status string `gorm:"not null;size:20;default:'draft'" json:"status"`
Description *string `gorm:"type:text" json:"description"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
// SettledAmount is filled in by the read queries from the spending charged to
// this advance. It has no column of its own, so it can never drift out of step
// with the purchases behind it; the arrow tag keeps writes from touching it.
SettledAmount float64 `gorm:"->;-:migration" json:"settled_amount"`
Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
TeamCategory *Category `gorm:"foreignKey:TeamCategoryID" json:"team_category,omitempty"`
}
func (k *CashAdvance) BeforeCreate(tx *gorm.DB) error {
if k.ID == uuid.Nil {
k.ID = uuid.New()
}
return nil
}
func (CashAdvance) TableName() string {
return "cash_advances"
}
// CashAdvanceSettlement is one piece of spending charged to an advance. It is read
// out of purchase_orders and expenses, so it has no table of its own.
type CashAdvanceSettlement struct {
Type string `json:"type"`
ID uuid.UUID `json:"id"`
Number string `json:"number"`
Date time.Time `json:"date"`
Amount float64 `json:"amount"`
Status string `json:"status"`
}
-3
View File
@@ -33,9 +33,6 @@ func (m *Metadata) Scan(value interface{}) error {
type Category struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
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"`
-8
View File
@@ -36,14 +36,6 @@ func GetAllEntities() []interface{} {
&CampaignRule{},
&OtpSession{},
// Analytics entities are not database tables, they are query results
&UserDevice{},
// Notification entities
&Notification{},
&NotificationReceiver{},
&NotificationDelivery{},
&ProductOutletPrice{},
&Expense{},
&CashAdvance{},
}
}
-93
View File
@@ -1,93 +0,0 @@
package entities
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Expense struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"`
OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id"`
Receiver string `gorm:"not null;size:255" json:"receiver"`
TransactionDate time.Time `gorm:"type:date;not null" json:"transaction_date"`
CodeNumber string `gorm:"not null;size:50" json:"code_number"`
Status string `gorm:"not null;size:20;default:'draft'" json:"status"`
Description *string `gorm:"type:text" json:"description"`
Tax float64 `gorm:"type:decimal(15,2);not null;default:0" json:"tax"`
Total float64 `gorm:"type:decimal(15,2);not null;default:0" json:"total"`
Reserved1 *string `gorm:"type:text" json:"reserved1"`
// CashAdvanceID is set when the expense was paid out of cash advanced to a team.
CashAdvanceID *uuid.UUID `gorm:"type:uuid;index" json:"cash_advance_id"`
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"`
CashAdvance *CashAdvance `gorm:"foreignKey:CashAdvanceID" json:"cash_advance,omitempty"`
Items []ExpenseItem `gorm:"foreignKey:ExpenseID" json:"items,omitempty"`
}
type ExpenseAnalytics struct {
Summary ExpenseAnalyticsSummary
Data []ExpenseAnalyticsData
CategoryData []ExpenseAnalyticsCategoryData
ChartOfAccountData []ExpenseAnalyticsChartOfAccountData
ItemData []ExpenseAnalyticsItemData
}
type ExpenseAnalyticsSummary struct {
TotalExpenses float64
TotalExpenseCount int64
TotalTax float64
AverageExpenseValue float64
TotalCategories int64
TotalItems int64
}
type ExpenseAnalyticsData struct {
Date time.Time
Expenses float64
ExpenseCount int64
Tax float64
Items int64
Categories int64
}
type ExpenseAnalyticsCategoryData struct {
PurchaseCategoryID uuid.UUID
PurchaseCategoryName string
PurchaseCategoryType string
TotalAmount float64
ExpenseCount int64
ItemCount int64
}
type ExpenseAnalyticsChartOfAccountData struct {
ChartOfAccountID uuid.UUID
ChartOfAccountName string
TotalAmount float64
ExpenseCount int64
ItemCount int64
}
type ExpenseAnalyticsItemData struct {
Item string
TotalAmount float64
ExpenseCount int64
ItemCount int64
}
func (e *Expense) BeforeCreate(tx *gorm.DB) error {
if e.ID == uuid.Nil {
e.ID = uuid.New()
}
return nil
}
func (Expense) TableName() string {
return "expenses"
}
-36
View File
@@ -1,36 +0,0 @@
package entities
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type ExpenseItem struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
ExpenseID uuid.UUID `gorm:"type:uuid;not null;index" json:"expense_id"`
ChartOfAccountID uuid.UUID `gorm:"type:uuid;not null;index" json:"chart_of_account_id"`
PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id"`
Item string `gorm:"not null;size:255" json:"item"`
Description *string `gorm:"type:text" json:"description"`
Amount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"amount"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Expense *Expense `gorm:"foreignKey:ExpenseID" json:"expense,omitempty"`
ChartOfAccount *ChartOfAccount `gorm:"foreignKey:ChartOfAccountID" json:"chart_of_account,omitempty"`
PurchaseCategory *PurchaseCategory `gorm:"foreignKey:PurchaseCategoryID" json:"purchase_category,omitempty"`
}
func (e *ExpenseItem) BeforeCreate(tx *gorm.DB) error {
if e.ID == uuid.Nil {
e.ID = uuid.New()
}
return nil
}
func (ExpenseItem) TableName() string {
return "expense_items"
}
+1 -1
View File
@@ -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;index" json:"unit_id"`
UnitID uuid.UUID `gorm:"type:uuid;not null;index" json:"unit_id"`
Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost"`
Stock float64 `gorm:"type:decimal(10,2);default:0.00" json:"stock"`
IsSemiFinished bool `gorm:"default:false" json:"is_semi_finished"`
@@ -39,3 +39,4 @@ func (iuc *IngredientUnitConverter) BeforeCreate() error {
}
return nil
}
-2
View File
@@ -49,7 +49,6 @@ type InventoryMovement struct {
TotalCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"total_cost"`
ReferenceType *InventoryMovementReferenceType `gorm:"size:50" json:"reference_type"`
ReferenceID *uuid.UUID `gorm:"type:uuid;index" json:"reference_id"`
PurchaseOrderItemID *uuid.UUID `gorm:"type:uuid;index" json:"purchase_order_item_id"`
OrderID *uuid.UUID `gorm:"type:uuid;index" json:"order_id"`
PaymentID *uuid.UUID `gorm:"type:uuid;index" json:"payment_id"`
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id" validate:"required"`
@@ -62,7 +61,6 @@ type InventoryMovement struct {
Outlet Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
Product *Product `gorm:"foreignKey:ItemID" json:"product,omitempty"`
Ingredient *Ingredient `gorm:"foreignKey:ItemID" json:"ingredient,omitempty"`
PurchaseOrderItem *PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderItemID" json:"purchase_order_item,omitempty"`
Order *Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
Payment *Payment `gorm:"foreignKey:PaymentID" json:"payment,omitempty"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
-150
View File
@@ -1,150 +0,0 @@
package entities
import (
"database/sql/driver"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type NotificationPriority string
type NotificationDeliveryStatus string
type NotificationChannel string
type NotificationProvider string
const (
NotificationPriorityLow NotificationPriority = "low"
NotificationPriorityNormal NotificationPriority = "normal"
NotificationPriorityHigh NotificationPriority = "high"
NotificationDeliveryStatusPending NotificationDeliveryStatus = "pending"
NotificationDeliveryStatusSent NotificationDeliveryStatus = "sent"
NotificationDeliveryStatusDelivered NotificationDeliveryStatus = "delivered"
NotificationDeliveryStatusFailed NotificationDeliveryStatus = "failed"
NotificationChannelPush NotificationChannel = "push"
NotificationChannelWebsocket NotificationChannel = "websocket"
NotificationChannelEmail NotificationChannel = "email"
NotificationProviderFirebase NotificationProvider = "firebase"
)
// NotificationData is a JSON-serializable map for extra notification payload.
type NotificationData map[string]interface{}
func (d NotificationData) Value() (driver.Value, error) {
if d == nil {
return nil, nil
}
return json.Marshal(d)
}
func (d *NotificationData) Scan(value interface{}) error {
if value == nil {
*d = nil
return nil
}
bytes, ok := value.([]byte)
if !ok {
return errors.New("type assertion to []byte failed")
}
return json.Unmarshal(bytes, d)
}
// Notification is the master notification record.
type Notification struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
Title string `gorm:"not null;size:255" json:"title"`
Body string `gorm:"type:text" json:"body"`
Type string `gorm:"size:100" json:"type"`
Category string `gorm:"size:100" json:"category"`
Priority NotificationPriority `gorm:"size:50;default:'normal'" json:"priority"`
ImageURL string `gorm:"size:512" json:"image_url"`
ActionURL string `gorm:"size:512" json:"action_url"`
NotifiableType string `gorm:"size:100" json:"notifiable_type"`
NotifiableID *uuid.UUID `gorm:"type:uuid" json:"notifiable_id"`
Data NotificationData `gorm:"type:jsonb" json:"data"`
ScheduledAt *time.Time `gorm:"type:timestamptz" json:"scheduled_at"`
SentAt *time.Time `gorm:"type:timestamptz" json:"sent_at"`
ExpiredAt *time.Time `gorm:"type:timestamptz" json:"expired_at"`
CreatedBy *uuid.UUID `gorm:"type:uuid" json:"created_by"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Creator *User `gorm:"foreignKey:CreatedBy" json:"creator,omitempty"`
Receivers []*NotificationReceiver `gorm:"foreignKey:NotificationID" json:"receivers,omitempty"`
}
func (n *Notification) BeforeCreate(tx *gorm.DB) error {
if n.ID == uuid.Nil {
n.ID = uuid.New()
}
return nil
}
func (Notification) TableName() string {
return "notifications"
}
// NotificationReceiver links a notification to a specific user.
type NotificationReceiver struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
NotificationID uuid.UUID `gorm:"type:uuid;not null;index" json:"notification_id"`
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"`
IsRead bool `gorm:"default:false" json:"is_read"`
ReadAt *time.Time `gorm:"type:timestamptz" json:"read_at"`
IsDeleted bool `gorm:"default:false" json:"is_deleted"`
DeletedAt *time.Time `gorm:"type:timestamptz" json:"deleted_at"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Notification *Notification `gorm:"foreignKey:NotificationID" json:"notification,omitempty"`
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Deliveries []*NotificationDelivery `gorm:"foreignKey:NotificationReceiverID" json:"deliveries,omitempty"`
}
func (n *NotificationReceiver) BeforeCreate(tx *gorm.DB) error {
if n.ID == uuid.Nil {
n.ID = uuid.New()
}
return nil
}
func (NotificationReceiver) TableName() string {
return "notification_receivers"
}
// NotificationDelivery tracks per-device delivery attempts.
type NotificationDelivery struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
NotificationReceiverID uuid.UUID `gorm:"type:uuid;not null;index" json:"notification_receiver_id"`
UserDeviceID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_device_id"`
Channel NotificationChannel `gorm:"size:50;default:'push'" json:"channel"`
DeliveryStatus NotificationDeliveryStatus `gorm:"size:50;default:'pending'" json:"delivery_status"`
Provider NotificationProvider `gorm:"size:50" json:"provider"`
ProviderMessageID string `gorm:"size:255" json:"provider_message_id"`
SentAt *time.Time `gorm:"type:timestamptz" json:"sent_at"`
DeliveredAt *time.Time `gorm:"type:timestamptz" json:"delivered_at"`
FailedAt *time.Time `gorm:"type:timestamptz" json:"failed_at"`
FailureReason string `gorm:"type:text" json:"failure_reason"`
RetryCount int `gorm:"default:0" json:"retry_count"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
NotificationReceiver *NotificationReceiver `gorm:"foreignKey:NotificationReceiverID" json:"notification_receiver,omitempty"`
UserDevice *UserDevice `gorm:"foreignKey:UserDeviceID" json:"user_device,omitempty"`
}
func (n *NotificationDelivery) BeforeCreate(tx *gorm.DB) error {
if n.ID == uuid.Nil {
n.ID = uuid.New()
}
return nil
}
func (NotificationDelivery) TableName() string {
return "notification_deliveries"
}
-1
View File
@@ -33,7 +33,6 @@ type Product struct {
ProductRecipes []ProductRecipe `gorm:"foreignKey:ProductID" json:"product_recipes,omitempty"`
Inventory []Inventory `gorm:"foreignKey:ProductID" json:"inventory,omitempty"`
OrderItems []OrderItem `gorm:"foreignKey:ProductID" json:"order_items,omitempty"`
ProductOutletPrices []ProductOutletPrice `gorm:"foreignKey:ProductID" json:"product_outlet_prices,omitempty"`
}
func (p *Product) BeforeCreate(tx *gorm.DB) error {
-32
View File
@@ -1,32 +0,0 @@
package entities
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type ProductOutletPrice struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id"`
OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id"`
Price float64 `gorm:"type:decimal(10,2);not null" json:"price"`
PrintToChecker bool `gorm:"not null;default:true" json:"print_to_checker"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
Outlet Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
}
func (p *ProductOutletPrice) BeforeCreate(tx *gorm.DB) error {
if p.ID == uuid.Nil {
p.ID = uuid.New()
}
return nil
}
func (ProductOutletPrice) TableName() string {
return "product_outlet_prices"
}
-71
View File
@@ -1,71 +0,0 @@
package entities
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type PurchaseCategoryType string
const (
PurchaseCategoryTypeRawMaterial PurchaseCategoryType = "raw_material"
PurchaseCategoryTypeExpense PurchaseCategoryType = "expense"
)
type PurchaseCategoryPreset struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
ParentID *uuid.UUID `gorm:"type:uuid;index" json:"parent_id"`
Code string `gorm:"not null;unique;size:100" json:"code"`
Name string `gorm:"not null;size:255" json:"name"`
Type PurchaseCategoryType `gorm:"not null;size:20" json:"type"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
IsActive bool `gorm:"not null;default:true" json:"is_active"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Parent *PurchaseCategoryPreset `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
}
func (p *PurchaseCategoryPreset) BeforeCreate(tx *gorm.DB) error {
if p.ID == uuid.Nil {
p.ID = uuid.New()
}
return nil
}
func (PurchaseCategoryPreset) TableName() string {
return "purchase_category_presets"
}
type PurchaseCategory struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"`
PresetID *uuid.UUID `gorm:"type:uuid;index" json:"preset_id"`
ParentID *uuid.UUID `gorm:"type:uuid;index" json:"parent_id"`
Code string `gorm:"not null;size:100" json:"code"`
Name string `gorm:"not null;size:255" json:"name"`
Type PurchaseCategoryType `gorm:"not null;size:20" json:"type"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
IsSystem bool `gorm:"not null;default:false" json:"is_system"`
IsActive bool `gorm:"not null;default:true" json:"is_active"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
Preset *PurchaseCategoryPreset `gorm:"foreignKey:PresetID" json:"preset,omitempty"`
Parent *PurchaseCategory `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
Children []PurchaseCategory `gorm:"foreignKey:ParentID" json:"children,omitempty"`
}
func (c *PurchaseCategory) BeforeCreate(tx *gorm.DB) error {
if c.ID == uuid.Nil {
c.ID = uuid.New()
}
return nil
}
func (PurchaseCategory) TableName() string {
return "purchase_categories"
}
+5 -18
View File
@@ -11,30 +11,19 @@ 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"`
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id" validate:"omitempty"`
VendorID *uuid.UUID `gorm:"type:uuid" json:"vendor_id" validate:"omitempty"`
VendorID uuid.UUID `gorm:"type:uuid;not null" json:"vendor_id" validate:"required"`
PONumber string `gorm:"not null;size:50" json:"po_number" validate:"required,min=1,max=50"`
TransactionDate time.Time `gorm:"type:date;not null" json:"transaction_date" validate:"required"`
DueDate *time.Time `gorm:"type:date" json:"due_date" validate:"omitempty"`
DueDate time.Time `gorm:"type:date;not null" json:"due_date" validate:"required"`
Reference *string `gorm:"size:100" json:"reference" validate:"omitempty,max=100"`
Status string `gorm:"not null;size:20;default:'draft'" json:"status" validate:"required,oneof=draft sent approved received cancelled"`
Message *string `gorm:"type:text" json:"message" validate:"omitempty"`
TotalAmount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"total_amount"`
// 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"`
// CashAdvanceID is set when the purchase was paid out of cash advanced to the team.
// It is what accounts for that advance, so the cash advance holds no copy of the items.
CashAdvanceID *uuid.UUID `gorm:"type:uuid;index" json:"cash_advance_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"`
CashAdvance *CashAdvance `gorm:"foreignKey:CashAdvanceID" json:"cash_advance,omitempty"`
Items []PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderID" json:"items,omitempty"`
Attachments []PurchaseOrderAttachment `gorm:"foreignKey:PurchaseOrderID" json:"attachments,omitempty"`
}
@@ -54,18 +43,16 @@ func (PurchaseOrder) TableName() string {
type PurchaseOrderItem struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"`
IngredientID *uuid.UUID `gorm:"type:uuid" json:"ingredient_id" validate:"omitempty"`
PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id" validate:"required"`
IngredientID uuid.UUID `gorm:"type:uuid;not null" json:"ingredient_id" validate:"required"`
Description *string `gorm:"type:text" json:"description" validate:"omitempty"`
Quantity *float64 `gorm:"type:decimal(10,3)" json:"quantity" validate:"omitempty,gt=0"`
UnitID *uuid.UUID `gorm:"type:uuid" json:"unit_id" validate:"omitempty"`
Quantity float64 `gorm:"type:decimal(10,3);not null" json:"quantity" validate:"required,gt=0"`
UnitID uuid.UUID `gorm:"type:uuid;not null" json:"unit_id" validate:"required"`
Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
PurchaseOrder *PurchaseOrder `gorm:"foreignKey:PurchaseOrderID" json:"purchase_order,omitempty"`
Ingredient *Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"`
PurchaseCategory *PurchaseCategory `gorm:"foreignKey:PurchaseCategoryID" json:"purchase_category,omitempty"`
Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"`
}
+2 -3
View File
@@ -17,8 +17,6 @@ const (
RoleManager UserRole = "manager"
RoleCashier UserRole = "cashier"
RoleWaiter UserRole = "waiter"
RoleOwner UserRole = "owner"
RolePurchasing UserRole = "purchasing"
)
type Permissions map[string]interface{}
@@ -48,9 +46,10 @@ 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 owner purchasing"`
Role UserRole `gorm:"not null;size:50" json:"role" validate:"required,oneof=admin manager cashier waiter"`
Permissions Permissions `gorm:"type:jsonb;default:'{}'" json:"permissions"`
IsActive bool `gorm:"default:true" json:"is_active"`
FcmToken *string `gorm:"size:512" json:"fcm_token,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"`
-50
View File
@@ -1,50 +0,0 @@
package entities
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type DeviceType string
type DevicePlatform string
const (
DeviceTypeMobile DeviceType = "mobile"
DeviceTypeTablet DeviceType = "tablet"
DeviceTypeDesktop DeviceType = "desktop"
DevicePlatformAndroid DevicePlatform = "android"
DevicePlatformIOS DevicePlatform = "ios"
DevicePlatformWeb DevicePlatform = "web"
)
type UserDevice struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"`
DeviceID string `gorm:"not null;size:255;index" json:"device_id"`
DeviceName string `gorm:"size:255" json:"device_name"`
DeviceType DeviceType `gorm:"size:50" json:"device_type"`
Platform DevicePlatform `gorm:"size:50" json:"platform"`
FCMToken string `gorm:"size:512" json:"fcm_token"`
AppVersion string `gorm:"size:50" json:"app_version"`
OsVersion string `gorm:"size:50" json:"os_version"`
IPAddress string `gorm:"size:45" json:"ip_address"`
LastActiveAt *time.Time `gorm:"type:timestamptz" json:"last_active_at"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}
func (u *UserDevice) BeforeCreate(tx *gorm.DB) error {
if u.ID == uuid.Nil {
u.ID = uuid.New()
}
return nil
}
func (UserDevice) TableName() string {
return "user_devices"
}
+5 -175
View File
@@ -8,7 +8,6 @@ import (
"apskel-pos-be/internal/util"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type AnalyticsHandler struct {
@@ -26,17 +25,6 @@ func NewAnalyticsHandler(
}
}
func (h *AnalyticsHandler) resolveOutletID(c *gin.Context, contextOutletID uuid.UUID) *string {
if outletIDStr := c.Query("outlet_id"); outletIDStr != "" {
return &outletIDStr
}
if contextOutletID != uuid.Nil {
s := contextOutletID.String()
return &s
}
return nil
}
func (h *AnalyticsHandler) GetPaymentMethodAnalytics(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
@@ -48,7 +36,7 @@ func (h *AnalyticsHandler) GetPaymentMethodAnalytics(c *gin.Context) {
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
req.OutletID = &contextInfo.OutletID
modelReq := transformer.PaymentMethodAnalyticsContractToModel(&req)
response, err := h.analyticsService.GetPaymentMethodAnalytics(ctx, modelReq)
@@ -72,7 +60,7 @@ func (h *AnalyticsHandler) GetSalesAnalytics(c *gin.Context) {
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
req.OutletID = &contextInfo.OutletID
modelReq := transformer.SalesAnalyticsContractToModel(&req)
response, err := h.analyticsService.GetSalesAnalytics(ctx, modelReq)
@@ -85,30 +73,6 @@ func (h *AnalyticsHandler) GetSalesAnalytics(c *gin.Context) {
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetSalesAnalytics")
}
func (h *AnalyticsHandler) GetPurchasingAnalytics(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.PurchasingAnalyticsRequest
if err := c.ShouldBindQuery(&req); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetPurchasingAnalytics", err.Error())}), "AnalyticsHandler::GetPurchasingAnalytics")
return
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
modelReq := transformer.PurchasingAnalyticsContractToModel(&req)
response, err := h.analyticsService.GetPurchasingAnalytics(ctx, modelReq)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetPurchasingAnalytics", err.Error())}), "AnalyticsHandler::GetPurchasingAnalytics")
return
}
contractResp := transformer.PurchasingAnalyticsModelToContract(response)
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetPurchasingAnalytics")
}
func (h *AnalyticsHandler) GetProductAnalytics(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
@@ -120,7 +84,7 @@ func (h *AnalyticsHandler) GetProductAnalytics(c *gin.Context) {
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
req.OutletID = &contextInfo.OutletID
modelReq := transformer.ProductAnalyticsContractToModel(&req)
response, err := h.analyticsService.GetProductAnalytics(ctx, modelReq)
@@ -144,7 +108,7 @@ func (h *AnalyticsHandler) GetProductAnalyticsPerCategory(c *gin.Context) {
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
req.OutletID = &contextInfo.OutletID
modelReq := transformer.ProductAnalyticsPerCategoryContractToModel(&req)
response, err := h.analyticsService.GetProductAnalyticsPerCategory(ctx, modelReq)
@@ -157,55 +121,6 @@ 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)
@@ -217,7 +132,7 @@ func (h *AnalyticsHandler) GetDashboardAnalytics(c *gin.Context) {
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
req.OutletID = &contextInfo.OutletID
modelReq := transformer.DashboardAnalyticsContractToModel(&req)
response, err := h.analyticsService.GetDashboardAnalytics(ctx, modelReq)
@@ -241,7 +156,6 @@ func (h *AnalyticsHandler) GetProfitLossAnalytics(c *gin.Context) {
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
modelReq, err := transformer.ProfitLossAnalyticsContractToModel(&req)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetProfitLossAnalytics", err.Error())}), "AnalyticsHandler::GetProfitLossAnalytics")
@@ -259,87 +173,3 @@ 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")
}
-232
View File
@@ -1,232 +0,0 @@
package handler
import (
"strconv"
"time"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
"apskel-pos-be/internal/validator"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type CashAdvanceHandler struct {
cashAdvanceService service.CashAdvanceService
cashAdvanceValidator validator.CashAdvanceValidator
}
func NewCashAdvanceHandler(cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator) *CashAdvanceHandler {
return &CashAdvanceHandler{
cashAdvanceService: cashAdvanceService,
cashAdvanceValidator: cashAdvanceValidator,
}
}
func (h *CashAdvanceHandler) CreateCashAdvance(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.CreateCashAdvanceRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::CreateCashAdvance -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::CreateCashAdvance")
return
}
validationError, validationErrorCode := h.cashAdvanceValidator.ValidateCreateCashAdvanceRequest(&req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::CreateCashAdvance")
return
}
response := h.cashAdvanceService.CreateCashAdvance(ctx, contextInfo, &req)
if response.HasErrors() {
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::CreateCashAdvance -> Failed to create cash advance from service")
}
util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::CreateCashAdvance")
}
func (h *CashAdvanceHandler) UpdateCashAdvance(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
cashAdvanceID, err := uuid.Parse(c.Param("id"))
if err != nil {
logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::UpdateCashAdvance -> Invalid cash advance ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid cash advance ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::UpdateCashAdvance")
return
}
var req contract.UpdateCashAdvanceRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::UpdateCashAdvance -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "Invalid request body")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::UpdateCashAdvance")
return
}
validationError, validationErrorCode := h.cashAdvanceValidator.ValidateUpdateCashAdvanceRequest(&req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::UpdateCashAdvance")
return
}
response := h.cashAdvanceService.UpdateCashAdvance(ctx, contextInfo, cashAdvanceID, &req)
if response.HasErrors() {
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::UpdateCashAdvance -> Failed to update cash advance from service")
}
util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::UpdateCashAdvance")
}
func (h *CashAdvanceHandler) DeleteCashAdvance(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
cashAdvanceID, err := uuid.Parse(c.Param("id"))
if err != nil {
logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::DeleteCashAdvance -> Invalid cash advance ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid cash advance ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::DeleteCashAdvance")
return
}
response := h.cashAdvanceService.DeleteCashAdvance(ctx, contextInfo, cashAdvanceID)
if response.HasErrors() {
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::DeleteCashAdvance -> Failed to delete cash advance from service")
}
util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::DeleteCashAdvance")
}
func (h *CashAdvanceHandler) GetCashAdvance(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
cashAdvanceID, err := uuid.Parse(c.Param("id"))
if err != nil {
logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::GetCashAdvance -> Invalid cash advance ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid cash advance ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::GetCashAdvance")
return
}
response := h.cashAdvanceService.GetCashAdvanceByID(ctx, contextInfo, cashAdvanceID)
if response.HasErrors() {
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::GetCashAdvance -> Failed to get cash advance from service")
}
util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::GetCashAdvance")
}
func (h *CashAdvanceHandler) ListCashAdvances(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
req := &contract.ListCashAdvancesRequest{
Page: 1,
Limit: 10,
}
if pageStr := c.Query("page"); pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil {
req.Page = page
}
}
if limitStr := c.Query("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil {
req.Limit = limit
}
}
req.Search = c.Query("search")
req.Status = c.Query("status")
req.SettlementStatus = c.Query("settlement_status")
req.Team = c.Query("team")
req.TeamScope = c.Query("team_scope")
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
}
}
if endDateStr := c.Query("end_date"); endDateStr != "" {
if endDate, err := time.Parse("2006-01-02", endDateStr); err == nil {
req.EndDate = &endDate
}
}
validationError, validationErrorCode := h.cashAdvanceValidator.ValidateListCashAdvancesRequest(req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::ListCashAdvances")
return
}
response := h.cashAdvanceService.ListCashAdvances(ctx, contextInfo, req)
if response.HasErrors() {
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::ListCashAdvances -> Failed to list cash advances from service")
}
util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::ListCashAdvances")
}
func (h *CashAdvanceHandler) UpdateCashAdvanceStatus(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
cashAdvanceID, err := uuid.Parse(c.Param("id"))
if err != nil {
logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::UpdateCashAdvanceStatus -> Invalid cash advance ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid cash advance ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::UpdateCashAdvanceStatus")
return
}
status := c.Param("status")
if status == "" {
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "Status parameter is required")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::UpdateCashAdvanceStatus")
return
}
response := h.cashAdvanceService.UpdateCashAdvanceStatus(ctx, contextInfo, cashAdvanceID, status)
if response.HasErrors() {
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::UpdateCashAdvanceStatus -> Failed to update cash advance status from service")
}
util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::UpdateCashAdvanceStatus")
}
// ListCashAdvanceTeams serves the team picker for the advance form: the parent
// categories of the caller's outlet, plus Pusat. Same list the purchase form uses.
func (h *CashAdvanceHandler) ListCashAdvanceTeams(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
response := h.cashAdvanceService.ListCashAdvanceTeams(ctx, contextInfo)
if response.HasErrors() {
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::ListCashAdvanceTeams -> Failed to list cash advance teams from service")
}
util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::ListCashAdvanceTeams")
}
-27
View File
@@ -44,11 +44,6 @@ func (h *CategoryHandler) CreateCategory(c *gin.Context) {
return
}
// Inject outlet_id from context if user has one and request doesn't provide it
if req.OutletID == nil && contextInfo.OutletID != uuid.Nil {
req.OutletID = &contextInfo.OutletID
}
validationError, validationErrorCode := h.categoryValidator.ValidateCreateCategoryRequest(&req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
@@ -154,11 +149,6 @@ func (h *CategoryHandler) ListCategories(c *gin.Context) {
OrganizationID: &contextInfo.OrganizationID,
}
// Inject outlet_id from context if user has one
if contextInfo.OutletID != uuid.Nil {
req.OutletID = &contextInfo.OutletID
}
// Parse query parameters
if pageStr := c.Query("page"); pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil {
@@ -186,23 +176,6 @@ func (h *CategoryHandler) ListCategories(c *gin.Context) {
}
}
if outletIDStr := c.Query("outlet_id"); outletIDStr != "" {
if outletID, err := uuid.Parse(outletIDStr); err == nil {
req.OutletID = &outletID
}
}
if parentIDStr := c.Query("parent_id"); parentIDStr != "" {
if parentID, err := uuid.Parse(parentIDStr); err == nil {
req.ParentID = &parentID
}
}
// type=parent -> top level categories only
// type=child -> leaf categories (sub categories + top level ones without children)
if categoryType := c.Query("type"); categoryType != "" {
req.Type = categoryType
}
validationError, validationErrorCode := h.categoryValidator.ValidateListCategoriesRequest(req)
if validationError != nil {
logger.FromContext(ctx).WithError(validationError).Error("CategoryHandler::ListCategories -> request validation failed")
+1 -3
View File
@@ -1,8 +1,6 @@
package handler
import (
"apskel-pos-be/internal/logger"
"fmt"
"net/http"
"time"
)
@@ -49,7 +47,7 @@ func (m *CommonMiddleware) Recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
logger.FromContext(r.Context()).Error("Recovery", fmt.Sprintf("panic recovered: %v", err))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
-229
View File
@@ -1,229 +0,0 @@
package handler
import (
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/util"
"strconv"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/validator"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type ExpenseHandler struct {
expenseService service.ExpenseService
expenseValidator validator.ExpenseValidator
}
func NewExpenseHandler(
expenseService service.ExpenseService,
expenseValidator validator.ExpenseValidator,
) *ExpenseHandler {
return &ExpenseHandler{
expenseService: expenseService,
expenseValidator: expenseValidator,
}
}
func (h *ExpenseHandler) CreateExpense(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.CreateExpenseRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::CreateExpense -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::CreateExpense")
return
}
validationError, validationErrorCode := h.expenseValidator.ValidateCreateExpenseRequest(&req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::CreateExpense")
return
}
expenseResponse := h.expenseService.CreateExpense(ctx, contextInfo, &req)
if expenseResponse.HasErrors() {
errorResp := expenseResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::CreateExpense -> Failed to create expense from service")
}
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::CreateExpense")
}
func (h *ExpenseHandler) UpdateExpense(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
expenseIDStr := c.Param("id")
expenseID, err := uuid.Parse(expenseIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::UpdateExpense -> Invalid expense ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid expense ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::UpdateExpense")
return
}
var req contract.UpdateExpenseRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::UpdateExpense -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "Invalid request body")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::UpdateExpense")
return
}
validationError, validationErrorCode := h.expenseValidator.ValidateUpdateExpenseRequest(&req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::UpdateExpense")
return
}
expenseResponse := h.expenseService.UpdateExpense(ctx, contextInfo, expenseID, &req)
if expenseResponse.HasErrors() {
errorResp := expenseResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::UpdateExpense -> Failed to update expense from service")
}
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::UpdateExpense")
}
func (h *ExpenseHandler) DeleteExpense(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
expenseIDStr := c.Param("id")
expenseID, err := uuid.Parse(expenseIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::DeleteExpense -> Invalid expense ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid expense ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::DeleteExpense")
return
}
expenseResponse := h.expenseService.DeleteExpense(ctx, contextInfo, expenseID)
if expenseResponse.HasErrors() {
errorResp := expenseResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::DeleteExpense -> Failed to delete expense from service")
}
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::DeleteExpense")
}
func (h *ExpenseHandler) GetExpense(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
expenseIDStr := c.Param("id")
expenseID, err := uuid.Parse(expenseIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::GetExpense -> Invalid expense ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid expense ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::GetExpense")
return
}
expenseResponse := h.expenseService.GetExpenseByID(ctx, contextInfo, expenseID)
if expenseResponse.HasErrors() {
errorResp := expenseResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::GetExpense -> Failed to get expense from service")
}
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::GetExpense")
}
func (h *ExpenseHandler) ListExpenses(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
req := &contract.ListExpenseRequest{
Page: 1,
Limit: 10,
}
if pageStr := c.Query("page"); pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil {
req.Page = page
}
}
if limitStr := c.Query("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil {
req.Limit = limit
}
}
if search := c.Query("search"); search != "" {
req.Search = search
}
if status := c.Query("status"); status != "" {
req.Status = status
}
// Prioritize outlet_id from context (e.g. outlet-scoped user),
// fall back to query param if context has no outlet.
if contextInfo.OutletID != uuid.Nil {
req.OutletID = contextInfo.OutletID.String()
} else if outletID := c.Query("outlet_id"); outletID != "" {
req.OutletID = outletID
}
if startDate := c.Query("start_date"); startDate != "" {
req.StartDate = startDate
}
if endDate := c.Query("end_date"); endDate != "" {
req.EndDate = endDate
}
validationError, validationErrorCode := h.expenseValidator.ValidateListExpenseRequest(req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::ListExpenses")
return
}
expenseResponse := h.expenseService.ListExpenses(ctx, contextInfo, req)
if expenseResponse.HasErrors() {
errorResp := expenseResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::ListExpenses -> Failed to list expenses from service")
}
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::ListExpenses")
}
func (h *ExpenseHandler) GetExpenseAnalytics(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.ExpenseAnalyticsRequest
if err := c.ShouldBindQuery(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::GetExpenseAnalytics -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::GetExpenseAnalytics")
return
}
if contextInfo.OutletID != uuid.Nil {
outletID := contextInfo.OutletID.String()
req.OutletID = &outletID
} else if outletID := c.Query("outlet_id"); outletID != "" {
req.OutletID = &outletID
}
expenseResponse := h.expenseService.GetExpenseAnalytics(ctx, contextInfo, &req)
if expenseResponse.HasErrors() {
errorResp := expenseResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::GetExpenseAnalytics -> Failed to get expense analytics from service")
}
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::GetExpenseAnalytics")
}
@@ -275,3 +275,4 @@ func (h *IngredientUnitConverterHandler) GetUnitsByIngredientID(c *gin.Context)
util.HandleResponse(c.Writer, c.Request, unitsResponse, "IngredientUnitConverterHandler::GetUnitsByIngredientID")
}
-190
View File
@@ -1,190 +0,0 @@
package handler
import (
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
"apskel-pos-be/internal/validator"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type NotificationHandler struct {
notificationService service.NotificationService
notificationValidator validator.NotificationValidator
}
func NewNotificationHandler(
notificationService service.NotificationService,
notificationValidator validator.NotificationValidator,
) *NotificationHandler {
return &NotificationHandler{
notificationService: notificationService,
notificationValidator: notificationValidator,
}
}
// Send godoc
// POST /api/v1/notifications/send
// Sends a notification to specific users.
func (h *NotificationHandler) Send(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.SendNotificationRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("NotificationHandler::Send -> request binding failed")
validationErr := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationErr}), "NotificationHandler::Send")
return
}
if validationErr, errCode := h.notificationValidator.ValidateSendRequest(&req); validationErr != nil {
respErr := contract.NewResponseError(errCode, constants.RequestEntity, validationErr.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{respErr}), "NotificationHandler::Send")
return
}
resp := h.notificationService.Send(ctx, &req, contextInfo.UserID)
if resp.HasErrors() {
logger.FromContext(ctx).WithError(resp.GetErrors()[0]).Error("NotificationHandler::Send -> service error")
}
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::Send")
}
// Broadcast godoc
// POST /api/v1/notifications/broadcast
// Sends a notification to all active users in the caller's organization.
func (h *NotificationHandler) Broadcast(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.BroadcastNotificationRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("NotificationHandler::Broadcast -> request binding failed")
validationErr := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationErr}), "NotificationHandler::Broadcast")
return
}
if validationErr, errCode := h.notificationValidator.ValidateBroadcastRequest(&req); validationErr != nil {
respErr := contract.NewResponseError(errCode, constants.RequestEntity, validationErr.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{respErr}), "NotificationHandler::Broadcast")
return
}
resp := h.notificationService.Broadcast(ctx, &req, contextInfo.OrganizationID, contextInfo.UserID)
if resp.HasErrors() {
logger.FromContext(ctx).WithError(resp.GetErrors()[0]).Error("NotificationHandler::Broadcast -> service error")
}
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::Broadcast")
}
// List godoc
// GET /api/v1/notifications
// Returns paginated notifications for the authenticated user.
func (h *NotificationHandler) List(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
req := contract.ListNotificationsRequest{
Page: 1,
Limit: 20,
}
if err := c.ShouldBindQuery(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("NotificationHandler::List -> query binding failed")
validationErr := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationErr}), "NotificationHandler::List")
return
}
if validationErr, errCode := h.notificationValidator.ValidateListRequest(&req); validationErr != nil {
respErr := contract.NewResponseError(errCode, constants.RequestEntity, validationErr.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{respErr}), "NotificationHandler::List")
return
}
resp := h.notificationService.ListForUser(ctx, &req, contextInfo.UserID)
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::List")
}
// GetByID godoc
// GET /api/v1/notifications/:id
func (h *NotificationHandler) GetByID(c *gin.Context) {
ctx := c.Request.Context()
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("NotificationHandler::GetByID -> invalid notification ID")
validationErr := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid notification ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationErr}), "NotificationHandler::GetByID")
return
}
resp := h.notificationService.GetByID(ctx, id)
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::GetByID")
}
// MarkAsRead godoc
// PUT /api/v1/notifications/:id/read
func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
idStr := c.Param("id")
receiverID, err := uuid.Parse(idStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("NotificationHandler::MarkAsRead -> invalid receiver ID")
validationErr := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid notification receiver ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationErr}), "NotificationHandler::MarkAsRead")
return
}
resp := h.notificationService.MarkAsRead(ctx, receiverID, contextInfo.UserID)
if resp.HasErrors() {
logger.FromContext(ctx).WithError(resp.GetErrors()[0]).Error("NotificationHandler::MarkAsRead -> service error")
}
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::MarkAsRead")
}
// MarkAllAsRead godoc
// PUT /api/v1/notifications/read-all
func (h *NotificationHandler) MarkAllAsRead(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
resp := h.notificationService.MarkAllAsRead(ctx, contextInfo.UserID)
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::MarkAllAsRead")
}
// Delete godoc
// DELETE /api/v1/notifications/:id
func (h *NotificationHandler) Delete(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
idStr := c.Param("id")
receiverID, err := uuid.Parse(idStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("NotificationHandler::Delete -> invalid receiver ID")
validationErr := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid notification receiver ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationErr}), "NotificationHandler::Delete")
return
}
resp := h.notificationService.DeleteForUser(ctx, receiverID, contextInfo.UserID)
if resp.HasErrors() {
logger.FromContext(ctx).WithError(resp.GetErrors()[0]).Error("NotificationHandler::Delete -> service error")
}
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::Delete")
}
-4
View File
@@ -137,10 +137,6 @@ func (h *OrderHandler) ListOrders(c *gin.Context) {
}
modelReq.OrganizationID = &contextInfo.OrganizationID
if modelReq.OutletID == nil && contextInfo.OutletID != uuid.Nil {
modelReq.OutletID = &contextInfo.OutletID
}
response, err := h.orderService.ListOrders(c.Request.Context(), modelReq)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::ListOrders", err.Error())}), "OrderHandler::ListOrders")
+2 -95
View File
@@ -60,7 +60,6 @@ func (h *ProductHandler) CreateProduct(c *gin.Context) {
func (h *ProductHandler) UpdateProduct(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
productIDStr := c.Param("id")
productID, err := uuid.Parse(productIDStr)
@@ -86,7 +85,7 @@ func (h *ProductHandler) UpdateProduct(c *gin.Context) {
return
}
productResponse := h.productService.UpdateProduct(ctx, contextInfo, productID, &req)
productResponse := h.productService.UpdateProduct(ctx, productID, &req)
if productResponse.HasErrors() {
errorResp := productResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ProductHandler::UpdateProduct -> Failed to update product from service")
@@ -118,7 +117,6 @@ func (h *ProductHandler) DeleteProduct(c *gin.Context) {
func (h *ProductHandler) GetProduct(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
productIDStr := c.Param("id")
productID, err := uuid.Parse(productIDStr)
@@ -129,7 +127,7 @@ func (h *ProductHandler) GetProduct(c *gin.Context) {
return
}
productResponse := h.productService.GetProductByID(ctx, productID, contextInfo.OutletID)
productResponse := h.productService.GetProductByID(ctx, productID)
if productResponse.HasErrors() {
errorResp := productResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ProductHandler::GetProduct -> Failed to get product from service")
@@ -186,97 +184,6 @@ func (h *ProductHandler) ListProducts(c *gin.Context) {
}
}
if outletIDStr := c.Query("outlet_id"); outletIDStr != "" {
if outletID, err := uuid.Parse(outletIDStr); err == nil {
req.OutletID = &outletID
}
} else if contextInfo.OutletID != uuid.Nil {
req.OutletID = &contextInfo.OutletID
}
if minPriceStr := c.Query("min_price"); minPriceStr != "" {
if minPrice, err := strconv.ParseFloat(minPriceStr, 64); err == nil {
req.MinPrice = &minPrice
}
}
if maxPriceStr := c.Query("max_price"); maxPriceStr != "" {
if maxPrice, err := strconv.ParseFloat(maxPriceStr, 64); err == nil {
req.MaxPrice = &maxPrice
}
}
validationError, validationErrorCode := h.productValidator.ValidateListProductsRequest(req)
if validationError != nil {
logger.FromContext(ctx).WithError(validationError).Error("ProductHandler::ListProducts -> request validation failed")
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductHandler::ListProducts")
return
}
productsResponse := h.productService.ListProducts(ctx, req)
if productsResponse.HasErrors() {
errorResp := productsResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ProductHandler::ListProducts -> Failed to list products from service")
}
util.HandleResponse(c.Writer, c.Request, productsResponse, "ProductHandler::ListProducts")
}
func (h *ProductHandler) ListProductAll(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
req := &contract.ListProductsRequest{
Page: 1,
Limit: 10,
OrganizationID: &contextInfo.OrganizationID,
}
if pageStr := c.Query("page"); pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil {
req.Page = page
}
}
if limitStr := c.Query("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil {
req.Limit = limit
}
}
if search := c.Query("search"); search != "" {
req.Search = search
}
if businessType := c.Query("business_type"); businessType != "" {
req.BusinessType = businessType
}
if organizationIDStr := c.Query("organization_id"); organizationIDStr != "" {
if organizationID, err := uuid.Parse(organizationIDStr); err == nil {
req.OrganizationID = &organizationID
}
}
if categoryIDStr := c.Query("category_id"); categoryIDStr != "" {
if categoryID, err := uuid.Parse(categoryIDStr); err == nil {
req.CategoryID = &categoryID
}
}
if isActiveStr := c.Query("is_active"); isActiveStr != "" {
if isActive, err := strconv.ParseBool(isActiveStr); err == nil {
req.IsActive = &isActive
}
}
if outletIDStr := c.Query("outlet_id"); outletIDStr != "" {
if outletID, err := uuid.Parse(outletIDStr); err == nil {
req.OutletID = &outletID
}
}
if minPriceStr := c.Query("min_price"); minPriceStr != "" {
if minPrice, err := strconv.ParseFloat(minPriceStr, 64); err == nil {
req.MinPrice = &minPrice
@@ -1,135 +0,0 @@
package handler
import (
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
"apskel-pos-be/internal/validator"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type ProductOutletPriceHandler struct {
service service.ProductOutletPriceService
validator validator.ProductOutletPriceValidator
}
func NewProductOutletPriceHandler(svc service.ProductOutletPriceService, v validator.ProductOutletPriceValidator) *ProductOutletPriceHandler {
return &ProductOutletPriceHandler{
service: svc,
validator: v,
}
}
func (h *ProductOutletPriceHandler) Upsert(c *gin.Context) {
ctx := c.Request.Context()
var req contract.CreateProductOutletPriceRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("ProductOutletPriceHandler::Upsert -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::Upsert")
return
}
if validationErr, code := h.validator.ValidateCreateRequest(&req); validationErr != nil {
validationResponseError := contract.NewResponseError(code, constants.RequestEntity, validationErr.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::Upsert")
return
}
resp := h.service.Upsert(ctx, &req)
util.HandleResponse(c.Writer, c.Request, resp, "ProductOutletPriceHandler::Upsert")
}
func (h *ProductOutletPriceHandler) GetByProductAndOutlet(c *gin.Context) {
ctx := c.Request.Context()
productIDStr := c.Param("product_id")
productID, err := uuid.Parse(productIDStr)
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid product ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::GetByProductAndOutlet")
return
}
outletIDStr := c.Param("outlet_id")
outletID, err := uuid.Parse(outletIDStr)
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid outlet ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::GetByProductAndOutlet")
return
}
resp := h.service.GetByProductAndOutlet(ctx, productID, outletID)
util.HandleResponse(c.Writer, c.Request, resp, "ProductOutletPriceHandler::GetByProductAndOutlet")
}
func (h *ProductOutletPriceHandler) GetByProduct(c *gin.Context) {
ctx := c.Request.Context()
productIDStr := c.Param("product_id")
productID, err := uuid.Parse(productIDStr)
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid product ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::GetByProduct")
return
}
resp := h.service.GetByProduct(ctx, productID)
util.HandleResponse(c.Writer, c.Request, resp, "ProductOutletPriceHandler::GetByProduct")
}
func (h *ProductOutletPriceHandler) GetByOutlet(c *gin.Context) {
ctx := c.Request.Context()
outletIDStr := c.Param("outlet_id")
outletID, err := uuid.Parse(outletIDStr)
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid outlet ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::GetByOutlet")
return
}
resp := h.service.GetByOutlet(ctx, outletID)
util.HandleResponse(c.Writer, c.Request, resp, "ProductOutletPriceHandler::GetByOutlet")
}
func (h *ProductOutletPriceHandler) Delete(c *gin.Context) {
ctx := c.Request.Context()
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::Delete")
return
}
resp := h.service.Delete(ctx, id)
util.HandleResponse(c.Writer, c.Request, resp, "ProductOutletPriceHandler::Delete")
}
func (h *ProductOutletPriceHandler) BulkUpsert(c *gin.Context) {
ctx := c.Request.Context()
var req contract.BulkCreateProductOutletPriceRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("ProductOutletPriceHandler::BulkUpsert -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::BulkUpsert")
return
}
if validationErr, code := h.validator.ValidateBulkCreateRequest(&req); validationErr != nil {
validationResponseError := contract.NewResponseError(code, constants.RequestEntity, validationErr.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::BulkUpsert")
return
}
resp := h.service.BulkUpsert(ctx, &req)
util.HandleResponse(c.Writer, c.Request, resp, "ProductOutletPriceHandler::BulkUpsert")
}
@@ -1,160 +0,0 @@
package handler
import (
"strconv"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
"apskel-pos-be/internal/validator"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type PurchaseCategoryHandler struct {
purchaseCategoryService service.PurchaseCategoryService
purchaseCategoryValidator validator.PurchaseCategoryValidator
}
func NewPurchaseCategoryHandler(purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator) *PurchaseCategoryHandler {
return &PurchaseCategoryHandler{
purchaseCategoryService: purchaseCategoryService,
purchaseCategoryValidator: purchaseCategoryValidator,
}
}
func (h *PurchaseCategoryHandler) CreatePurchaseCategory(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.CreatePurchaseCategoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("PurchaseCategoryHandler::CreatePurchaseCategory -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::CreatePurchaseCategory")
return
}
if validationError, validationErrorCode := h.purchaseCategoryValidator.ValidateCreatePurchaseCategoryRequest(&req); validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::CreatePurchaseCategory")
return
}
response := h.purchaseCategoryService.CreatePurchaseCategory(ctx, contextInfo, &req)
util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::CreatePurchaseCategory")
}
func (h *PurchaseCategoryHandler) UpdatePurchaseCategory(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
categoryID, err := uuid.Parse(c.Param("id"))
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid purchase category ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::UpdatePurchaseCategory")
return
}
var req contract.UpdatePurchaseCategoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("PurchaseCategoryHandler::UpdatePurchaseCategory -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::UpdatePurchaseCategory")
return
}
if validationError, validationErrorCode := h.purchaseCategoryValidator.ValidateUpdatePurchaseCategoryRequest(&req); validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::UpdatePurchaseCategory")
return
}
response := h.purchaseCategoryService.UpdatePurchaseCategory(ctx, contextInfo, categoryID, &req)
util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::UpdatePurchaseCategory")
}
func (h *PurchaseCategoryHandler) DeletePurchaseCategory(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
categoryID, err := uuid.Parse(c.Param("id"))
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid purchase category ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::DeletePurchaseCategory")
return
}
response := h.purchaseCategoryService.DeletePurchaseCategory(ctx, contextInfo, categoryID)
util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::DeletePurchaseCategory")
}
func (h *PurchaseCategoryHandler) GetPurchaseCategory(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
categoryID, err := uuid.Parse(c.Param("id"))
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid purchase category ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::GetPurchaseCategory")
return
}
response := h.purchaseCategoryService.GetPurchaseCategoryByID(ctx, contextInfo, categoryID)
util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::GetPurchaseCategory")
}
func (h *PurchaseCategoryHandler) ListPurchaseCategories(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
req := &contract.ListPurchaseCategoriesRequest{
Page: 1,
Limit: 100,
}
if pageStr := c.Query("page"); pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil {
req.Page = page
}
}
if limitStr := c.Query("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil {
req.Limit = limit
}
}
if parentIDStr := c.Query("parent_id"); parentIDStr != "" {
if parentID, err := uuid.Parse(parentIDStr); err == nil {
req.ParentID = &parentID
}
}
if categoryType := c.Query("type"); categoryType != "" {
req.Type = categoryType
}
if search := c.Query("search"); search != "" {
req.Search = search
}
if isActiveStr := c.Query("is_active"); isActiveStr != "" {
if isActive, err := strconv.ParseBool(isActiveStr); err == nil {
req.IsActive = &isActive
}
}
if validationError, validationErrorCode := h.purchaseCategoryValidator.ValidateListPurchaseCategoriesRequest(req); validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::ListPurchaseCategories")
return
}
response := h.purchaseCategoryService.ListPurchaseCategories(ctx, contextInfo, req)
util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::ListPurchaseCategories")
}
@@ -176,20 +176,6 @@ func (h *PurchaseOrderHandler) ListPurchaseOrders(c *gin.Context) {
}
}
if team := c.Query("team"); team != "" {
req.Team = team
}
if teamScope := c.Query("team_scope"); teamScope != "" {
req.TeamScope = teamScope
}
if teamCategoryIDStr := c.Query("team_category_id"); teamCategoryIDStr != "" {
if teamCategoryID, err := uuid.Parse(teamCategoryIDStr); err == nil {
req.TeamCategoryID = &teamCategoryID
}
}
if startDateStr := c.Query("start_date"); startDateStr != "" {
if startDate, err := time.Parse("2006-01-02", startDateStr); err == nil {
req.StartDate = &startDate
@@ -238,21 +224,6 @@ 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)
+1 -49
View File
@@ -8,7 +8,6 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type ReportHandler struct {
@@ -20,26 +19,11 @@ func NewReportHandler(reportService service.ReportService, userService UserServi
return &ReportHandler{reportService: reportService, userService: userService}
}
func (h *ReportHandler) resolveOutletID(c *gin.Context, contextOutletID uuid.UUID) string {
if outletIDStr := c.Query("outlet_id"); outletIDStr != "" {
if _, err := uuid.Parse(outletIDStr); err == nil {
return outletIDStr
}
}
if pathOutletID := c.Param("outlet_id"); pathOutletID != "" {
return pathOutletID
}
if contextOutletID != uuid.Nil {
return contextOutletID.String()
}
return ""
}
func (h *ReportHandler) GetDailyTransactionReportPDF(c *gin.Context) {
ctx := c.Request.Context()
ci := appcontext.FromGinContext(ctx)
outletID := h.resolveOutletID(c, ci.OutletID)
outletID := c.Param("outlet_id")
var dayPtr *time.Time
if d := c.Query("date"); d != "" {
if t, err := time.Parse("2006-01-02", d); err == nil {
@@ -66,35 +50,3 @@ 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")
}
+78 -36
View File
@@ -1,11 +1,11 @@
package handler
import (
"apskel-pos-be/internal/client"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/pkg/tabletoken"
"apskel-pos-be/internal/processor"
@@ -15,6 +15,7 @@ import (
"apskel-pos-be/internal/util"
"context"
"fmt"
"log"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -29,7 +30,7 @@ type SelfOrderHandler struct {
userRepo processor.UserRepository
sessionRepo repository.SessionRepository
orderRepo repository.OrderRepository
productOutletPriceService service.ProductOutletPriceService
fcmClient client.FcmClient
}
func NewSelfOrderHandler(
@@ -41,7 +42,7 @@ func NewSelfOrderHandler(
userRepo processor.UserRepository,
sessionRepo repository.SessionRepository,
orderRepo repository.OrderRepository,
productOutletPriceService service.ProductOutletPriceService,
fcmClient client.FcmClient,
) *SelfOrderHandler {
return &SelfOrderHandler{
orderService: orderService,
@@ -52,7 +53,7 @@ func NewSelfOrderHandler(
userRepo: userRepo,
sessionRepo: sessionRepo,
orderRepo: orderRepo,
productOutletPriceService: productOutletPriceService,
fcmClient: fcmClient,
}
}
@@ -219,29 +220,16 @@ func (h *SelfOrderHandler) GetMenu(c *gin.Context) {
return
}
menu := h.buildMenuResponse(ctx, outlet, table, catList.Categories, prodList.Products)
menu := h.buildMenuResponse(outlet, table, catList.Categories, prodList.Products)
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(menu), "SelfOrderHandler::GetMenu")
}
func (h *SelfOrderHandler) buildMenuResponse(
ctx context.Context,
outlet *entities.Outlet,
table *entities.Table,
categories []contract.CategoryResponse,
products []contract.ProductResponse,
) *contract.SelfOrderMenuResponse {
outletPriceMap := make(map[uuid.UUID]float64)
if h.productOutletPriceService != nil {
priceResp := h.productOutletPriceService.GetByOutlet(ctx, outlet.ID)
if priceResp != nil && !priceResp.HasErrors() {
if priceList, ok := priceResp.Data.(*contract.ListProductOutletPricesResponse); ok {
for _, p := range priceList.Prices {
outletPriceMap[p.ProductID] = p.Price
}
}
}
}
productMap := make(map[uuid.UUID][]contract.ProductResponse)
for _, p := range products {
productMap[p.CategoryID] = append(productMap[p.CategoryID], p)
@@ -252,15 +240,11 @@ func (h *SelfOrderHandler) buildMenuResponse(
menuItems := make([]contract.SelfOrderMenuItem, 0)
if prods, ok := productMap[cat.ID]; ok {
for _, p := range prods {
price := p.Price
if outletPrice, exists := outletPriceMap[p.ID]; exists {
price = outletPrice
}
item := contract.SelfOrderMenuItem{
ID: p.ID,
Name: p.Name,
Description: p.Description,
Price: price,
Price: p.Price,
ImageURL: p.ImageURL,
}
for _, v := range p.Variants {
@@ -351,7 +335,6 @@ func (h *SelfOrderHandler) CreateOrder(c *gin.Context) {
metadata := make(map[string]interface{})
metadata["self_order"] = true
metadata["session_id"] = session.ID
metadata["customer_name"] = req.CustomerName
tableID := table.ID
modelReq := &models.CreateOrderRequest{
@@ -359,7 +342,7 @@ func (h *SelfOrderHandler) CreateOrder(c *gin.Context) {
UserID: userID,
TableID: &tableID,
TableNumber: &table.TableName,
OrderType: constants.OrderType(req.OrderType),
OrderType: constants.OrderTypeDineIn,
OrderItems: orderItems,
Metadata: metadata,
}
@@ -373,13 +356,41 @@ func (h *SelfOrderHandler) CreateOrder(c *gin.Context) {
return
}
go h.sendNewOrderNotification(context.Background(), table.OrganizationID, table.TableName, len(req.OrderItems))
contractResp := transformer.OrderModelToContract(response)
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "SelfOrderHandler::CreateOrder")
}
func (h *SelfOrderHandler) sendNewOrderNotification(ctx context.Context, organizationID uuid.UUID, tableName string, itemCount int) {
users, err := h.userRepo.GetUsersWithFcmTokenByOrganization(ctx, organizationID)
if err != nil {
log.Printf("SelfOrderHandler::sendNewOrderNotification -> failed to get users with FCM token: %v", err)
return
}
tokens := make([]string, 0, len(users))
for _, u := range users {
if u.FcmToken != nil && *u.FcmToken != "" {
tokens = append(tokens, *u.FcmToken)
}
}
if len(tokens) == 0 {
return
}
title := "Order Baru"
body := fmt.Sprintf("Order baru dari Meja %s — %d item", tableName, itemCount)
if err := h.fcmClient.SendMulticastNotification(ctx, tokens, title, body); err != nil {
log.Printf("SelfOrderHandler::sendNewOrderNotification -> failed to send FCM notification: %v", err)
}
}
func (h *SelfOrderHandler) GetOrdersBySession(c *gin.Context) {
ctx := c.Request.Context()
sessionID := c.Param("session_id")
sessionID := c.Param("sessionId")
if sessionID == "" {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
@@ -412,15 +423,47 @@ func (h *SelfOrderHandler) GetOrdersBySession(c *gin.Context) {
return
}
modelOrders := mappers.OrderEntitiesToResponses(orders)
contractOrders := make([]contract.OrderResponse, len(modelOrders))
for i := range modelOrders {
contractOrders[i] = *transformer.OrderModelToContract(&modelOrders[i])
resp := &contract.SelfOrderListOrdersResponse{
Orders: make([]contract.SelfOrderOrderItem, 0, len(orders)),
}
for _, o := range orders {
item := contract.SelfOrderOrderItem{
ID: o.ID,
OrderNumber: o.OrderNumber,
TableNumber: o.TableNumber,
OrderType: string(o.OrderType),
Status: string(o.Status),
Subtotal: o.Subtotal,
TaxAmount: o.TaxAmount,
DiscountAmount: o.DiscountAmount,
TotalAmount: o.TotalAmount,
RemainingAmount: o.RemainingAmount,
PaymentStatus: string(o.PaymentStatus),
IsVoid: o.IsVoid,
IsRefund: o.IsRefund,
CreatedAt: o.CreatedAt,
}
for _, oi := range o.OrderItems {
lineItem := contract.SelfOrderOrderLineItem{
ProductID: oi.ProductID,
Quantity: oi.Quantity,
UnitPrice: oi.UnitPrice,
TotalPrice: oi.TotalPrice,
Notes: oi.Notes,
Status: string(oi.Status),
ProductVariantID: oi.ProductVariantID,
}
if oi.Product.ID != uuid.Nil {
lineItem.ProductName = oi.Product.Name
}
if oi.ProductVariant != nil {
lineItem.ProductVariantNam = &oi.ProductVariant.Name
}
item.Items = append(item.Items, lineItem)
}
resp.Orders = append(resp.Orders, item)
}
resp := &contract.SelfOrderListOrdersResponse{
Orders: contractOrders,
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(resp), "SelfOrderHandler::GetOrdersBySession")
}
@@ -429,7 +472,6 @@ func (h *SelfOrderHandler) validateCreateOrderRequest(req *contract.SelfOrderCre
return fmt.Errorf("session_id is required")
}
if len(req.OrderItems) == 0 {
return fmt.Errorf("at least one order item is required")
}
for i, item := range req.OrderItems {
@@ -457,7 +499,7 @@ func (h *SelfOrderHandler) ListCategories(c *gin.Context) {
if req.OrganizationID == "" {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "organization_id is required"),
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "organisasi_id is required"),
}), "SelfOrderHandler::ListCategories")
return
}
@@ -472,7 +514,7 @@ func (h *SelfOrderHandler) ListCategories(c *gin.Context) {
orgID, err := uuid.Parse(req.OrganizationID)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.RequestEntity, "invalid organization_id format"),
contract.NewResponseError(constants.ValidationErrorCode, constants.RequestEntity, "invalid organisasi_id format"),
}), "SelfOrderHandler::ListCategories")
return
}
+5 -10
View File
@@ -19,14 +19,14 @@ import (
type TableHandler struct {
tableService TableService
tableValidator *validator.TableValidator
selfOrderURL string
baseURL string
}
func NewTableHandler(tableService TableService, tableValidator *validator.TableValidator, selfOrderURL string) *TableHandler {
func NewTableHandler(tableService TableService, tableValidator *validator.TableValidator, baseURL string) *TableHandler {
return &TableHandler{
tableService: tableService,
tableValidator: tableValidator,
selfOrderURL: selfOrderURL,
baseURL: baseURL,
}
}
@@ -150,11 +150,6 @@ func (h *TableHandler) List(c *gin.Context) {
Limit: 100,
}
// Fallback to context outlet ID if not provided in query
if query.OutletID == "" && contextInfo.OutletID != uuid.Nil {
query.OutletID = contextInfo.OutletID.String()
}
if pageStr := c.Query("page"); pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil && page > 0 {
query.Page = page
@@ -317,7 +312,7 @@ func (h *TableHandler) GenerateQRCode(c *gin.Context) {
return
}
selfOrderURLResult := fmt.Sprintf("%s/menu?token=%s", h.selfOrderURL, token)
selfOrderURL := fmt.Sprintf("%s/api/v1/self-order/table/%s", h.baseURL, token)
size := 256
if sizeStr := c.Query("size"); sizeStr != "" {
@@ -326,7 +321,7 @@ func (h *TableHandler) GenerateQRCode(c *gin.Context) {
}
}
pngBytes, err := qrcode.GeneratePNG(selfOrderURLResult, size)
pngBytes, err := qrcode.GeneratePNG(selfOrderURL, size)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("TableHandler::GenerateQRCode -> QR generation failed")
validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.TableEntity, "Failed to generate QR code")
-215
View File
@@ -1,215 +0,0 @@
package handler
import (
"strconv"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
"apskel-pos-be/internal/validator"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type UserDeviceHandler struct {
userDeviceService service.UserDeviceService
userDeviceValidator validator.UserDeviceValidator
}
func NewUserDeviceHandler(
userDeviceService service.UserDeviceService,
userDeviceValidator validator.UserDeviceValidator,
) *UserDeviceHandler {
return &UserDeviceHandler{
userDeviceService: userDeviceService,
userDeviceValidator: userDeviceValidator,
}
}
func (h *UserDeviceHandler) RegisterDevice(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.RegisterUserDeviceRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("UserDeviceHandler::RegisterDevice -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::RegisterDevice")
return
}
validationError, validationErrorCode := h.userDeviceValidator.ValidateRegisterDeviceRequest(&req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::RegisterDevice")
return
}
deviceResponse := h.userDeviceService.RegisterDevice(ctx, contextInfo.UserID, &req)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::RegisterDevice -> Failed to register device from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::RegisterDevice")
}
func (h *UserDeviceHandler) UpdateDevice(c *gin.Context) {
ctx := c.Request.Context()
deviceIDStr := c.Param("id")
deviceID, err := uuid.Parse(deviceIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("UserDeviceHandler::UpdateDevice -> Invalid device ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid device ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::UpdateDevice")
return
}
var req contract.UpdateUserDeviceRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("UserDeviceHandler::UpdateDevice -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "Invalid request body")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::UpdateDevice")
return
}
validationError, validationErrorCode := h.userDeviceValidator.ValidateUpdateDeviceRequest(&req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::UpdateDevice")
return
}
deviceResponse := h.userDeviceService.UpdateDevice(ctx, deviceID, &req)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::UpdateDevice -> Failed to update device from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::UpdateDevice")
}
func (h *UserDeviceHandler) DeleteDevice(c *gin.Context) {
ctx := c.Request.Context()
deviceIDStr := c.Param("id")
deviceID, err := uuid.Parse(deviceIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("UserDeviceHandler::DeleteDevice -> Invalid device ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid device ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::DeleteDevice")
return
}
deviceResponse := h.userDeviceService.DeleteDevice(ctx, deviceID)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::DeleteDevice -> Failed to delete device from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::DeleteDevice")
}
func (h *UserDeviceHandler) GetDevice(c *gin.Context) {
ctx := c.Request.Context()
deviceIDStr := c.Param("id")
deviceID, err := uuid.Parse(deviceIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("UserDeviceHandler::GetDevice -> Invalid device ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid device ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::GetDevice")
return
}
deviceResponse := h.userDeviceService.GetDeviceByID(ctx, deviceID)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::GetDevice -> Failed to get device from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::GetDevice")
}
func (h *UserDeviceHandler) GetMyDevices(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
deviceResponse := h.userDeviceService.GetDevicesByUserID(ctx, contextInfo.UserID)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::GetMyDevices -> Failed to get devices from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::GetMyDevices")
}
func (h *UserDeviceHandler) GetDevicesByUser(c *gin.Context) {
ctx := c.Request.Context()
userIDStr := c.Param("user_id")
userID, err := uuid.Parse(userIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("UserDeviceHandler::GetDevicesByUser -> Invalid user ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid user ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::GetDevicesByUser")
return
}
deviceResponse := h.userDeviceService.GetDevicesByUserID(ctx, userID)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::GetDevicesByUser -> Failed to get devices from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::GetDevicesByUser")
}
func (h *UserDeviceHandler) ListDevices(c *gin.Context) {
ctx := c.Request.Context()
req := &contract.ListUserDevicesRequest{
Page: 1,
Limit: 10,
}
if pageStr := c.Query("page"); pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil {
req.Page = page
}
}
if limitStr := c.Query("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil {
req.Limit = limit
}
}
if userID := c.Query("user_id"); userID != "" {
req.UserID = userID
}
if platform := c.Query("platform"); platform != "" {
req.Platform = platform
}
validationError, validationErrorCode := h.userDeviceValidator.ValidateListDevicesRequest(req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::ListDevices")
return
}
deviceResponse := h.userDeviceService.ListDevices(ctx, req)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::ListDevices -> Failed to list devices from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::ListDevices")
}
-128
View File
@@ -1,128 +0,0 @@
package mappers
import (
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
)
// Amounts are money rounded to two decimals, so anything under half a cent apart
// is the same figure. Comparing them directly would leave an advance a hundredth of
// a rupiah short of settled.
const cashAdvanceAmountEpsilon = 0.005
// cashAdvanceTeamFromEntity renders the team an advance was handed to. Unlike a
// purchase order there is always one, so this never returns nil. The category name
// is only filled in when TeamCategory was preloaded.
func cashAdvanceTeamFromEntity(entity *entities.CashAdvance) *models.PurchaseTeam {
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
}
// cashAdvanceSettlementStatus reads the money rather than any stored flag: cash is
// accounted for by spending charged to the advance plus what was handed back. A
// team that overspent still counts as settled — the excess is owed back to them.
func cashAdvanceSettlementStatus(amount, settled, returned float64) string {
accounted := settled + returned
switch {
case accounted <= cashAdvanceAmountEpsilon:
return constants.CashAdvanceSettlementOpen
case amount-accounted > cashAdvanceAmountEpsilon:
return constants.CashAdvanceSettlementPartial
default:
return constants.CashAdvanceSettlementSettled
}
}
func CashAdvanceEntityToModel(entity *entities.CashAdvance) *models.CashAdvance {
if entity == nil {
return nil
}
return &models.CashAdvance{
ID: entity.ID,
OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
CodeNumber: entity.CodeNumber,
TeamScope: entity.TeamScope,
TeamCategoryID: entity.TeamCategoryID,
Amount: entity.Amount,
ReturnedAmount: entity.ReturnedAmount,
IssuedDate: entity.IssuedDate,
DueDate: entity.DueDate,
Status: entity.Status,
Description: entity.Description,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
}
func CashAdvanceEntityToResponse(entity *entities.CashAdvance) *models.CashAdvanceResponse {
if entity == nil {
return nil
}
return &models.CashAdvanceResponse{
ID: entity.ID,
OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
CodeNumber: entity.CodeNumber,
TeamScope: entity.TeamScope,
TeamCategoryID: entity.TeamCategoryID,
Amount: entity.Amount,
SettledAmount: entity.SettledAmount,
ReturnedAmount: entity.ReturnedAmount,
RemainingAmount: entity.Amount - entity.SettledAmount - entity.ReturnedAmount,
SettlementStatus: cashAdvanceSettlementStatus(entity.Amount, entity.SettledAmount, entity.ReturnedAmount),
IssuedDate: entity.IssuedDate,
DueDate: entity.DueDate,
Status: entity.Status,
Description: entity.Description,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
Team: cashAdvanceTeamFromEntity(entity),
}
}
func CashAdvanceEntitiesToResponses(entities []*entities.CashAdvance) []*models.CashAdvanceResponse {
if entities == nil {
return nil
}
responses := make([]*models.CashAdvanceResponse, len(entities))
for i, entity := range entities {
responses[i] = CashAdvanceEntityToResponse(entity)
}
return responses
}
func CashAdvanceSettlementEntitiesToModels(settlements []*entities.CashAdvanceSettlement) []models.CashAdvanceSettlement {
if settlements == nil {
return nil
}
result := make([]models.CashAdvanceSettlement, len(settlements))
for i, settlement := range settlements {
result[i] = models.CashAdvanceSettlement{
Type: settlement.Type,
ID: settlement.ID,
Number: settlement.Number,
Date: settlement.Date,
Amount: settlement.Amount,
Status: settlement.Status,
}
}
return result
}
@@ -1,101 +0,0 @@
package mappers
import (
"testing"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
func TestCashAdvanceSettlementStatusFollowsTheMoney(t *testing.T) {
tests := []struct {
name string
amount float64
settled float64
returned float64
expectedStatus string
expectedRemaining float64
}{
{
name: "nothing spent or returned is still open",
amount: 500000,
expectedStatus: constants.CashAdvanceSettlementOpen,
expectedRemaining: 500000,
},
{
name: "some spending leaves it partial",
amount: 500000,
settled: 200000,
expectedStatus: constants.CashAdvanceSettlementPartial,
expectedRemaining: 300000,
},
{
name: "cash handed back counts the same as spending",
amount: 500000,
settled: 300000,
returned: 100000,
expectedStatus: constants.CashAdvanceSettlementPartial,
expectedRemaining: 100000,
},
{
name: "spending plus cash back covering the advance settles it",
amount: 500000,
settled: 420000,
returned: 80000,
expectedStatus: constants.CashAdvanceSettlementSettled,
expectedRemaining: 0,
},
{
// The team paid the difference out of pocket, so the outlet owes them.
name: "overspending settles the cash advance and goes negative",
amount: 500000,
settled: 620000,
expectedStatus: constants.CashAdvanceSettlementSettled,
expectedRemaining: -120000,
},
{
// Two decimals of money should not leave a cash advance a fraction short.
name: "a rounding crumb short still settles",
amount: 100000,
settled: 99999.999,
expectedStatus: constants.CashAdvanceSettlementSettled,
expectedRemaining: 0.001,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
response := CashAdvanceEntityToResponse(&entities.CashAdvance{
ID: uuid.New(),
TeamScope: constants.PurchaseTeamScopeCentral,
Amount: tt.amount,
SettledAmount: tt.settled,
ReturnedAmount: tt.returned,
})
require.Equal(t, tt.expectedStatus, response.SettlementStatus)
require.InDelta(t, tt.expectedRemaining, response.RemainingAmount, 0.0001)
})
}
}
func TestCashAdvanceTeamAlwaysRendered(t *testing.T) {
categoryID := uuid.New()
central := CashAdvanceEntityToResponse(&entities.CashAdvance{TeamScope: constants.PurchaseTeamScopeCentral})
require.NotNil(t, central.Team)
require.Equal(t, constants.PurchaseTeamCentralName, central.Team.Name)
require.Nil(t, central.Team.CategoryID)
category := CashAdvanceEntityToResponse(&entities.CashAdvance{
TeamScope: constants.PurchaseTeamScopeCategory,
TeamCategoryID: &categoryID,
TeamCategory: &entities.Category{ID: categoryID, Name: "Dapur"},
})
require.NotNil(t, category.Team)
require.Equal(t, "Dapur", category.Team.Name)
require.Equal(t, &categoryID, category.Team.CategoryID)
}
+8 -28
View File
@@ -13,12 +13,11 @@ func CategoryEntityToModel(entity *entities.Category) *models.Category {
return &models.Category{
ID: entity.ID,
OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
Name: entity.Name,
Description: entity.Description,
ImageURL: nil,
Order: entity.Order,
IsActive: true,
ImageURL: nil, // Entity doesn't have ImageURL, model does
Order: entity.Order, // Entity doesn't have SortOrder, model does
IsActive: true, // Entity doesn't have IsActive, default to true
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
@@ -33,14 +32,14 @@ func CategoryModelToEntity(model *models.Category) *entities.Category {
if model.ImageURL != nil {
metadata["image_url"] = *model.ImageURL
}
// metadata["sort_order"] = model.SortOrder
return &entities.Category{
ID: model.ID,
OrganizationID: model.OrganizationID,
OutletID: model.OutletID,
Name: model.Name,
Description: model.Description,
BusinessType: "restaurant",
BusinessType: "restaurant", // Default business type
Order: model.Order,
Metadata: metadata,
CreatedAt: model.CreatedAt,
@@ -57,15 +56,14 @@ func CreateCategoryRequestToEntity(req *models.CreateCategoryRequest) *entities.
if req.ImageURL != nil {
metadata["image_url"] = *req.ImageURL
}
// metadata["sort_order"] = req.SortOrder
return &entities.Category{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
ParentID: req.ParentID,
Name: req.Name,
Description: req.Description,
Order: req.Order,
BusinessType: "restaurant",
BusinessType: "restaurant", // Default business type
Metadata: metadata,
}
}
@@ -86,24 +84,14 @@ func CategoryEntityToResponse(entity *entities.Category) *models.CategoryRespons
}
}
// Parent name is only available when the Parent association is preloaded
var parentName *string
if entity.Parent != nil {
name := entity.Parent.Name
parentName = &name
}
return &models.CategoryResponse{
ID: entity.ID,
OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
ParentID: entity.ParentID,
ParentName: parentName,
Name: entity.Name,
Description: entity.Description,
ImageURL: imageURL,
Order: entity.Order,
IsActive: true,
IsActive: true, // Default to true since entity doesn't have this field
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
@@ -133,14 +121,6 @@ func UpdateCategoryEntityFromRequest(entity *entities.Category, req *models.Upda
if req.Order != nil {
entity.Order = *req.Order
}
if req.OutletID != nil {
entity.OutletID = req.OutletID
}
if req.ParentID != nil {
entity.ParentID = req.ParentID
}
}
func CategoryEntitiesToModels(entities []*entities.Category) []*models.Category {
-136
View File
@@ -1,136 +0,0 @@
package mappers
import (
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
)
func ExpenseEntityToModel(entity *entities.Expense) *models.Expense {
if entity == nil {
return nil
}
return &models.Expense{
ID: entity.ID,
OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
Receiver: entity.Receiver,
TransactionDate: entity.TransactionDate,
CodeNumber: entity.CodeNumber,
Status: entity.Status,
Description: entity.Description,
Tax: entity.Tax,
Total: entity.Total,
Reserved1: entity.Reserved1,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
}
func ExpenseModelToEntity(model *models.Expense) *entities.Expense {
if model == nil {
return nil
}
return &entities.Expense{
ID: model.ID,
OrganizationID: model.OrganizationID,
OutletID: model.OutletID,
Receiver: model.Receiver,
TransactionDate: model.TransactionDate,
CodeNumber: model.CodeNumber,
Status: model.Status,
Description: model.Description,
Tax: model.Tax,
Total: model.Total,
Reserved1: model.Reserved1,
CreatedAt: model.CreatedAt,
UpdatedAt: model.UpdatedAt,
}
}
func ExpenseEntityToResponse(entity *entities.Expense) *models.ExpenseResponse {
if entity == nil {
return nil
}
resp := &models.ExpenseResponse{
ID: entity.ID,
OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
Receiver: entity.Receiver,
TransactionDate: entity.TransactionDate,
CodeNumber: entity.CodeNumber,
Status: entity.Status,
Description: entity.Description,
Tax: entity.Tax,
Total: entity.Total,
Reserved1: entity.Reserved1,
CashAdvanceID: entity.CashAdvanceID,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
if entity.Items != nil {
resp.Items = ExpenseItemEntitiesToResponses(entity.Items)
}
return resp
}
func ExpenseEntitiesToResponses(entities []*entities.Expense) []*models.ExpenseResponse {
if entities == nil {
return nil
}
responses := make([]*models.ExpenseResponse, len(entities))
for i, entity := range entities {
responses[i] = ExpenseEntityToResponse(entity)
}
return responses
}
func ExpenseItemEntityToResponse(entity *entities.ExpenseItem) *models.ExpenseItemResponse {
if entity == nil {
return nil
}
response := &models.ExpenseItemResponse{
ID: entity.ID,
ExpenseID: entity.ExpenseID,
ChartOfAccountID: entity.ChartOfAccountID,
PurchaseCategoryID: entity.PurchaseCategoryID,
Item: entity.Item,
Description: entity.Description,
Amount: entity.Amount,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
if entity.ChartOfAccount != nil {
response.ChartOfAccountName = entity.ChartOfAccount.Name
}
if entity.PurchaseCategory != nil {
response.PurchaseCategoryName = entity.PurchaseCategory.Name
response.PurchaseCategoryType = string(entity.PurchaseCategory.Type)
response.PurchaseCategory = PurchaseCategoryEntityToResponse(entity.PurchaseCategory)
}
return response
}
func ExpenseItemEntitiesToResponses(entities []entities.ExpenseItem) []models.ExpenseItemResponse {
if entities == nil {
return nil
}
responses := make([]models.ExpenseItemResponse, len(entities))
for i, entity := range entities {
response := ExpenseItemEntityToResponse(&entity)
if response != nil {
responses[i] = *response
}
}
return responses
}
-85
View File
@@ -1,85 +0,0 @@
package mappers
import (
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
)
func NotificationEntityToResponse(e *entities.Notification) *models.NotificationResponse {
if e == nil {
return nil
}
return &models.NotificationResponse{
ID: e.ID,
Title: e.Title,
Body: e.Body,
Type: e.Type,
Category: e.Category,
Priority: e.Priority,
ImageURL: e.ImageURL,
ActionURL: e.ActionURL,
NotifiableType: e.NotifiableType,
NotifiableID: e.NotifiableID,
Data: e.Data,
ScheduledAt: e.ScheduledAt,
SentAt: e.SentAt,
ExpiredAt: e.ExpiredAt,
CreatedBy: e.CreatedBy,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
}
}
func NotificationReceiverEntityToResponse(e *entities.NotificationReceiver) *models.NotificationReceiverResponse {
if e == nil {
return nil
}
resp := &models.NotificationReceiverResponse{
ID: e.ID,
NotificationID: e.NotificationID,
UserID: e.UserID,
IsRead: e.IsRead,
ReadAt: e.ReadAt,
IsDeleted: e.IsDeleted,
DeletedAt: e.DeletedAt,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
}
if e.Notification != nil {
resp.Notification = NotificationEntityToResponse(e.Notification)
}
return resp
}
func NotificationReceiverEntitiesToResponses(entities []*entities.NotificationReceiver) []*models.NotificationReceiverResponse {
if entities == nil {
return nil
}
responses := make([]*models.NotificationReceiverResponse, len(entities))
for i, e := range entities {
responses[i] = NotificationReceiverEntityToResponse(e)
}
return responses
}
func NotificationDeliveryEntityToResponse(e *entities.NotificationDelivery) *models.NotificationDeliveryResponse {
if e == nil {
return nil
}
return &models.NotificationDeliveryResponse{
ID: e.ID,
NotificationReceiverID: e.NotificationReceiverID,
UserDeviceID: e.UserDeviceID,
Channel: e.Channel,
DeliveryStatus: e.DeliveryStatus,
Provider: e.Provider,
ProviderMessageID: e.ProviderMessageID,
SentAt: e.SentAt,
DeliveredAt: e.DeliveredAt,
FailedAt: e.FailedAt,
FailureReason: e.FailureReason,
RetryCount: e.RetryCount,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
}
}
+4 -22
View File
@@ -82,7 +82,7 @@ func OrderEntityToResponse(order *entities.Order) *models.OrderResponse {
}
for i, item := range order.OrderItems {
resp := OrderItemEntityToResponse(&item, order.OutletID)
resp := OrderItemEntityToResponse(&item)
if resp != nil {
resp.PaidQuantity = paidQtyByOrderItem[item.ID]
response.OrderItems[i] = *resp
@@ -101,20 +101,11 @@ func OrderEntityToResponse(order *entities.Order) *models.OrderResponse {
return response
}
func OrderItemEntityToResponse(item *entities.OrderItem, outletID uuid.UUID) *models.OrderItemResponse {
func OrderItemEntityToResponse(item *entities.OrderItem) *models.OrderItemResponse {
if item == nil {
return nil
}
// Resolve print_to_checker from preloaded outlet prices
printToChecker := true // default
for _, op := range item.Product.ProductOutletPrices {
if op.OutletID == outletID {
printToChecker = op.PrintToChecker
break
}
}
response := &models.OrderItemResponse{
ID: item.ID,
OrderID: item.OrderID,
@@ -139,19 +130,10 @@ func OrderItemEntityToResponse(item *entities.OrderItem, outletID uuid.UUID) *mo
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
PrinterType: item.Product.PrinterType,
PrintToChecker: printToChecker,
}
if item.Product.ID != uuid.Nil {
response.ProductName = item.Product.Name
if item.Product.CategoryID != uuid.Nil {
categoryID := item.Product.CategoryID
response.CategoryID = &categoryID
}
if item.Product.Category.ID != uuid.Nil {
categoryName := item.Product.Category.Name
response.CategoryName = &categoryName
}
}
if item.ProductVariant != nil {
@@ -334,14 +316,14 @@ func OrderEntitiesToResponses(orders []*entities.Order) []models.OrderResponse {
return responses
}
func OrderItemEntitiesToResponses(items []*entities.OrderItem, outletID uuid.UUID) []models.OrderItemResponse {
func OrderItemEntitiesToResponses(items []*entities.OrderItem) []models.OrderItemResponse {
if items == nil {
return nil
}
responses := make([]models.OrderItemResponse, len(items))
for i, item := range items {
response := OrderItemEntityToResponse(item, outletID)
response := OrderItemEntityToResponse(item)
if response != nil {
responses[i] = *response
}
+3 -3
View File
@@ -45,7 +45,7 @@ func TestOrderItemEntityToResponse_WithProductNames(t *testing.T) {
}
// Act
result := OrderItemEntityToResponse(orderItem, uuid.Nil)
result := OrderItemEntityToResponse(orderItem)
// Assert
assert.NotNil(t, result)
@@ -89,7 +89,7 @@ func TestOrderItemEntityToResponse_WithoutProductVariant(t *testing.T) {
}
// Act
result := OrderItemEntityToResponse(orderItem, uuid.Nil)
result := OrderItemEntityToResponse(orderItem)
// Assert
assert.NotNil(t, result)
@@ -129,7 +129,7 @@ func TestOrderItemEntityToResponse_WithoutProductPreload(t *testing.T) {
}
// Act
result := OrderItemEntityToResponse(orderItem, uuid.Nil)
result := OrderItemEntityToResponse(orderItem)
// Assert
assert.NotNil(t, result)
-1
View File
@@ -135,7 +135,6 @@ func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse {
Name: entity.Name,
Description: entity.Description,
Price: entity.Price,
OutletPrice: nil, // populated by processor when outletID is available
Cost: entity.Cost,
BusinessType: constants.BusinessType(entity.BusinessType),
ImageURL: entity.ImageURL,
@@ -1,50 +0,0 @@
package mappers
import (
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
)
func ProductOutletPriceEntityToModel(entity *entities.ProductOutletPrice) *models.ProductOutletPrice {
if entity == nil {
return nil
}
return &models.ProductOutletPrice{
ID: entity.ID,
ProductID: entity.ProductID,
OutletID: entity.OutletID,
Price: entity.Price,
PrintToChecker: entity.PrintToChecker,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
}
func ProductOutletPriceModelToEntity(model *models.ProductOutletPrice) *entities.ProductOutletPrice {
if model == nil {
return nil
}
return &entities.ProductOutletPrice{
ID: model.ID,
ProductID: model.ProductID,
OutletID: model.OutletID,
Price: model.Price,
PrintToChecker: model.PrintToChecker,
CreatedAt: model.CreatedAt,
UpdatedAt: model.UpdatedAt,
}
}
func ProductOutletPriceEntitiesToModels(entities []*entities.ProductOutletPrice) []*models.ProductOutletPrice {
if entities == nil {
return nil
}
models := make([]*models.ProductOutletPrice, len(entities))
for i, entity := range entities {
models[i] = ProductOutletPriceEntityToModel(entity)
}
return models
}
@@ -1,53 +0,0 @@
package mappers
import (
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
)
func CreatePurchaseCategoryRequestToEntity(req *models.CreatePurchaseCategoryRequest) *entities.PurchaseCategory {
if req == nil {
return nil
}
return &entities.PurchaseCategory{
OrganizationID: req.OrganizationID,
ParentID: req.ParentID,
Name: req.Name,
Type: entities.PurchaseCategoryType(req.Type),
SortOrder: req.SortOrder,
IsActive: req.IsActive,
}
}
func PurchaseCategoryEntityToResponse(entity *entities.PurchaseCategory) *models.PurchaseCategoryResponse {
if entity == nil {
return nil
}
return &models.PurchaseCategoryResponse{
ID: entity.ID,
OrganizationID: entity.OrganizationID,
PresetID: entity.PresetID,
ParentID: entity.ParentID,
Code: entity.Code,
Name: entity.Name,
Type: string(entity.Type),
SortOrder: entity.SortOrder,
IsSystem: entity.IsSystem,
IsActive: entity.IsActive,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
}
func PurchaseCategoryEntitiesToResponses(categoryEntities []*entities.PurchaseCategory) []models.PurchaseCategoryResponse {
responses := make([]models.PurchaseCategoryResponse, len(categoryEntities))
for i, entity := range categoryEntities {
response := PurchaseCategoryEntityToResponse(entity)
if response != nil {
responses[i] = *response
}
}
return responses
}
-43
View File
@@ -1,33 +1,10 @@
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
@@ -36,7 +13,6 @@ 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,
@@ -45,9 +21,6 @@ func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.Purchase
Status: entity.Status,
Message: entity.Message,
TotalAmount: entity.TotalAmount,
TeamScope: entity.TeamScope,
TeamCategoryID: entity.TeamCategoryID,
CashAdvanceID: entity.CashAdvanceID,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
@@ -61,7 +34,6 @@ 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,
@@ -70,9 +42,6 @@ func PurchaseOrderModelToEntity(model *models.PurchaseOrder) *entities.PurchaseO
Status: model.Status,
Message: model.Message,
TotalAmount: model.TotalAmount,
TeamScope: model.TeamScope,
TeamCategoryID: model.TeamCategoryID,
CashAdvanceID: model.CashAdvanceID,
CreatedAt: model.CreatedAt,
UpdatedAt: model.UpdatedAt,
}
@@ -86,7 +55,6 @@ 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,
@@ -95,12 +63,8 @@ func PurchaseOrderEntityToResponse(entity *entities.PurchaseOrder) *models.Purch
Status: entity.Status,
Message: entity.Message,
TotalAmount: entity.TotalAmount,
TeamScope: entity.TeamScope,
TeamCategoryID: entity.TeamCategoryID,
CashAdvanceID: entity.CashAdvanceID,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
Team: purchaseTeamFromEntity(entity),
}
// Map vendor if present
@@ -130,7 +94,6 @@ func PurchaseOrderItemEntityToModel(entity *entities.PurchaseOrderItem) *models.
ID: entity.ID,
PurchaseOrderID: entity.PurchaseOrderID,
IngredientID: entity.IngredientID,
PurchaseCategoryID: entity.PurchaseCategoryID,
Description: entity.Description,
Quantity: entity.Quantity,
UnitID: entity.UnitID,
@@ -149,7 +112,6 @@ func PurchaseOrderItemModelToEntity(model *models.PurchaseOrderItem) *entities.P
ID: model.ID,
PurchaseOrderID: model.PurchaseOrderID,
IngredientID: model.IngredientID,
PurchaseCategoryID: model.PurchaseCategoryID,
Description: model.Description,
Quantity: model.Quantity,
UnitID: model.UnitID,
@@ -168,7 +130,6 @@ func PurchaseOrderItemEntityToResponse(entity *entities.PurchaseOrderItem) *mode
ID: entity.ID,
PurchaseOrderID: entity.PurchaseOrderID,
IngredientID: entity.IngredientID,
PurchaseCategoryID: entity.PurchaseCategoryID,
Description: entity.Description,
Quantity: entity.Quantity,
UnitID: entity.UnitID,
@@ -185,10 +146,6 @@ func PurchaseOrderItemEntityToResponse(entity *entities.PurchaseOrderItem) *mode
}
}
if entity.PurchaseCategory != nil {
response.PurchaseCategory = PurchaseCategoryEntityToResponse(entity.PurchaseCategory)
}
// Map unit if present
if entity.Unit != nil {
response.Unit = &models.UnitResponse{
-62
View File
@@ -1,62 +0,0 @@
package mappers
import (
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
)
func UserDeviceEntityToModel(entity *entities.UserDevice) *models.UserDevice {
if entity == nil {
return nil
}
return &models.UserDevice{
ID: entity.ID,
UserID: entity.UserID,
DeviceID: entity.DeviceID,
DeviceName: entity.DeviceName,
DeviceType: entity.DeviceType,
Platform: entity.Platform,
FCMToken: entity.FCMToken,
AppVersion: entity.AppVersion,
OsVersion: entity.OsVersion,
IPAddress: entity.IPAddress,
LastActiveAt: entity.LastActiveAt,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
}
func UserDeviceEntityToResponse(entity *entities.UserDevice) *models.UserDeviceResponse {
if entity == nil {
return nil
}
return &models.UserDeviceResponse{
ID: entity.ID,
UserID: entity.UserID,
DeviceID: entity.DeviceID,
DeviceName: entity.DeviceName,
DeviceType: entity.DeviceType,
Platform: entity.Platform,
FCMToken: entity.FCMToken,
AppVersion: entity.AppVersion,
OsVersion: entity.OsVersion,
IPAddress: entity.IPAddress,
LastActiveAt: entity.LastActiveAt,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
}
func UserDeviceEntitiesToResponses(entities []*entities.UserDevice) []*models.UserDeviceResponse {
if entities == nil {
return nil
}
responses := make([]*models.UserDeviceResponse, len(entities))
for i, entity := range entities {
responses[i] = UserDeviceEntityToResponse(entity)
}
return responses
}
+3 -12
View File
@@ -11,7 +11,6 @@ import (
"apskel-pos-be/internal/service"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type AuthMiddleware struct {
@@ -46,13 +45,9 @@ func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
setKeyInContext(c, appcontext.OrganizationIDKey, userResponse.OrganizationID.String())
setKeyInContext(c, appcontext.UserIDKey, userResponse.ID.String())
// Always override OutletID from token to prevent header injection.
// Set empty string if user has no outlet, so PopulateContext header value is ignored.
outletIDStr := ""
if userResponse.OutletID != nil && *userResponse.OutletID != uuid.Nil {
outletIDStr = userResponse.OutletID.String()
if userResponse.Role != "superadmin" {
setKeyInContext(c, appcontext.OutletIDKey, userResponse.OutletID.String())
}
setKeyInContext(c, appcontext.OutletIDKey, outletIDStr)
logger.FromContext(c.Request.Context()).Infof("AuthMiddleware::RequireAuth -> User authenticated: %s", userResponse.Email)
c.Next()
@@ -82,11 +77,7 @@ func (m *AuthMiddleware) RequireRole(allowedRoles ...string) gin.HandlerFunc {
}
func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc {
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
}
func (m *AuthMiddleware) RequireAdminOrManagerOrPurchasing() gin.HandlerFunc {
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
return m.RequireRole("superadmin", "admin", "manager")
}
func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
-137
View File
@@ -1,137 +0,0 @@
package middleware
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
const (
IdempotencyKeyHeader = "X-Idempotency-Key"
idempotencyTTL = 24 * time.Hour
idempotencyPrefix = "idempotency:"
)
type cachedResponse struct {
StatusCode int `json:"status_code"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
}
// IdempotencyMiddleware returns a Gin middleware that ensures idempotent processing
// for mutating operations. Client must send X-Idempotency-Key header.
func IdempotencyMiddleware(redisClient *redis.Client) gin.HandlerFunc {
return func(c *gin.Context) {
key := c.GetHeader(IdempotencyKeyHeader)
if key == "" {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
"success": false,
"errors": []gin.H{
{
"code": "missing_idempotency_key",
"entity": "IdempotencyMiddleware",
"cause": "X-Idempotency-Key header is required",
},
},
})
return
}
redisKey := fmt.Sprintf("%s%s", idempotencyPrefix, key)
ctx := context.Background()
fmt.Printf("[DEBUG] IdempotencyMiddleware: key=%s redisKey=%s\n", key, redisKey)
// Check if key already exists (request was already processed)
cached, err := redisClient.Get(ctx, redisKey).Result()
if err == nil {
// Key exists — return cached response
fmt.Printf("[DEBUG] IdempotencyMiddleware: cache HIT for key=%s\n", key)
var resp cachedResponse
if err := json.Unmarshal([]byte(cached), &resp); err == nil {
for k, v := range resp.Headers {
c.Writer.Header().Set(k, v)
}
c.Writer.Header().Set("X-Idempotent-Replay", "true")
c.Data(resp.StatusCode, "application/json", []byte(resp.Body))
c.Abort()
return
}
} else {
fmt.Printf("[DEBUG] IdempotencyMiddleware: cache MISS for key=%s err=%v\n", key, err)
}
// Mark key as in-progress to prevent concurrent duplicates
set, err := redisClient.SetNX(ctx, redisKey, "processing", idempotencyTTL).Result()
if err != nil {
// Redis error — proceed without idempotency (fail open)
c.Next()
return
}
if !set {
// Another request with the same key is being processed
c.AbortWithStatusJSON(http.StatusConflict, gin.H{
"success": false,
"errors": []gin.H{
{
"code": "request_in_progress",
"entity": "IdempotencyMiddleware",
"cause": "A request with this idempotency key is already being processed",
},
},
})
return
}
// Capture response using a custom writer
writer := &responseCapture{
ResponseWriter: c.Writer,
body: &bytes.Buffer{},
}
c.Writer = writer
c.Next()
// After handler completes, cache the response only if successful (2xx)
statusCode := writer.Status()
if statusCode >= 200 && statusCode < 300 {
resp := cachedResponse{
StatusCode: statusCode,
Headers: map[string]string{
"Content-Type": writer.Header().Get("Content-Type"),
},
Body: writer.body.String(),
}
respJSON, err := json.Marshal(resp)
if err == nil {
redisClient.Set(ctx, redisKey, string(respJSON), idempotencyTTL)
}
} else {
// Remove the in-progress key so the client can retry with the same key
redisClient.Del(ctx, redisKey)
}
}
}
// responseCapture wraps gin.ResponseWriter to capture the response body
type responseCapture struct {
gin.ResponseWriter
body *bytes.Buffer
}
func (w *responseCapture) Write(b []byte) (int, error) {
w.body.Write(b)
return w.ResponseWriter.Write(b)
}
func (w *responseCapture) WriteString(s string) (int, error) {
w.body.WriteString(s)
return w.ResponseWriter.WriteString(s)
}
+5 -412
View File
@@ -1,12 +1,8 @@
package models
import (
"fmt"
"time"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
)
@@ -23,7 +19,6 @@ 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"`
@@ -63,7 +58,6 @@ 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"`
@@ -93,118 +87,6 @@ type SalesAnalyticsData struct {
NetSales float64 `json:"net_sales"`
}
// PurchasingAnalyticsRequest represents the request for purchasing analytics
type PurchasingAnalyticsRequest struct {
OrganizationID uuid.UUID `validate:"required"`
OutletID *uuid.UUID `validate:"omitempty"`
// 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
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"`
Summary PurchasingSummary `json:"summary"`
Data []PurchasingAnalyticsData `json:"data"`
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
VendorData []PurchasingVendorData `json:"vendor_data"`
TeamData []PurchasingTeamData `json:"team_data"`
}
// PurchasingTeamData represents purchasing analytics for a single team
type PurchasingTeamData struct {
Scope string `json:"scope"`
CategoryID *uuid.UUID `json:"category_id"`
Name string `json:"name"`
TotalPurchases float64 `json:"total_purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
Quantity float64 `json:"quantity"`
Percentage float64 `json:"percentage"`
}
// PurchasingSummary represents the summary of purchasing analytics
type PurchasingSummary struct {
TotalPurchases float64 `json:"total_purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
TotalPurchaseOrders int64 `json:"total_purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
TotalQuantity float64 `json:"total_quantity"`
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"`
TotalVendors int64 `json:"total_vendors"`
TotalTeams int64 `json:"total_teams"`
}
// PurchasingAnalyticsData represents purchasing analytics by time period
type PurchasingAnalyticsData struct {
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
}
// PurchasingIngredientData represents purchasing analytics for an ingredient
type PurchasingIngredientData struct {
IngredientID uuid.UUID `json:"ingredient_id"`
IngredientName string `json:"ingredient_name"`
Quantity float64 `json:"quantity"`
TotalCost float64 `json:"total_cost"`
AverageUnitCost float64 `json:"average_unit_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
}
// 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"`
}
// ProductAnalyticsRequest represents the request for product analytics
type ProductAnalyticsRequest struct {
OrganizationID uuid.UUID `validate:"required"`
@@ -218,7 +100,6 @@ 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"`
@@ -228,7 +109,6 @@ type ProductAnalyticsData struct {
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
ProductSku string `json:"product_sku"`
ProductPrice float64 `json:"product_price"`
CategoryID uuid.UUID `json:"category_id"`
CategoryName string `json:"category_name"`
CategoryOrder int `json:"category_order"`
@@ -256,7 +136,6 @@ 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"`
@@ -274,135 +153,6 @@ 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"`
@@ -415,7 +165,6 @@ 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"`
@@ -432,11 +181,9 @@ 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"`
}
// ProfitLossAnalyticsRequest represents the request for profit and loss analytics
type ProfitLossAnalyticsRequest struct {
OrganizationID uuid.UUID `validate:"required"`
OutletID *uuid.UUID `validate:"omitempty"`
@@ -445,39 +192,19 @@ type ProfitLossAnalyticsRequest struct {
GroupBy string `validate:"omitempty,oneof=day hour week month"`
}
// ProfitLossAnalyticsResponse represents the response for profit and loss analytics
type ProfitLossAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
Summary ProfitLossSummary `json:"summary"`
Data []ProfitLossData `json:"data"`
ProductData []ProductProfitData `json:"product_data"`
MainSummary []ProfitLossSummaryRow `json:"main_summary"`
Purchasing ProfitLossPurchasing `json:"purchasing"`
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
}
type ProfitLossPurchasing struct {
TodayTotal float64 `json:"today_total"`
MtdTotal float64 `json:"mtd_total"`
TodayRawMaterial float64 `json:"today_raw_material"`
MtdRawMaterial float64 `json:"mtd_raw_material"`
TodayExpense float64 `json:"today_expense"`
MtdExpense float64 `json:"mtd_expense"`
Items []ProfitLossPurchasingItem `json:"items"`
}
type ProfitLossPurchasingItem struct {
Date time.Time `json:"date"`
Item string `json:"item"`
Quantity float64 `json:"quantity"`
Nominal float64 `json:"nominal"`
}
// ProfitLossSummary represents the summary of profit and loss analytics
type ProfitLossSummary struct {
TotalRevenue float64 `json:"total_revenue"`
TotalCost float64 `json:"total_cost"`
@@ -492,6 +219,7 @@ type ProfitLossSummary struct {
ProfitabilityRatio float64 `json:"profitability_ratio"`
}
// ProfitLossData represents individual profit and loss data point by time period
type ProfitLossData struct {
Date time.Time `json:"date"`
Revenue float64 `json:"revenue"`
@@ -505,6 +233,7 @@ type ProfitLossData struct {
Orders int64 `json:"orders"`
}
// ProductProfitData represents profit data for individual products
type ProductProfitData struct {
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
@@ -519,139 +248,3 @@ type ProductProfitData struct {
AverageCost float64 `json:"average_cost"`
ProfitPerUnit float64 `json:"profit_per_unit"`
}
type ProfitLossSummaryRow struct {
ID string `json:"id"`
Label string `json:"label"`
IsBold bool `json:"is_bold"`
TodayNominal float64 `json:"today_nominal"`
TodayPct float64 `json:"today_pct"`
MtdNominal float64 `json:"mtd_nominal"`
MtdPct float64 `json:"mtd_pct"`
SubItems []ProfitLossSummaryRow `json:"sub_items,omitempty"`
}
type OperationalExpenseItem struct {
Item string `json:"item"`
Nominal float64 `json:"nominal"`
}
type ExclusiveSummaryPeriodRequest struct {
OrganizationID uuid.UUID `validate:"required"`
OutletID *uuid.UUID `validate:"omitempty"`
DateFrom time.Time `validate:"required"`
DateTo time.Time `validate:"required"`
ExcludeGajiStaffFromReimburse bool `validate:"omitempty"`
}
type ExclusiveSummaryMonthlyRequest struct {
OrganizationID uuid.UUID `validate:"required"`
OutletID *uuid.UUID `validate:"omitempty"`
Month time.Time `validate:"required"`
}
type ExclusiveSummaryMTDRequest struct {
OrganizationID uuid.UUID `validate:"required"`
OutletID *uuid.UUID `validate:"omitempty"`
DateTo time.Time `validate:"required"`
ExcludeGajiStaffFromReimburse bool `validate:"omitempty"`
}
type ExclusiveSummaryPeriodResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
Period ExclusiveSummaryPeriodRange `json:"period"`
Summary ExclusiveSummaryPeriodSummary `json:"summary"`
Reimburse ExclusiveSummaryReimburse `json:"reimburse"`
HPPBreakdown []ExclusiveSummaryCategoryBreakdown `json:"hpp_breakdown"`
OperationalExpenseBreakdown []ExclusiveSummaryCategoryBreakdown `json:"operational_expense_breakdown"`
DailySummary []ExclusiveSummaryDailySummary `json:"daily_summary"`
DailyTransactions []ExclusiveSummaryDailyTransaction `json:"daily_transactions"`
}
type ExclusiveSummaryPeriodRange struct {
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
}
type ExclusiveSummaryPeriodSummary struct {
Sales float64 `json:"sales"`
HPP float64 `json:"hpp"`
GrossProfit float64 `json:"gross_profit"`
SalaryTotal float64 `json:"salary_total"`
SalaryDW float64 `json:"salary_dw"`
SalaryStaff float64 `json:"salary_staff"`
SalaryOther float64 `json:"salary_other"`
OtherOperationalExpenses float64 `json:"other_operational_expenses"`
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
TotalCost float64 `json:"total_cost"`
NetProfit float64 `json:"net_profit"`
}
type ExclusiveSummaryReimburse struct {
TotalCost float64 `json:"total_cost"`
ExcludedSalaryStaff float64 `json:"excluded_salary_staff"`
TotalReimburse float64 `json:"total_reimburse"`
}
type ExclusiveSummaryCategoryBreakdown struct {
CategoryCode string `json:"category_code"`
CategoryName string `json:"category_name"`
Amount float64 `json:"amount"`
Percentage float64 `json:"percentage"`
}
type ExclusiveSummaryDailySummary struct {
Date time.Time `json:"date"`
TransactionCount int64 `json:"transaction_count"`
TotalCost float64 `json:"total_cost"`
}
type ExclusiveSummaryDailyTransaction struct {
Date time.Time `json:"date"`
CategoryCode string `json:"category_code"`
CategoryName string `json:"category_name"`
Description string `json:"description"`
Amount float64 `json:"amount"`
Source string `json:"source"`
}
type ExclusiveSummaryMonthlyResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
Month string `json:"month"`
Summary ExclusiveSummaryMonthlySummary `json:"summary"`
Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"`
BankBalance []ExclusiveSummaryBankBalance `json:"bank_balance"`
}
type ExclusiveSummaryMonthlySummary struct {
TotalSales float64 `json:"total_sales"`
HPP float64 `json:"hpp"`
GrossProfit float64 `json:"gross_profit"`
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
TotalCost float64 `json:"total_cost"`
NetProfit float64 `json:"net_profit"`
NetProfitMargin float64 `json:"net_profit_margin"`
}
type ExclusiveSummaryMonthlyPeriod struct {
Label string `json:"label"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
Sales float64 `json:"sales"`
HPP float64 `json:"hpp"`
GrossProfit float64 `json:"gross_profit"`
GrossMargin float64 `json:"gross_margin"`
}
type ExclusiveSummaryBankBalance struct {
Bank string `json:"bank"`
OpeningBalance *float64 `json:"opening_balance"`
IncomingMutation *float64 `json:"incoming_mutation"`
OutgoingMutation *float64 `json:"outgoing_mutation"`
ClosingBalance *float64 `json:"closing_balance"`
Notes *string `json:"notes"`
}
-104
View File
@@ -1,104 +0,0 @@
package models
import (
"time"
"github.com/google/uuid"
)
type CashAdvance struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID uuid.UUID `json:"outlet_id"`
CodeNumber string `json:"code_number"`
TeamScope string `json:"team_scope"`
TeamCategoryID *uuid.UUID `json:"team_category_id"`
Amount float64 `json:"amount"`
ReturnedAmount float64 `json:"returned_amount"`
IssuedDate time.Time `json:"issued_date"`
DueDate *time.Time `json:"due_date"`
Status string `json:"status"`
Description *string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CashAdvanceResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID uuid.UUID `json:"outlet_id"`
CodeNumber string `json:"code_number"`
TeamScope string `json:"team_scope"`
TeamCategoryID *uuid.UUID `json:"team_category_id"`
Amount float64 `json:"amount"`
// SettledAmount is the spending charged to this advance, ReturnedAmount the cash
// handed back, and RemainingAmount what is still out with the team. A negative
// remaining amount means the team overspent and is owed the difference.
SettledAmount float64 `json:"settled_amount"`
ReturnedAmount float64 `json:"returned_amount"`
RemainingAmount float64 `json:"remaining_amount"`
SettlementStatus string `json:"settlement_status"`
IssuedDate time.Time `json:"issued_date"`
DueDate *time.Time `json:"due_date"`
Status string `json:"status"`
Description *string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Team *PurchaseTeam `json:"team,omitempty"`
Settlements []CashAdvanceSettlement `json:"settlements,omitempty"`
}
// CashAdvanceSettlement is one purchase order or expense paid out of the advance.
type CashAdvanceSettlement struct {
Type string `json:"type"`
ID uuid.UUID `json:"id"`
Number string `json:"number"`
Date time.Time `json:"date"`
Amount float64 `json:"amount"`
Status string `json:"status"`
}
type CreateCashAdvanceRequest struct {
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
CodeNumber string `json:"code_number"`
TeamScope string `json:"team_scope"`
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"`
Amount float64 `json:"amount"`
IssuedDate time.Time `json:"issued_date"`
DueDate *time.Time `json:"due_date,omitempty"`
Status *string `json:"status,omitempty"`
Description *string `json:"description,omitempty"`
}
type UpdateCashAdvanceRequest struct {
CodeNumber *string `json:"code_number,omitempty"`
TeamScope *string `json:"team_scope,omitempty"`
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"`
Amount *float64 `json:"amount,omitempty"`
ReturnedAmount *float64 `json:"returned_amount,omitempty"`
IssuedDate *time.Time `json:"issued_date,omitempty"`
DueDate *time.Time `json:"due_date,omitempty"`
Status *string `json:"status,omitempty"`
Description *string `json:"description,omitempty"`
}
type ListCashAdvancesRequest struct {
Page int `json:"page"`
Limit int `json:"limit"`
Search string `json:"search,omitempty"`
Status string `json:"status,omitempty"`
SettlementStatus string `json:"settlement_status,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 ListCashAdvancesResponse struct {
CashAdvances []CashAdvanceResponse `json:"cash_advances"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}
-8
View File
@@ -9,7 +9,6 @@ import (
type Category struct {
ID uuid.UUID
OrganizationID uuid.UUID
OutletID *uuid.UUID
Name string
Description *string
ImageURL *string
@@ -21,8 +20,6 @@ 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,8 +30,6 @@ type UpdateCategoryRequest struct {
Name *string `validate:"omitempty,min=1,max=255"`
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
}
@@ -42,9 +37,6 @@ type UpdateCategoryRequest struct {
type CategoryResponse struct {
ID uuid.UUID
OrganizationID uuid.UUID
OutletID *uuid.UUID
ParentID *uuid.UUID
ParentName *string
Name string
Description *string
ImageURL *string
-193
View File
@@ -1,193 +0,0 @@
package models
import (
"time"
"github.com/google/uuid"
)
type Expense struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID uuid.UUID `json:"outlet_id"`
Receiver string `json:"receiver"`
TransactionDate time.Time `json:"transaction_date"`
CodeNumber string `json:"code_number"`
Status string `json:"status"`
Description *string `json:"description"`
Tax float64 `json:"tax"`
Total float64 `json:"total"`
Reserved1 *string `json:"reserved1"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ExpenseItem struct {
ID uuid.UUID `json:"id"`
ExpenseID uuid.UUID `json:"expense_id"`
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Item string `json:"item"`
Description *string `json:"description"`
Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ExpenseResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID uuid.UUID `json:"outlet_id"`
Receiver string `json:"receiver"`
TransactionDate time.Time `json:"transaction_date"`
CodeNumber string `json:"code_number"`
Status string `json:"status"`
Description *string `json:"description"`
Tax float64 `json:"tax"`
Total float64 `json:"total"`
Reserved1 *string `json:"reserved1"`
CashAdvanceID *uuid.UUID `json:"cash_advance_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Items []ExpenseItemResponse `json:"items,omitempty"`
}
type ExpenseItemResponse struct {
ID uuid.UUID `json:"id"`
ExpenseID uuid.UUID `json:"expense_id"`
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
ChartOfAccountName string `json:"chart_of_account_name,omitempty"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
PurchaseCategoryName string `json:"purchase_category_name,omitempty"`
PurchaseCategoryType string `json:"purchase_category_type,omitempty"`
PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
Item string `json:"item"`
Description *string `json:"description"`
Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateExpenseRequest struct {
Receiver string `json:"receiver"`
TransactionDate string `json:"transaction_date"`
CodeNumber string `json:"code_number"`
OutletID string `json:"outlet_id"`
Status *string `json:"status,omitempty"`
Description *string `json:"description"`
Tax float64 `json:"tax"`
Total float64 `json:"total"`
CashAdvanceID *string `json:"cash_advance_id,omitempty"`
Items []CreateExpenseItemRequest `json:"items"`
}
type CreateExpenseItemRequest struct {
ChartOfAccountID string `json:"chart_of_account_id"`
PurchaseCategoryID string `json:"purchase_category_id"`
Item string `json:"item"`
Description *string `json:"description,omitempty"`
Amount float64 `json:"amount"`
}
type UpdateExpenseRequest struct {
Receiver *string `json:"receiver,omitempty"`
TransactionDate *string `json:"transaction_date,omitempty"`
CodeNumber *string `json:"code_number,omitempty"`
OutletID *string `json:"outlet_id,omitempty"`
Status *string `json:"status,omitempty"`
Description *string `json:"description,omitempty"`
Tax *float64 `json:"tax,omitempty"`
Total *float64 `json:"total,omitempty"`
Reserved1 *string `json:"reserved1,omitempty"`
CashAdvanceID *string `json:"cash_advance_id,omitempty"`
Items []UpdateExpenseItemRequest `json:"items,omitempty"`
}
type UpdateExpenseItemRequest struct {
ChartOfAccountID *string `json:"chart_of_account_id,omitempty"`
PurchaseCategoryID *string `json:"purchase_category_id,omitempty"`
Item *string `json:"item,omitempty"`
Description *string `json:"description,omitempty"`
Amount *float64 `json:"amount,omitempty"`
}
type ListExpenseRequest struct {
Page int `json:"page"`
Limit int `json:"limit"`
Search string `json:"search,omitempty"`
OutletID string `json:"outlet_id,omitempty"`
Status string `json:"status,omitempty"`
StartDate string `json:"start_date,omitempty"`
EndDate string `json:"end_date,omitempty"`
}
type ListExpenseResponse struct {
Expenses []*ExpenseResponse `json:"expenses"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}
type ExpenseAnalyticsRequest struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
}
type ExpenseAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
Summary ExpenseAnalyticsSummary `json:"summary"`
Data []ExpenseAnalyticsData `json:"data"`
CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"`
ChartOfAccountData []ExpenseAnalyticsChartOfAccountData `json:"chart_of_account_data"`
ItemData []ExpenseAnalyticsItemData `json:"item_data"`
}
type ExpenseAnalyticsSummary struct {
TotalExpenses float64 `json:"total_expenses"`
TotalExpenseCount int64 `json:"total_expense_count"`
TotalTax float64 `json:"total_tax"`
AverageExpenseValue float64 `json:"average_expense_value"`
TotalCategories int64 `json:"total_categories"`
TotalItems int64 `json:"total_items"`
}
type ExpenseAnalyticsData struct {
Date time.Time `json:"date"`
Expenses float64 `json:"expenses"`
ExpenseCount int64 `json:"expense_count"`
Tax float64 `json:"tax"`
Items int64 `json:"items"`
Categories int64 `json:"categories"`
}
type ExpenseAnalyticsCategoryData struct {
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
PurchaseCategoryName string `json:"purchase_category_name"`
PurchaseCategoryType string `json:"purchase_category_type"`
TotalAmount float64 `json:"total_amount"`
ExpenseCount int64 `json:"expense_count"`
ItemCount int64 `json:"item_count"`
}
type ExpenseAnalyticsChartOfAccountData struct {
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
ChartOfAccountName string `json:"chart_of_account_name"`
TotalAmount float64 `json:"total_amount"`
ExpenseCount int64 `json:"expense_count"`
ItemCount int64 `json:"item_count"`
}
type ExpenseAnalyticsItemData struct {
Item string `json:"item"`
TotalAmount float64 `json:"total_amount"`
ExpenseCount int64 `json:"expense_count"`
ItemCount int64 `json:"item_count"`
}
+4 -4
View File
@@ -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:"omitempty"`
UnitID uuid.UUID `json:"unit_id" validate:"required"`
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:"omitempty"`
UnitID uuid.UUID `json:"unit_id" validate:"required"`
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"`
+2 -1
View File
@@ -97,7 +97,8 @@ 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"`
}
-118
View File
@@ -1,118 +0,0 @@
package models
import (
"time"
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
)
// ---- Request models ----
type SendNotificationRequest struct {
Title string `json:"title"`
Body string `json:"body"`
Type string `json:"type"`
Category string `json:"category"`
Priority entities.NotificationPriority `json:"priority"`
ImageURL string `json:"image_url"`
ActionURL string `json:"action_url"`
NotifiableType string `json:"notifiable_type"`
NotifiableID *uuid.UUID `json:"notifiable_id"`
Data map[string]interface{} `json:"data"`
ReceiverIDs []uuid.UUID `json:"receiver_ids"`
ScheduledAt *time.Time `json:"scheduled_at"`
ExpiredAt *time.Time `json:"expired_at"`
CreatedBy *uuid.UUID `json:"created_by"`
}
type BroadcastNotificationRequest struct {
Title string `json:"title"`
Body string `json:"body"`
Type string `json:"type"`
Category string `json:"category"`
Priority entities.NotificationPriority `json:"priority"`
ImageURL string `json:"image_url"`
ActionURL string `json:"action_url"`
NotifiableType string `json:"notifiable_type"`
NotifiableID *uuid.UUID `json:"notifiable_id"`
Data map[string]interface{} `json:"data"`
OrganizationID uuid.UUID `json:"organization_id"`
ScheduledAt *time.Time `json:"scheduled_at"`
ExpiredAt *time.Time `json:"expired_at"`
CreatedBy *uuid.UUID `json:"created_by"`
}
type MarkNotificationReadRequest struct {
NotificationReceiverID uuid.UUID `json:"notification_receiver_id"`
UserID uuid.UUID `json:"user_id"`
}
type ListNotificationsRequest struct {
Page int `json:"page"`
Limit int `json:"limit"`
UserID uuid.UUID `json:"user_id"`
IsRead *bool `json:"is_read"`
}
// ---- Response models ----
type NotificationResponse struct {
ID uuid.UUID `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Type string `json:"type"`
Category string `json:"category"`
Priority entities.NotificationPriority `json:"priority"`
ImageURL string `json:"image_url"`
ActionURL string `json:"action_url"`
NotifiableType string `json:"notifiable_type"`
NotifiableID *uuid.UUID `json:"notifiable_id"`
Data map[string]interface{} `json:"data"`
ScheduledAt *time.Time `json:"scheduled_at"`
SentAt *time.Time `json:"sent_at"`
ExpiredAt *time.Time `json:"expired_at"`
CreatedBy *uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type NotificationReceiverResponse struct {
ID uuid.UUID `json:"id"`
NotificationID uuid.UUID `json:"notification_id"`
UserID uuid.UUID `json:"user_id"`
IsRead bool `json:"is_read"`
ReadAt *time.Time `json:"read_at"`
IsDeleted bool `json:"is_deleted"`
DeletedAt *time.Time `json:"deleted_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Notification *NotificationResponse `json:"notification,omitempty"`
}
type NotificationDeliveryResponse struct {
ID uuid.UUID `json:"id"`
NotificationReceiverID uuid.UUID `json:"notification_receiver_id"`
UserDeviceID uuid.UUID `json:"user_device_id"`
Channel entities.NotificationChannel `json:"channel"`
DeliveryStatus entities.NotificationDeliveryStatus `json:"delivery_status"`
Provider entities.NotificationProvider `json:"provider"`
ProviderMessageID string `json:"provider_message_id"`
SentAt *time.Time `json:"sent_at"`
DeliveredAt *time.Time `json:"delivered_at"`
FailedAt *time.Time `json:"failed_at"`
FailureReason string `json:"failure_reason"`
RetryCount int `json:"retry_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListNotificationsResponse struct {
Notifications []*NotificationReceiverResponse `json:"notifications"`
TotalCount int `json:"total_count"`
UnreadCount int `json:"unread_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}
-3
View File
@@ -188,8 +188,6 @@ type OrderItemResponse struct {
ProductName string
ProductVariantID *uuid.UUID
ProductVariantName *string
CategoryID *uuid.UUID
CategoryName *string
Quantity int
UnitPrice float64
TotalPrice float64
@@ -209,7 +207,6 @@ type OrderItemResponse struct {
CreatedAt time.Time
UpdatedAt time.Time
PrinterType string
PrintToChecker bool
PaidQuantity int
}
-14
View File
@@ -40,7 +40,6 @@ type ProductVariant struct {
type CreateProductRequest struct {
OrganizationID uuid.UUID `validate:"required"`
OutletID uuid.UUID `validate:"omitempty"` // If set, upsert product_outlet_prices on create
CategoryID uuid.UUID `validate:"required"`
SKU *string `validate:"omitempty,max=100"`
Name string `validate:"required,min=1,max=255"`
@@ -50,7 +49,6 @@ type CreateProductRequest struct {
BusinessType constants.BusinessType `validate:"required"`
ImageURL *string `validate:"omitempty,max=500"`
PrinterType *string `validate:"omitempty,max=50"`
PrintToChecker *bool `validate:"omitempty"`
UnitID *uuid.UUID `validate:"omitempty"`
HasIngredients bool `validate:"omitempty"`
Metadata map[string]interface{}
@@ -62,7 +60,6 @@ type CreateProductRequest struct {
}
type UpdateProductRequest struct {
OutletID uuid.UUID `validate:"omitempty"` // If set, upsert product_outlet_prices on update
CategoryID *uuid.UUID `validate:"omitempty"`
SKU *string `validate:"omitempty,max=100"`
Name *string `validate:"omitempty,min=1,max=255"`
@@ -71,7 +68,6 @@ type UpdateProductRequest struct {
Cost *float64 `validate:"omitempty,min=0"`
ImageURL *string `validate:"omitempty,max=500"`
PrinterType *string `validate:"omitempty,max=50"`
PrintToChecker *bool `validate:"omitempty"`
UnitID *uuid.UUID `validate:"omitempty"`
HasIngredients *bool `validate:"omitempty"`
Metadata map[string]interface{}
@@ -104,13 +100,10 @@ type ProductResponse struct {
Name string
Description *string
Price float64
OutletPrice *float64 // outlet-specific price, nil if not set
OutletPrices []OutletPrice // all outlet prices, populated when no outletID in context
Cost float64
BusinessType constants.BusinessType
ImageURL *string
PrinterType string
PrintToChecker bool
UnitID *uuid.UUID
HasIngredients bool
Metadata map[string]interface{}
@@ -120,13 +113,6 @@ type ProductResponse struct {
Variants []ProductVariantResponse
}
type OutletPrice struct {
OutletID uuid.UUID
OutletName string
Price float64
PrintToChecker bool
}
type ProductVariantResponse struct {
ID uuid.UUID
ProductID uuid.UUID
-38
View File
@@ -1,38 +0,0 @@
package models
import (
"time"
"github.com/google/uuid"
)
type ProductOutletPrice struct {
ID uuid.UUID
ProductID uuid.UUID
OutletID uuid.UUID
Price float64
PrintToChecker bool
CreatedAt time.Time
UpdatedAt time.Time
}
type CreateProductOutletPriceRequest struct {
ProductID uuid.UUID `validate:"required"`
OutletID uuid.UUID `validate:"required"`
Price float64 `validate:"required,min=0"`
PrintToChecker bool
}
type UpdateProductOutletPriceRequest struct {
Price *float64 `validate:"required,min=0"`
PrintToChecker *bool
}
type ProductOutletPriceResponse struct {
ID uuid.UUID `json:"id"`
ProductID uuid.UUID `json:"product_id"`
OutletID uuid.UUID `json:"outlet_id"`
Price float64 `json:"price"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
-51
View File
@@ -1,51 +0,0 @@
package models
import (
"time"
"github.com/google/uuid"
)
type PurchaseCategoryResponse struct {
ID uuid.UUID
OrganizationID uuid.UUID
PresetID *uuid.UUID
ParentID *uuid.UUID
Code string
Name string
Type string
SortOrder int
IsSystem bool
IsActive bool
CreatedAt time.Time
UpdatedAt time.Time
}
type CreatePurchaseCategoryRequest struct {
OrganizationID uuid.UUID
ParentID *uuid.UUID
Code *string
Name string
Type string
SortOrder int
IsActive bool
}
type UpdatePurchaseCategoryRequest struct {
ParentID *uuid.UUID
Code *string
Name *string
Type *string
SortOrder *int
IsActive *bool
}
type ListPurchaseCategoriesRequest struct {
OrganizationID uuid.UUID
ParentID *uuid.UUID
Type string
Search string
IsActive *bool
Page int
Limit int
}
+15 -51
View File
@@ -9,38 +9,25 @@ import (
type PurchaseOrder struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
VendorID *uuid.UUID `json:"vendor_id"`
VendorID uuid.UUID `json:"vendor_id"`
PONumber string `json:"po_number"`
TransactionDate time.Time `json:"transaction_date"`
DueDate *time.Time `json:"due_date"`
DueDate time.Time `json:"due_date"`
Reference *string `json:"reference"`
Status string `json:"status"`
Message *string `json:"message"`
TotalAmount float64 `json:"total_amount"`
TeamScope *string `json:"team_scope"`
TeamCategoryID *uuid.UUID `json:"team_category_id"`
CashAdvanceID *uuid.UUID `json:"cash_advance_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// PurchaseTeam is one entry of the team picker: either a parent category or Pusat.
// Pusat carries no CategoryID because it has no category of its own.
type PurchaseTeam struct {
Scope string `json:"scope"`
CategoryID *uuid.UUID `json:"category_id"`
Name string `json:"name"`
}
type PurchaseOrderItem struct {
ID uuid.UUID `json:"id"`
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
IngredientID *uuid.UUID `json:"ingredient_id"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
IngredientID uuid.UUID `json:"ingredient_id"`
Description *string `json:"description"`
Quantity *float64 `json:"quantity"`
UnitID *uuid.UUID `json:"unit_id"`
Quantity float64 `json:"quantity"`
UnitID uuid.UUID `json:"unit_id"`
Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -56,21 +43,16 @@ type PurchaseOrderAttachment struct {
type PurchaseOrderResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
VendorID *uuid.UUID `json:"vendor_id"`
VendorID uuid.UUID `json:"vendor_id"`
PONumber string `json:"po_number"`
TransactionDate time.Time `json:"transaction_date"`
DueDate *time.Time `json:"due_date"`
DueDate time.Time `json:"due_date"`
Reference *string `json:"reference"`
Status string `json:"status"`
Message *string `json:"message"`
TotalAmount float64 `json:"total_amount"`
TeamScope *string `json:"team_scope"`
TeamCategoryID *uuid.UUID `json:"team_category_id"`
CashAdvanceID *uuid.UUID `json:"cash_advance_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"`
@@ -79,16 +61,14 @@ type PurchaseOrderResponse struct {
type PurchaseOrderItemResponse struct {
ID uuid.UUID `json:"id"`
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
IngredientID *uuid.UUID `json:"ingredient_id"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
IngredientID uuid.UUID `json:"ingredient_id"`
Description *string `json:"description"`
Quantity *float64 `json:"quantity"`
UnitID *uuid.UUID `json:"unit_id"`
Quantity float64 `json:"quantity"`
UnitID uuid.UUID `json:"unit_id"`
Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Ingredient *IngredientResponse `json:"ingredient,omitempty"`
PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
Unit *UnitResponse `json:"unit,omitempty"`
}
@@ -101,27 +81,22 @@ type PurchaseOrderAttachmentResponse struct {
}
type CreatePurchaseOrderRequest struct {
VendorID *uuid.UUID `json:"vendor_id,omitempty"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
VendorID uuid.UUID `json:"vendor_id"`
PONumber string `json:"po_number"`
TransactionDate time.Time `json:"transaction_date"`
DueDate *time.Time `json:"due_date,omitempty"`
DueDate time.Time `json:"due_date"`
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"`
CashAdvanceID *uuid.UUID `json:"cash_advance_id,omitempty"`
Items []CreatePurchaseOrderItemRequest `json:"items"`
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
}
type CreatePurchaseOrderItemRequest struct {
IngredientID *uuid.UUID `json:"ingredient_id,omitempty"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
IngredientID uuid.UUID `json:"ingredient_id"`
Description *string `json:"description,omitempty"`
Quantity *float64 `json:"quantity,omitempty"`
UnitID *uuid.UUID `json:"unit_id,omitempty"`
Quantity float64 `json:"quantity"`
UnitID uuid.UUID `json:"unit_id"`
Amount float64 `json:"amount"`
}
@@ -133,9 +108,6 @@ 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"`
CashAdvanceID *uuid.UUID `json:"cash_advance_id,omitempty"`
Items []UpdatePurchaseOrderItemRequest `json:"items,omitempty"`
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
}
@@ -143,7 +115,6 @@ type UpdatePurchaseOrderRequest struct {
type UpdatePurchaseOrderItemRequest struct {
ID *uuid.UUID `json:"id,omitempty"` // For existing items
IngredientID *uuid.UUID `json:"ingredient_id,omitempty"`
PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty"`
Description *string `json:"description,omitempty"`
Quantity *float64 `json:"quantity,omitempty"`
UnitID *uuid.UUID `json:"unit_id,omitempty"`
@@ -156,17 +127,10 @@ type ListPurchaseOrdersRequest struct {
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 {
PurchaseOrders []PurchaseOrderResponse `json:"purchase_orders"`
TotalCount int `json:"total_count"`
+2 -4
View File
@@ -65,10 +65,8 @@ func (u *User) HasPermission(requiredRole constants.UserRole) bool {
roleHierarchy := map[constants.UserRole]int{
constants.RoleWaiter: 1,
constants.RoleCashier: 2,
constants.RolePurchasing: 3,
constants.RoleManager: 4,
constants.RoleAdmin: 5,
constants.RoleOwner: 6,
constants.RoleManager: 3,
constants.RoleAdmin: 4,
}
userLevel := roleHierarchy[u.Role]
-77
View File
@@ -1,77 +0,0 @@
package models
import (
"time"
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
)
type UserDevice struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
DeviceType entities.DeviceType `json:"device_type"`
Platform entities.DevicePlatform `json:"platform"`
FCMToken string `json:"fcm_token"`
AppVersion string `json:"app_version"`
OsVersion string `json:"os_version"`
IPAddress string `json:"ip_address"`
LastActiveAt *time.Time `json:"last_active_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type UserDeviceResponse struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
DeviceType entities.DeviceType `json:"device_type"`
Platform entities.DevicePlatform `json:"platform"`
FCMToken string `json:"fcm_token"`
AppVersion string `json:"app_version"`
OsVersion string `json:"os_version"`
IPAddress string `json:"ip_address"`
LastActiveAt *time.Time `json:"last_active_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type RegisterUserDeviceRequest struct {
UserID uuid.UUID `json:"user_id"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
DeviceType entities.DeviceType `json:"device_type"`
Platform entities.DevicePlatform `json:"platform"`
FCMToken string `json:"fcm_token"`
AppVersion string `json:"app_version"`
OsVersion string `json:"os_version"`
IPAddress string `json:"ip_address"`
}
type UpdateUserDeviceRequest struct {
DeviceName string `json:"device_name"`
DeviceType entities.DeviceType `json:"device_type"`
Platform entities.DevicePlatform `json:"platform"`
FCMToken string `json:"fcm_token"`
AppVersion string `json:"app_version"`
OsVersion string `json:"os_version"`
}
type ListUserDevicesRequest struct {
Page int `json:"page"`
Limit int `json:"limit"`
UserID string `json:"user_id,omitempty"`
Platform string `json:"platform,omitempty"`
}
type ListUserDevicesResponse struct {
Devices []*UserDeviceResponse `json:"devices"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}
+3 -849
View File
@@ -3,56 +3,31 @@ package processor
import (
"context"
"fmt"
"strings"
"time"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
"github.com/google/uuid"
)
type AnalyticsProcessor interface {
GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error)
GetSalesAnalytics(ctx context.Context, req *models.SalesAnalyticsRequest) (*models.SalesAnalyticsResponse, error)
GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error)
GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error)
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error)
GetProductAnalyticsPerParentCategory(ctx context.Context, req *models.ProductAnalyticsPerParentCategoryRequest) (*models.ProductAnalyticsPerParentCategoryResponse, error)
GetParentCategoryAnalyticsDetail(ctx context.Context, req *models.ParentCategoryAnalyticsDetailRequest) (*models.ParentCategoryAnalyticsDetailResponse, error)
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error)
GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error)
GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error)
GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error)
}
type AnalyticsProcessorImpl struct {
analyticsRepo repository.AnalyticsRepository
expenseRepo ExpenseRepository
}
func NewAnalyticsProcessorImpl(analyticsRepo repository.AnalyticsRepository, expenseRepo ExpenseRepository) *AnalyticsProcessorImpl {
func NewAnalyticsProcessorImpl(analyticsRepo repository.AnalyticsRepository) *AnalyticsProcessorImpl {
return &AnalyticsProcessorImpl{
analyticsRepo: analyticsRepo,
expenseRepo: expenseRepo,
}
}
// resolveOutletName fetches the outlet name from the database if outletID is provided
func (p *AnalyticsProcessorImpl) resolveOutletName(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) *string {
if outletID == nil {
return nil
}
name, err := p.analyticsRepo.GetOutletName(ctx, organizationID, *outletID)
if err != nil || name == "" {
return nil
}
return &name
}
func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) {
if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to")
@@ -107,7 +82,6 @@ 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,
@@ -182,7 +156,6 @@ 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,
@@ -191,108 +164,6 @@ func (p *AnalyticsProcessorImpl) GetSalesAnalytics(ctx context.Context, req *mod
}, nil
}
func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error) {
if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to")
}
if req.GroupBy == "" {
req.GroupBy = "day"
}
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)
}
data := make([]models.PurchasingAnalyticsData, len(result.Data))
for i, item := range result.Data {
data[i] = models.PurchasingAnalyticsData{
Date: item.Date,
Purchases: item.Purchases,
RawMaterialPurchases: item.RawMaterialPurchases,
ExpensePurchases: item.ExpensePurchases,
PurchaseOrders: item.PurchaseOrders,
RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders,
ExpenseCount: item.ExpenseCount,
Quantity: item.Quantity,
Ingredients: item.Ingredients,
Vendors: item.Vendors,
}
}
ingredientData := make([]models.PurchasingIngredientData, len(result.IngredientData))
for i, item := range result.IngredientData {
ingredientData[i] = models.PurchasingIngredientData{
IngredientID: item.IngredientID,
IngredientName: item.IngredientName,
Quantity: item.Quantity,
TotalCost: item.TotalCost,
AverageUnitCost: item.AverageUnitCost,
PurchaseOrderCount: item.PurchaseOrderCount,
}
}
vendorData := make([]models.PurchasingVendorData, len(result.VendorData))
for i, item := range result.VendorData {
vendorData[i] = models.PurchasingVendorData{
VendorID: item.VendorID,
VendorName: item.VendorName,
TotalCost: item.TotalCost,
PurchaseOrderCount: item.PurchaseOrderCount,
IngredientCount: item.IngredientCount,
Quantity: item.Quantity,
}
}
teamData := make([]models.PurchasingTeamData, len(result.TeamData))
for i, item := range result.TeamData {
teamData[i] = models.PurchasingTeamData{
Scope: item.Scope,
CategoryID: item.CategoryID,
Name: item.Name,
TotalPurchases: item.TotalPurchases,
RawMaterialPurchases: item.RawMaterialPurchases,
ExpensePurchases: item.ExpensePurchases,
PurchaseOrderCount: item.PurchaseOrderCount,
Quantity: item.Quantity,
Percentage: item.Percentage,
}
}
return &models.PurchasingAnalyticsResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: result.OutletName,
Team: req.Team,
DateFrom: req.DateFrom,
DateTo: req.DateTo,
GroupBy: req.GroupBy,
Summary: models.PurchasingSummary{
TotalPurchases: result.Summary.TotalPurchases,
RawMaterialPurchases: result.Summary.RawMaterialPurchases,
ExpensePurchases: result.Summary.ExpensePurchases,
TotalPurchaseOrders: result.Summary.TotalPurchaseOrders,
RawMaterialPurchaseOrders: result.Summary.RawMaterialPurchaseOrders,
ExpenseCount: result.Summary.ExpenseCount,
TotalQuantity: result.Summary.TotalQuantity,
AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue,
TotalIngredients: result.Summary.TotalIngredients,
TotalVendors: result.Summary.TotalVendors,
TotalTeams: result.Summary.TotalTeams,
},
Data: data,
IngredientData: ingredientData,
VendorData: vendorData,
TeamData: teamData,
}, nil
}
func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error) {
// Validate date range
if req.DateFrom.After(req.DateTo) {
@@ -317,7 +188,6 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m
ProductID: data.ProductID,
ProductName: data.ProductName,
ProductSku: data.ProductSku,
ProductPrice: data.ProductPrice,
CategoryID: data.CategoryID,
CategoryName: data.CategoryName,
CategoryOrder: data.CategoryOrder,
@@ -337,7 +207,6 @@ 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,
@@ -375,249 +244,12 @@ 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) {
@@ -673,7 +305,6 @@ 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{
@@ -683,9 +314,6 @@ func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req
TotalCustomers: overview.TotalCustomers,
VoidedOrders: overview.VoidedOrders,
RefundedOrders: overview.RefundedOrders,
TotalItemSold: overview.TotalItemSold,
TotalLowStock: overview.TotalLowStock,
TotalProductActive: overview.TotalProductActive,
},
TopProducts: topProducts.Data,
PaymentMethods: paymentMethods.Data,
@@ -694,27 +322,17 @@ func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req
}
func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) {
if req.DateFrom.IsZero() {
return nil, fmt.Errorf("date_from is required")
}
if req.DateTo.IsZero() {
return nil, fmt.Errorf("date_to is required")
}
if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to")
}
if req.GroupBy == "" {
req.GroupBy = "day"
}
// Get analytics data from repository
result, err := p.analyticsRepo.GetProfitLossAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy)
if err != nil {
return nil, fmt.Errorf("failed to get profit/loss analytics: %w", err)
}
// Transform entities to models
data := make([]models.ProfitLossData, len(result.Data))
for i, item := range result.Data {
data[i] = models.ProfitLossData{
@@ -749,159 +367,9 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
}
}
type categoryAmount struct {
Name string
TodayAmt float64
MtdAmt float64
}
categoryMap := make(map[string]*categoryAmount)
var categoryOrder []string
for _, cat := range result.TodayExpenseByCategory {
name := cat.CategoryName
if _, exists := categoryMap[name]; !exists {
categoryMap[name] = &categoryAmount{Name: name}
categoryOrder = append(categoryOrder, name)
}
categoryMap[name].TodayAmt = cat.Amount
}
for _, cat := range result.MtdExpenseByCategory {
name := cat.CategoryName
if _, exists := categoryMap[name]; !exists {
categoryMap[name] = &categoryAmount{Name: name}
categoryOrder = append(categoryOrder, name)
}
categoryMap[name].MtdAmt = cat.Amount
}
var todayTotalOps float64
var mtdTotalOps float64
var todayGaji float64
var mtdGaji float64
for _, cat := range categoryMap {
if isSalaryExpenseCategory(cat.Name) {
todayGaji += cat.TodayAmt
mtdGaji += cat.MtdAmt
continue
}
todayTotalOps += cat.TodayAmt
mtdTotalOps += cat.MtdAmt
}
todayGrossProfit := result.TodayRevenue - result.TodayCost
mtdGrossProfit := result.MtdRevenue - result.MtdCost
todayProfitBeforeGaji := todayGrossProfit - todayTotalOps
mtdProfitBeforeGaji := mtdGrossProfit - mtdTotalOps
todayNetProfit := todayProfitBeforeGaji - todayGaji
mtdNetProfit := mtdProfitBeforeGaji - mtdGaji
todayPct := func(nominal float64) float64 {
if result.TodayRevenue == 0 {
return 0
}
return (nominal / result.TodayRevenue) * 100
}
mtdPct := func(nominal float64) float64 {
if result.MtdRevenue == 0 {
return 0
}
return (nominal / result.MtdRevenue) * 100
}
opsSubItems := make([]models.ProfitLossSummaryRow, 0, len(categoryOrder)+1)
opsCategoryCount := 0
for _, name := range categoryOrder {
cat := categoryMap[name]
if isSalaryExpenseCategory(cat.Name) {
continue
}
opsCategoryCount++
opsSubItems = append(opsSubItems, models.ProfitLossSummaryRow{
ID: fmt.Sprintf("by_%s", slugify(name)),
Label: fmt.Sprintf("%d. %s", opsCategoryCount, cat.Name),
TodayNominal: cat.TodayAmt,
TodayPct: todayPct(cat.TodayAmt),
MtdNominal: cat.MtdAmt,
MtdPct: mtdPct(cat.MtdAmt),
})
}
opsSubItems = append(opsSubItems, models.ProfitLossSummaryRow{
ID: "total_biaya_ops",
Label: fmt.Sprintf("Total Biaya OPS (%d kategori)", opsCategoryCount),
IsBold: true,
TodayNominal: todayTotalOps,
TodayPct: todayPct(todayTotalOps),
MtdNominal: mtdTotalOps,
MtdPct: mtdPct(mtdTotalOps),
})
mainSummary := []models.ProfitLossSummaryRow{
{
ID: "total_omset", Label: "TOTAL OMSET",
TodayNominal: result.TodayRevenue, TodayPct: todayPct(result.TodayRevenue),
MtdNominal: result.MtdRevenue, MtdPct: mtdPct(result.MtdRevenue),
},
{
ID: "hpp", Label: "HPP",
TodayNominal: result.TodayCost, TodayPct: todayPct(result.TodayCost),
MtdNominal: result.MtdCost, MtdPct: mtdPct(result.MtdCost),
},
{
ID: "laba_kotor", Label: "Laba Kotor (1-2)",
TodayNominal: todayGrossProfit, TodayPct: todayPct(todayGrossProfit),
MtdNominal: mtdGrossProfit, MtdPct: mtdPct(mtdGrossProfit),
},
{
ID: "biaya_ops", Label: "BIAYA OPS",
TodayNominal: todayTotalOps, TodayPct: todayPct(todayTotalOps),
MtdNominal: mtdTotalOps, MtdPct: mtdPct(mtdTotalOps),
SubItems: opsSubItems,
},
{
ID: "laba_rugi_sblm_gaji", Label: "Laba/Rugi sblm Gaji (3-4)",
TodayNominal: todayProfitBeforeGaji, TodayPct: todayPct(todayProfitBeforeGaji),
MtdNominal: mtdProfitBeforeGaji, MtdPct: mtdPct(mtdProfitBeforeGaji),
},
{
ID: "biaya_gaji", Label: "BIAYA GAJI",
TodayNominal: todayGaji, TodayPct: todayPct(todayGaji),
MtdNominal: mtdGaji, MtdPct: mtdPct(mtdGaji),
},
{
ID: "laba_rugi", Label: "Laba/Rugi (5-6)", IsBold: true,
TodayNominal: todayNetProfit, TodayPct: todayPct(todayNetProfit),
MtdNominal: mtdNetProfit, MtdPct: mtdPct(mtdNetProfit),
},
}
opsItems := make([]models.OperationalExpenseItem, len(result.OperationalExpenseItems))
var opsTotal float64
for i, item := range result.OperationalExpenseItems {
opsItems[i] = models.OperationalExpenseItem{
Item: item.Item,
Nominal: item.Amount,
}
opsTotal += item.Amount
}
purchasingItems := make([]models.ProfitLossPurchasingItem, len(result.PurchasingItems))
for i, item := range result.PurchasingItems {
purchasingItems[i] = models.ProfitLossPurchasingItem{
Date: item.Date,
Item: item.Item,
Quantity: item.Quantity,
Nominal: item.Amount,
}
}
return &models.ProfitLossAnalyticsResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom,
DateTo: req.DateTo,
GroupBy: req.GroupBy,
@@ -920,319 +388,5 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
},
Data: data,
ProductData: productData,
MainSummary: mainSummary,
Purchasing: models.ProfitLossPurchasing{
TodayTotal: result.TodayPurchasing,
MtdTotal: result.MtdPurchasing,
TodayRawMaterial: result.TodayPurchasingRawMaterial,
MtdRawMaterial: result.MtdPurchasingRawMaterial,
TodayExpense: result.TodayPurchasingExpense,
MtdExpense: result.MtdPurchasingExpense,
Items: purchasingItems,
},
OperationalExpenses: opsItems,
OperationalExpensesTotal: opsTotal,
}, nil
}
func isSalaryExpenseCategory(name string) bool {
name = strings.ToLower(name)
return strings.Contains(name, "gaji") || strings.Contains(name, "salary")
}
func slugify(s string) string {
result := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'a' && c <= 'z':
result = append(result, c)
case c >= 'A' && c <= 'Z':
result = append(result, c+32)
case c >= '0' && c <= '9':
result = append(result, c)
default:
if len(result) == 0 || result[len(result)-1] != '_' {
result = append(result, '_')
}
}
}
return string(result)
}
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to")
}
return p.buildExclusiveSummaryPeriod(ctx, req)
}
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) {
monthStart := time.Date(req.Month.Year(), req.Month.Month(), 1, 0, 0, 0, 0, req.Month.Location())
monthEnd := monthStart.AddDate(0, 1, 0).Add(-time.Nanosecond)
fullPeriod, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
DateFrom: monthStart,
DateTo: monthEnd,
})
if err != nil {
return nil, err
}
periods := make([]models.ExclusiveSummaryMonthlyPeriod, 0)
for _, bucket := range buildExclusiveSummaryMonthlyBuckets(monthStart) {
period, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
DateFrom: bucket.DateFrom,
DateTo: bucket.DateTo,
})
if err != nil {
return nil, err
}
periods = append(periods, models.ExclusiveSummaryMonthlyPeriod{
Label: bucket.Label,
DateFrom: bucket.DateFrom,
DateTo: bucket.DateTo,
Sales: period.Summary.Sales,
HPP: period.Summary.HPP,
GrossProfit: period.Summary.GrossProfit,
GrossMargin: percentage(period.Summary.GrossProfit, period.Summary.Sales),
})
}
bankBalances, err := p.analyticsRepo.GetExclusiveSummaryBankBalances(ctx, req.OrganizationID, req.OutletID)
if err != nil {
return nil, fmt.Errorf("failed to get exclusive summary bank balances: %w", err)
}
bankBalance := make([]models.ExclusiveSummaryBankBalance, len(bankBalances))
for i, item := range bankBalances {
bankBalance[i] = models.ExclusiveSummaryBankBalance{
Bank: item.Bank,
OpeningBalance: item.OpeningBalance,
IncomingMutation: item.IncomingMutation,
OutgoingMutation: item.OutgoingMutation,
ClosingBalance: item.ClosingBalance,
Notes: item.Notes,
}
}
return &models.ExclusiveSummaryMonthlyResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
Month: monthStart.Format("2006-01"),
Summary: models.ExclusiveSummaryMonthlySummary{
TotalSales: fullPeriod.Summary.Sales,
HPP: fullPeriod.Summary.HPP,
GrossProfit: fullPeriod.Summary.GrossProfit,
OperationalExpensesTotal: fullPeriod.Summary.OperationalExpensesTotal,
TotalCost: fullPeriod.Summary.TotalCost,
NetProfit: fullPeriod.Summary.NetProfit,
NetProfitMargin: percentage(fullPeriod.Summary.NetProfit, fullPeriod.Summary.Sales),
},
Periods: periods,
BankBalance: bankBalance,
}, nil
}
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
mtdStart := time.Date(req.DateTo.Year(), req.DateTo.Month(), 1, 0, 0, 0, 0, req.DateTo.Location())
return p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
DateFrom: mtdStart,
DateTo: req.DateTo,
ExcludeGajiStaffFromReimburse: req.ExcludeGajiStaffFromReimburse,
})
}
func (p *AnalyticsProcessorImpl) buildExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
result, err := p.analyticsRepo.GetExclusiveSummaryAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo)
if err != nil {
return nil, fmt.Errorf("failed to get exclusive summary analytics: %w", err)
}
hppBreakdown, hppTotal := exclusiveSummaryCategoryBreakdown(result.HPPBreakdown)
operationalBreakdown, operationalTotal := exclusiveSummaryCategoryBreakdown(result.OperationalExpenseBreakdown)
salaryDW, salaryStaff, salaryOther := exclusiveSummarySalaryBreakdown(result.DailyTransactions)
salaryTotal := salaryDW + salaryStaff + salaryOther
otherOperationalExpenses := operationalTotal - salaryTotal
if otherOperationalExpenses < 0 {
otherOperationalExpenses = 0
}
grossProfit := result.SalesTotal - hppTotal
totalCost := hppTotal + operationalTotal
netProfit := result.SalesTotal - totalCost
excludedSalaryStaff := 0.0
if req.ExcludeGajiStaffFromReimburse {
excludedSalaryStaff = salaryStaff
}
dailySummary := make([]models.ExclusiveSummaryDailySummary, len(result.DailySummary))
for i, item := range result.DailySummary {
dailySummary[i] = models.ExclusiveSummaryDailySummary{
Date: item.Date,
TransactionCount: item.TransactionCount,
TotalCost: item.TotalCost,
}
}
dailyTransactions := make([]models.ExclusiveSummaryDailyTransaction, len(result.DailyTransactions))
for i, item := range result.DailyTransactions {
dailyTransactions[i] = models.ExclusiveSummaryDailyTransaction{
Date: item.Date,
CategoryCode: item.CategoryCode,
CategoryName: item.CategoryName,
Description: item.Description,
Amount: item.Amount,
Source: item.Source,
}
}
return &models.ExclusiveSummaryPeriodResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
Period: models.ExclusiveSummaryPeriodRange{
DateFrom: req.DateFrom,
DateTo: req.DateTo,
},
Summary: models.ExclusiveSummaryPeriodSummary{
Sales: result.SalesTotal,
HPP: hppTotal,
GrossProfit: grossProfit,
SalaryTotal: salaryTotal,
SalaryDW: salaryDW,
SalaryStaff: salaryStaff,
SalaryOther: salaryOther,
OtherOperationalExpenses: otherOperationalExpenses,
OperationalExpensesTotal: operationalTotal,
TotalCost: totalCost,
NetProfit: netProfit,
},
Reimburse: models.ExclusiveSummaryReimburse{
TotalCost: totalCost,
ExcludedSalaryStaff: excludedSalaryStaff,
TotalReimburse: totalCost - excludedSalaryStaff,
},
HPPBreakdown: hppBreakdown,
OperationalExpenseBreakdown: operationalBreakdown,
DailySummary: dailySummary,
DailyTransactions: dailyTransactions,
}, nil
}
func exclusiveSummaryCategoryBreakdown(items []entities.ExclusiveSummaryCategoryTotal) ([]models.ExclusiveSummaryCategoryBreakdown, float64) {
var total float64
for _, item := range items {
total += item.Amount
}
breakdown := make([]models.ExclusiveSummaryCategoryBreakdown, len(items))
for i, item := range items {
breakdown[i] = models.ExclusiveSummaryCategoryBreakdown{
CategoryCode: item.CategoryCode,
CategoryName: item.CategoryName,
Amount: item.Amount,
Percentage: percentage(item.Amount, total),
}
}
return breakdown, total
}
func exclusiveSummarySalaryBreakdown(transactions []entities.ExclusiveSummaryDailyTransaction) (float64, float64, float64) {
var salaryDW float64
var salaryStaff float64
var salaryOther float64
for _, transaction := range transactions {
if !isExclusiveSummarySalary(transaction.CategoryCode, transaction.CategoryName, transaction.Description) {
continue
}
classification := strings.ToLower(transaction.CategoryCode + " " + transaction.CategoryName + " " + transaction.Description)
switch {
case strings.Contains(classification, "staff") || strings.Contains(classification, "kary") || strings.Contains(classification, "karyawan"):
salaryStaff += transaction.Amount
case strings.Contains(classification, "dw"):
salaryDW += transaction.Amount
default:
salaryOther += transaction.Amount
}
}
return salaryDW, salaryStaff, salaryOther
}
func isExclusiveSummarySalary(parts ...string) bool {
text := strings.ToLower(strings.Join(parts, " "))
return strings.Contains(text, "gaji") || strings.Contains(text, "salary")
}
func percentage(numerator, denominator float64) float64 {
if denominator == 0 {
return 0
}
return (numerator / denominator) * 100
}
type exclusiveSummaryMonthlyBucket struct {
Label string
DateFrom time.Time
DateTo time.Time
}
func buildExclusiveSummaryMonthlyBuckets(monthStart time.Time) []exclusiveSummaryMonthlyBucket {
monthEnd := monthStart.AddDate(0, 1, 0).Add(-time.Nanosecond)
buckets := make([]exclusiveSummaryMonthlyBucket, 0, 6)
currentStart := monthStart
for !currentStart.After(monthEnd) {
currentEnd := currentStart
for currentEnd.Weekday() != time.Sunday && currentEnd.Day() < monthEnd.Day() {
currentEnd = currentEnd.AddDate(0, 0, 1)
}
bucketEnd := time.Date(currentEnd.Year(), currentEnd.Month(), currentEnd.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), currentEnd.Location())
if bucketEnd.After(monthEnd) {
bucketEnd = monthEnd
}
buckets = append(buckets, exclusiveSummaryMonthlyBucket{
Label: fmt.Sprintf("%d - %d %s", currentStart.Day(), bucketEnd.Day(), indonesianMonthName(currentStart.Month())),
DateFrom: currentStart,
DateTo: bucketEnd,
})
currentStart = time.Date(bucketEnd.Year(), bucketEnd.Month(), bucketEnd.Day(), 0, 0, 0, 0, bucketEnd.Location()).AddDate(0, 0, 1)
}
return buckets
}
func indonesianMonthName(month time.Month) string {
names := map[time.Month]string{
time.January: "Januari",
time.February: "Februari",
time.March: "Maret",
time.April: "April",
time.May: "Mei",
time.June: "Juni",
time.July: "Juli",
time.August: "Agustus",
time.September: "September",
time.October: "Oktober",
time.November: "November",
time.December: "Desember",
}
return names[month]
}
@@ -1,572 +0,0 @@
package processor
import (
"context"
"testing"
"time"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
type analyticsRepositoryStub struct {
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) {
return nil, nil
}
func (analyticsRepositoryStub) GetSalesAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) ([]*entities.SalesAnalytics, error) {
return nil, nil
}
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
}
func (analyticsRepositoryStub) GetProductAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, int) ([]*entities.ProductAnalytics, error) {
return nil, nil
}
func (analyticsRepositoryStub) GetProductAnalyticsPerCategory(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.ProductAnalyticsPerCategory, error) {
return nil, nil
}
func (analyticsRepositoryStub) GetProductAnalyticsPerParentCategory(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.ProductAnalyticsPerParentCategory, error) {
return nil, nil
}
func (analyticsRepositoryStub) GetParentCategoryAnalyticsDetail(context.Context, uuid.UUID, *uuid.UUID, uuid.UUID, time.Time, time.Time) (*entities.ParentCategoryAnalyticsDetail, error) {
return nil, nil
}
func (s analyticsRepositoryStub) GetBudgetCutOffWeekly(context.Context, uuid.UUID, *uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.BudgetCutOffWeek, error) {
return s.budgetCutOffWeeks, nil
}
func (analyticsRepositoryStub) GetDashboardOverview(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) (*entities.DashboardOverview, error) {
return nil, nil
}
func (s analyticsRepositoryStub) GetProfitLossAnalytics(_ context.Context, _ uuid.UUID, _ *uuid.UUID, _, _ time.Time, groupBy string) (*entities.ProfitLossAnalytics, error) {
s.profitLossGroup = groupBy
return s.profitLossResult, nil
}
func (s *analyticsRepositoryStub) GetExclusiveSummaryAnalytics(_ context.Context, _ uuid.UUID, _ *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error) {
s.exclusiveSummaryFrom = append(s.exclusiveSummaryFrom, dateFrom)
s.exclusiveSummaryTo = append(s.exclusiveSummaryTo, dateTo)
if s.exclusiveSummaryCalls < len(s.exclusiveSummaryResults) {
result := s.exclusiveSummaryResults[s.exclusiveSummaryCalls]
s.exclusiveSummaryCalls++
return result, nil
}
s.exclusiveSummaryCalls++
return &entities.ExclusiveSummaryAnalytics{}, nil
}
func (s *analyticsRepositoryStub) GetExclusiveSummaryBankBalances(context.Context, uuid.UUID, *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error) {
return s.bankBalances, nil
}
func (analyticsRepositoryStub) GetOutletName(context.Context, uuid.UUID, uuid.UUID) (string, error) {
return "", nil
}
type expenseRepositoryStub struct{}
func (expenseRepositoryStub) Create(context.Context, *entities.Expense) error { return nil }
func (expenseRepositoryStub) GetByID(context.Context, uuid.UUID) (*entities.Expense, error) {
return nil, nil
}
func (expenseRepositoryStub) GetByIDAndOrganizationID(context.Context, uuid.UUID, uuid.UUID) (*entities.Expense, error) {
return nil, nil
}
func (expenseRepositoryStub) Update(context.Context, *entities.Expense) error { return nil }
func (expenseRepositoryStub) Delete(context.Context, uuid.UUID) error { return nil }
func (expenseRepositoryStub) List(context.Context, uuid.UUID, map[string]interface{}, int, int) ([]*entities.Expense, int64, error) {
return nil, 0, nil
}
func (expenseRepositoryStub) GetAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.ExpenseAnalytics, error) {
return nil, nil
}
func (expenseRepositoryStub) CreateItem(context.Context, *entities.ExpenseItem) error { return nil }
func (expenseRepositoryStub) DeleteItemsByExpenseID(context.Context, uuid.UUID) error { return nil }
func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T) {
outletID := uuid.New()
outletName := "Main Outlet"
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
purchasingResult: &entities.PurchasingAnalytics{
OutletName: &outletName,
Summary: entities.PurchasingSummary{
TotalPurchases: 300,
RawMaterialPurchases: 125,
ExpensePurchases: 175,
TotalPurchaseOrders: 3,
RawMaterialPurchaseOrders: 1,
ExpenseCount: 2,
},
Data: []entities.PurchasingAnalyticsData{
{
Date: now,
Purchases: 300,
RawMaterialPurchases: 125,
ExpensePurchases: 175,
PurchaseOrders: 3,
RawMaterialPurchaseOrders: 1,
ExpenseCount: 2,
},
},
},
}, expenseRepositoryStub{})
result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{
OrganizationID: uuid.New(),
OutletID: &outletID,
DateFrom: now,
DateTo: now,
})
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, &outletID, result.OutletID)
require.NotNil(t, result.OutletName)
require.Equal(t, outletName, *result.OutletName)
require.Equal(t, float64(300), result.Summary.TotalPurchases)
require.Equal(t, float64(125), result.Summary.RawMaterialPurchases)
require.Equal(t, float64(175), result.Summary.ExpensePurchases)
require.Equal(t, int64(3), result.Summary.TotalPurchaseOrders)
require.Equal(t, int64(1), result.Summary.RawMaterialPurchaseOrders)
require.Equal(t, int64(2), result.Summary.ExpenseCount)
require.Len(t, result.Data, 1)
require.Equal(t, float64(300), result.Data[0].Purchases)
require.Equal(t, float64(125), result.Data[0].RawMaterialPurchases)
require.Equal(t, float64(175), result.Data[0].ExpensePurchases)
}
func TestAnalyticsProcessorGetPurchasingAnalyticsPassesTeamFilter(t *testing.T) {
categoryID := uuid.New()
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
tests := []struct {
name string
team string
want *entities.PurchaseTeamFilter
}{
{name: "all teams", team: "", want: nil},
{name: "pusat", team: constants.PurchaseTeamScopeCentral, want: &entities.PurchaseTeamFilter{Scope: constants.PurchaseTeamScopeCentral}},
{name: "no team", team: constants.PurchaseTeamNone, want: &entities.PurchaseTeamFilter{Scope: constants.PurchaseTeamNone}},
{
name: "category team",
team: categoryID.String(),
want: &entities.PurchaseTeamFilter{Scope: constants.PurchaseTeamScopeCategory, CategoryID: &categoryID},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := &analyticsRepositoryStub{purchasingResult: &entities.PurchasingAnalytics{}}
processor := NewAnalyticsProcessorImpl(repo, expenseRepositoryStub{})
result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{
OrganizationID: uuid.New(),
Team: tt.team,
DateFrom: now,
DateTo: now,
})
require.NoError(t, err)
require.Equal(t, tt.team, result.Team)
require.Equal(t, tt.want, repo.purchasingTeam)
})
}
}
func TestAnalyticsProcessorGetPurchasingAnalyticsMapsTeamBreakdown(t *testing.T) {
categoryID := uuid.New()
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
purchasingResult: &entities.PurchasingAnalytics{
Summary: entities.PurchasingSummary{TotalPurchases: 300, TotalTeams: 2},
TeamData: []entities.PurchasingTeamData{
{
Scope: constants.PurchaseTeamScopeCategory,
CategoryID: &categoryID,
Name: "Kitchen",
TotalPurchases: 200,
RawMaterialPurchases: 150,
ExpensePurchases: 50,
PurchaseOrderCount: 2,
Quantity: 12,
Percentage: 66.67,
},
{
Scope: constants.PurchaseTeamNone,
Name: constants.PurchaseTeamNoneName,
TotalPurchases: 100,
PurchaseOrderCount: 1,
Percentage: 33.33,
},
},
},
}, expenseRepositoryStub{})
result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{
OrganizationID: uuid.New(),
DateFrom: now,
DateTo: now,
})
require.NoError(t, err)
require.Equal(t, int64(2), result.Summary.TotalTeams)
require.Len(t, result.TeamData, 2)
require.Equal(t, constants.PurchaseTeamScopeCategory, result.TeamData[0].Scope)
require.Equal(t, &categoryID, result.TeamData[0].CategoryID)
require.Equal(t, "Kitchen", result.TeamData[0].Name)
require.Equal(t, float64(200), result.TeamData[0].TotalPurchases)
require.Equal(t, float64(150), result.TeamData[0].RawMaterialPurchases)
require.Equal(t, 66.67, result.TeamData[0].Percentage)
require.Equal(t, constants.PurchaseTeamNone, result.TeamData[1].Scope)
require.Nil(t, result.TeamData[1].CategoryID)
require.Equal(t, constants.PurchaseTeamNoneName, result.TeamData[1].Name)
}
func TestAnalyticsProcessorGetPurchasingAnalyticsRejectsUnknownTeam(t *testing.T) {
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
repo := &analyticsRepositoryStub{purchasingResult: &entities.PurchasingAnalytics{}}
processor := NewAnalyticsProcessorImpl(repo, expenseRepositoryStub{})
result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{
OrganizationID: uuid.New(),
Team: "marketing",
DateFrom: now,
DateTo: now,
})
require.Nil(t, result)
require.Error(t, err)
require.Contains(t, err.Error(), "team must be one of")
}
func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) {
productID := uuid.New()
categoryID := uuid.New()
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
profitLossResult: &entities.ProfitLossAnalytics{
Summary: entities.ProfitLossSummary{
TotalRevenue: 1000,
TotalCost: 400,
GrossProfit: 600,
GrossProfitMargin: 60,
TotalTax: 50,
TotalDiscount: 25,
NetProfit: 575,
NetProfitMargin: 57.5,
TotalOrders: 10,
AverageProfit: 57.5,
ProfitabilityRatio: 150,
},
Data: []entities.ProfitLossData{
{
Date: now,
Revenue: 1000,
Cost: 400,
GrossProfit: 600,
GrossProfitMargin: 60,
Tax: 50,
Discount: 25,
NetProfit: 575,
NetProfitMargin: 57.5,
Orders: 10,
},
},
ProductData: []entities.ProductProfitData{
{
ProductID: productID,
ProductName: "Nasi",
CategoryID: categoryID,
CategoryName: "Food",
QuantitySold: 5,
Revenue: 500,
Cost: 200,
GrossProfit: 300,
GrossProfitMargin: 60,
AveragePrice: 100,
AverageCost: 40,
ProfitPerUnit: 60,
},
},
TodayRevenue: 1000,
TodayCost: 400,
MtdRevenue: 2000,
MtdCost: 800,
},
}, expenseRepositoryStub{})
result, err := processor.GetProfitLossAnalytics(context.Background(), &models.ProfitLossAnalyticsRequest{
OrganizationID: uuid.New(),
DateFrom: now,
DateTo: now,
})
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, "day", result.GroupBy)
require.Equal(t, float64(1000), result.Summary.TotalRevenue)
require.Len(t, result.Data, 1)
require.Equal(t, float64(575), result.Data[0].NetProfit)
require.Len(t, result.ProductData, 1)
require.Equal(t, productID, result.ProductData[0].ProductID)
require.NotEmpty(t, result.MainSummary)
require.Equal(t, "total_omset", result.MainSummary[0].ID)
}
func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *testing.T) {
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
profitLossResult: &entities.ProfitLossAnalytics{
Summary: entities.ProfitLossSummary{
TotalRevenue: 10000,
TotalCost: 4000,
},
TodayRevenue: 10000,
TodayCost: 4000,
MtdRevenue: 20000,
MtdCost: 8000,
TodayExpenseByCategory: []entities.ExpenseCategoryTotal{
{CategoryName: "Gaji", Amount: 1500},
{CategoryName: "Promosi", Amount: 300},
{CategoryName: "Sewa", Amount: 500},
},
MtdExpenseByCategory: []entities.ExpenseCategoryTotal{
{CategoryName: "Gaji", Amount: 3000},
{CategoryName: "Promosi", Amount: 600},
{CategoryName: "Sewa", Amount: 1000},
},
},
}, expenseRepositoryStub{})
result, err := processor.GetProfitLossAnalytics(context.Background(), &models.ProfitLossAnalyticsRequest{
OrganizationID: uuid.New(),
DateFrom: now,
DateTo: now,
})
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, result.MainSummary, 7)
require.Equal(t, "total_omset", result.MainSummary[0].ID)
require.Equal(t, float64(10000), result.MainSummary[0].TodayNominal)
require.Equal(t, float64(20000), result.MainSummary[0].MtdNominal)
require.Equal(t, "hpp", result.MainSummary[1].ID)
require.Equal(t, float64(4000), result.MainSummary[1].TodayNominal)
require.Equal(t, float64(8000), result.MainSummary[1].MtdNominal)
require.Equal(t, "laba_kotor", result.MainSummary[2].ID)
require.Equal(t, float64(6000), result.MainSummary[2].TodayNominal)
require.Equal(t, float64(12000), result.MainSummary[2].MtdNominal)
require.Equal(t, "biaya_ops", result.MainSummary[3].ID)
require.Equal(t, float64(800), result.MainSummary[3].TodayNominal)
require.Equal(t, float64(1600), result.MainSummary[3].MtdNominal)
require.Len(t, result.MainSummary[3].SubItems, 3) // 2 operational categories + 1 total
require.Equal(t, "by_promosi", result.MainSummary[3].SubItems[0].ID)
require.Equal(t, float64(300), result.MainSummary[3].SubItems[0].TodayNominal)
require.Equal(t, float64(600), result.MainSummary[3].SubItems[0].MtdNominal)
require.Equal(t, "by_sewa", result.MainSummary[3].SubItems[1].ID)
require.Equal(t, float64(500), result.MainSummary[3].SubItems[1].TodayNominal)
require.Equal(t, float64(1000), result.MainSummary[3].SubItems[1].MtdNominal)
require.Equal(t, "total_biaya_ops", result.MainSummary[3].SubItems[2].ID)
require.True(t, result.MainSummary[3].SubItems[2].IsBold)
require.Equal(t, float64(800), result.MainSummary[3].SubItems[2].TodayNominal)
require.Equal(t, float64(1600), result.MainSummary[3].SubItems[2].MtdNominal)
require.Equal(t, "laba_rugi_sblm_gaji", result.MainSummary[4].ID)
require.Equal(t, float64(5200), result.MainSummary[4].TodayNominal)
require.Equal(t, float64(10400), result.MainSummary[4].MtdNominal)
require.Equal(t, "biaya_gaji", result.MainSummary[5].ID)
require.Equal(t, float64(1500), result.MainSummary[5].TodayNominal)
require.Equal(t, float64(3000), result.MainSummary[5].MtdNominal)
require.Equal(t, "laba_rugi", result.MainSummary[6].ID)
require.Equal(t, float64(3700), result.MainSummary[6].TodayNominal)
require.Equal(t, float64(7400), result.MainSummary[6].MtdNominal)
require.True(t, result.MainSummary[6].IsBold)
}
func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesTotalsAndReimburse(t *testing.T) {
now := time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC)
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{
{
SalesTotal: 1000,
HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{
{CategoryCode: "RAW", CategoryName: "Raw", Amount: 400},
},
OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{
{CategoryCode: "GAJI", CategoryName: "Gaji", Amount: 250},
{CategoryCode: "OPS", CategoryName: "Operasional", Amount: 100},
},
DailySummary: []entities.ExclusiveSummaryDailySummary{
{Date: now, TransactionCount: 3, TotalCost: 750},
},
DailyTransactions: []entities.ExclusiveSummaryDailyTransaction{
{Date: now, CategoryCode: "RAW", CategoryName: "Raw", Description: "beras", Amount: 400, Source: "purchase_order"},
{Date: now, CategoryCode: "GAJI", CategoryName: "Gaji", Description: "gaji karyawan", Amount: 200, Source: "purchase_order"},
{Date: now, CategoryCode: "GAJI", CategoryName: "Gaji", Description: "DW", Amount: 50, Source: "purchase_order"},
{Date: now, CategoryCode: "OPS", CategoryName: "Operasional", Description: "atk", Amount: 100, Source: "purchase_order"},
},
},
},
}, expenseRepositoryStub{})
result, err := processor.GetExclusiveSummaryPeriod(context.Background(), &models.ExclusiveSummaryPeriodRequest{
OrganizationID: uuid.New(),
DateFrom: now,
DateTo: now,
ExcludeGajiStaffFromReimburse: true,
})
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, float64(1000), result.Summary.Sales)
require.Equal(t, float64(400), result.Summary.HPP)
require.Equal(t, float64(600), result.Summary.GrossProfit)
require.Equal(t, float64(350), result.Summary.OperationalExpensesTotal)
require.Equal(t, float64(750), result.Summary.TotalCost)
require.Equal(t, float64(250), result.Summary.NetProfit)
require.Equal(t, float64(250), result.Summary.SalaryTotal)
require.Equal(t, float64(50), result.Summary.SalaryDW)
require.Equal(t, float64(200), result.Summary.SalaryStaff)
require.Equal(t, float64(100), result.Summary.OtherOperationalExpenses)
require.Equal(t, float64(200), result.Reimburse.ExcludedSalaryStaff)
require.Equal(t, float64(550), result.Reimburse.TotalReimburse)
require.Len(t, result.HPPBreakdown, 1)
require.Equal(t, float64(100), result.HPPBreakdown[0].Percentage)
require.Len(t, result.DailySummary, 1)
require.Len(t, result.DailyTransactions, 4)
}
func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsSummaryAndBuckets(t *testing.T) {
location, err := time.LoadLocation("Asia/Jakarta")
require.NoError(t, err)
month := time.Date(2026, 5, 1, 0, 0, 0, 0, location)
openingBalance := 5000000.0
closingBalance := 5000000.0
notes := "Main cash account for daily transactions"
stub := &analyticsRepositoryStub{
exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{
{SalesTotal: 1000, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 400}}, OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 100}}},
{SalesTotal: 100, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 40}}},
{SalesTotal: 200, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 80}}},
{SalesTotal: 300, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 120}}},
{SalesTotal: 400, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 160}}},
{SalesTotal: 500, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 200}}},
},
bankBalances: []entities.ExclusiveSummaryBankBalance{
{Bank: "Cash and Bank", OpeningBalance: &openingBalance, ClosingBalance: &closingBalance, Notes: &notes},
},
}
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)
}
-152
View File
@@ -1,152 +0,0 @@
package processor
import (
"context"
"testing"
"time"
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
func jakarta(t *testing.T) *time.Location {
t.Helper()
loc, err := time.LoadLocation("Asia/Jakarta")
require.NoError(t, err)
return loc
}
func TestStartOfWeekLandsOnMonday(t *testing.T) {
loc := jakarta(t)
// 3 Aug 2026 is a Monday, so the whole week must collapse onto it
monday := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
for offset := 0; offset < 7; offset++ {
day := monday.AddDate(0, 0, offset).Add(13 * time.Hour)
got := startOfWeek(day)
require.Equal(t, monday, got, "day %s should map to %s", day, monday)
require.Equal(t, time.Monday, got.Weekday())
}
}
func TestEndOfWeekLandsOnSunday(t *testing.T) {
loc := jakarta(t)
// Sunday 9 Aug 2026 closes the week that starts Monday 3 Aug
got := endOfWeek(time.Date(2026, 8, 5, 9, 30, 0, 0, loc))
require.Equal(t, time.Sunday, got.Weekday())
require.Equal(t, 2026, got.Year())
require.Equal(t, time.August, got.Month())
require.Equal(t, 9, got.Day())
require.Equal(t, 23, got.Hour())
require.Equal(t, 59, got.Minute())
}
func TestBuildBudgetCutOffWidensToWholeWeeks(t *testing.T) {
loc := jakarta(t)
processor := &AnalyticsProcessorImpl{analyticsRepo: &analyticsRepositoryStub{}}
// Saturday 1 Aug to Monday 31 Aug 2026: both ends fall mid-week
from := time.Date(2026, 8, 1, 0, 0, 0, 0, loc)
to := time.Date(2026, 8, 31, 23, 59, 59, 0, loc)
budget, err := processor.buildBudgetCutOff(context.Background(), uuid.New(), nil, nil, from, to)
require.NoError(t, err)
// Reaches back into July and forward into September to keep weeks whole
require.Equal(t, time.Monday, budget.CutOffFrom.Weekday())
require.Equal(t, time.July, budget.CutOffFrom.Month())
require.Equal(t, 27, budget.CutOffFrom.Day())
require.Equal(t, time.Sunday, budget.CutOffTo.Weekday())
require.Equal(t, time.September, budget.CutOffTo.Month())
require.Equal(t, 6, budget.CutOffTo.Day())
require.Len(t, budget.Weekly, 6)
for _, week := range budget.Weekly {
require.Equal(t, time.Monday, week.PeriodStart.Weekday())
require.Equal(t, time.Sunday, week.PeriodEnd.Weekday())
}
// A week is filed under the month of its Monday, so the 27 Jul week counts as July
require.Len(t, budget.Monthly, 2)
require.Equal(t, "2026-07", budget.Monthly[0].Month)
require.Equal(t, 1, budget.Monthly[0].WeekCount)
require.Equal(t, "2026-08", budget.Monthly[1].Month)
require.Equal(t, 5, budget.Monthly[1].WeekCount)
}
func TestBuildBudgetCutOffAppliesLimits(t *testing.T) {
loc := jakarta(t)
weekStart := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{
{WeekStart: weekStart, Revenue: 10_000_000, OrderCount: 120},
}}
processor := &AnalyticsProcessorImpl{analyticsRepo: stub}
budget, err := processor.buildBudgetCutOff(context.Background(), uuid.New(), nil, nil, weekStart, weekStart.AddDate(0, 0, 6))
require.NoError(t, err)
require.Len(t, budget.Weekly, 1)
// 60 / 20 / 20 of the week's revenue
week := budget.Weekly[0]
require.Equal(t, float64(6_000_000), week.LimitPurchase)
require.Equal(t, float64(2_000_000), week.LimitOwner)
require.Equal(t, float64(2_000_000), week.LimitTeam)
require.Equal(t, int64(120), week.OrderCount)
// Totals mirror the single week
require.Equal(t, week.Revenue, budget.Total.Revenue)
require.Equal(t, week.LimitPurchase, budget.Total.LimitPurchase)
}
func TestBuildBudgetCutOffAccumulatesMonthlyFromWeeks(t *testing.T) {
loc := jakarta(t)
first := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{
{WeekStart: first, Revenue: 10_000_000, OrderCount: 100},
{WeekStart: first.AddDate(0, 0, 7), Revenue: 5_000_000, OrderCount: 60},
}}
processor := &AnalyticsProcessorImpl{analyticsRepo: stub}
budget, err := processor.buildBudgetCutOff(context.Background(), uuid.New(), nil, nil, first, first.AddDate(0, 0, 9))
require.NoError(t, err)
require.Len(t, budget.Monthly, 1)
month := budget.Monthly[0]
require.Equal(t, "2026-08", month.Month)
require.Equal(t, 2, month.WeekCount)
require.Equal(t, float64(15_000_000), month.Revenue)
require.Equal(t, int64(160), month.OrderCount)
// The month limit is the accumulation of its weeks
require.Equal(t, float64(9_000_000), month.LimitPurchase)
require.Equal(t, budget.Weekly[0].LimitPurchase+budget.Weekly[1].LimitPurchase, month.LimitPurchase)
}
func TestBuildBudgetCutOffEmitsWeeksWithoutSales(t *testing.T) {
loc := jakarta(t)
first := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
// Only the third week has sales; the two quiet weeks must still be reported
stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{
{WeekStart: first.AddDate(0, 0, 14), Revenue: 4_000_000, OrderCount: 40},
}}
processor := &AnalyticsProcessorImpl{analyticsRepo: stub}
budget, err := processor.buildBudgetCutOff(context.Background(), uuid.New(), nil, nil, first, first.AddDate(0, 0, 16))
require.NoError(t, err)
require.Len(t, budget.Weekly, 3)
require.Zero(t, budget.Weekly[0].Revenue)
require.Zero(t, budget.Weekly[0].LimitPurchase)
require.Zero(t, budget.Weekly[1].Revenue)
require.Equal(t, float64(4_000_000), budget.Weekly[2].Revenue)
require.Equal(t, float64(4_000_000), budget.Total.Revenue)
}
@@ -1,286 +0,0 @@
package processor
import (
"context"
"fmt"
"strings"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models"
"github.com/google/uuid"
)
type CashAdvanceProcessor interface {
CreateCashAdvance(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreateCashAdvanceRequest) (*models.CashAdvanceResponse, error)
UpdateCashAdvance(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdateCashAdvanceRequest) (*models.CashAdvanceResponse, error)
DeleteCashAdvance(ctx context.Context, id, organizationID uuid.UUID) error
GetCashAdvanceByID(ctx context.Context, id, organizationID uuid.UUID) (*models.CashAdvanceResponse, error)
ListCashAdvances(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.CashAdvanceResponse, int, error)
UpdateCashAdvanceStatus(ctx context.Context, id, organizationID uuid.UUID, status string) (*models.CashAdvanceResponse, error)
ListCashAdvanceTeams(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error)
}
type CashAdvanceProcessorImpl struct {
cashAdvanceRepo CashAdvanceRepository
categoryRepo CategoryRepository
}
func NewCashAdvanceProcessorImpl(cashAdvanceRepo CashAdvanceRepository, categoryRepo CategoryRepository) *CashAdvanceProcessorImpl {
return &CashAdvanceProcessorImpl{
cashAdvanceRepo: cashAdvanceRepo,
categoryRepo: categoryRepo,
}
}
func (p *CashAdvanceProcessorImpl) CreateCashAdvance(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreateCashAdvanceRequest) (*models.CashAdvanceResponse, error) {
// The cash leaves one drawer, so the outlet has to be known: either the caller
// named it or it comes from the outlet they are signed in to.
resolvedOutletID := req.OutletID
if resolvedOutletID == nil {
resolvedOutletID = outletID
}
if resolvedOutletID == nil || *resolvedOutletID == uuid.Nil {
return nil, fmt.Errorf("outlet_id is required")
}
teamScope, teamCategoryID, err := p.resolveCashAdvanceTeam(ctx, organizationID, resolvedOutletID, &req.TeamScope, req.TeamCategoryID)
if err != nil {
return nil, err
}
existing, err := p.cashAdvanceRepo.GetByCodeNumber(ctx, req.CodeNumber, organizationID)
if err == nil && existing != nil {
return nil, fmt.Errorf("cash advance with code number %s already exists in this organization", req.CodeNumber)
}
status := constants.CashAdvanceStatusDraft
if req.Status != nil {
status = *req.Status
}
cashAdvance := &entities.CashAdvance{
OrganizationID: organizationID,
OutletID: *resolvedOutletID,
CodeNumber: req.CodeNumber,
TeamScope: teamScope,
TeamCategoryID: teamCategoryID,
Amount: req.Amount,
IssuedDate: req.IssuedDate,
DueDate: req.DueDate,
Status: status,
Description: req.Description,
}
if err := p.cashAdvanceRepo.Create(ctx, cashAdvance); err != nil {
return nil, fmt.Errorf("failed to create cash advance: %w", err)
}
created, err := p.cashAdvanceRepo.GetByID(ctx, cashAdvance.ID)
if err != nil {
return nil, fmt.Errorf("failed to get created cash advance: %w", err)
}
return mappers.CashAdvanceEntityToResponse(created), nil
}
func (p *CashAdvanceProcessorImpl) UpdateCashAdvance(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdateCashAdvanceRequest) (*models.CashAdvanceResponse, error) {
cashAdvance, err := p.cashAdvanceRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
if err != nil {
return nil, fmt.Errorf("cash advance not found: %w", err)
}
if req.CodeNumber != nil && *req.CodeNumber != cashAdvance.CodeNumber {
existing, err := p.cashAdvanceRepo.GetByCodeNumber(ctx, *req.CodeNumber, organizationID)
if err == nil && existing != nil {
return nil, fmt.Errorf("cash advance with code number %s already exists in this organization", *req.CodeNumber)
}
cashAdvance.CodeNumber = *req.CodeNumber
}
if req.TeamScope != nil {
teamScope, teamCategoryID, err := p.resolveCashAdvanceTeam(ctx, organizationID, &cashAdvance.OutletID, req.TeamScope, req.TeamCategoryID)
if err != nil {
return nil, err
}
cashAdvance.TeamScope = teamScope
cashAdvance.TeamCategoryID = teamCategoryID
}
if req.Amount != nil {
cashAdvance.Amount = *req.Amount
}
if req.ReturnedAmount != nil {
cashAdvance.ReturnedAmount = *req.ReturnedAmount
}
if req.IssuedDate != nil {
cashAdvance.IssuedDate = *req.IssuedDate
}
if req.DueDate != nil {
cashAdvance.DueDate = req.DueDate
}
if req.Status != nil {
if err := p.guardStatusChange(ctx, cashAdvance, *req.Status); err != nil {
return nil, err
}
cashAdvance.Status = *req.Status
}
if req.Description != nil {
cashAdvance.Description = req.Description
}
// Cash handed back can only ever be part of the cash handed out.
if cashAdvance.ReturnedAmount > cashAdvance.Amount {
return nil, fmt.Errorf("returned_amount cannot be greater than the cash advance amount")
}
if err := p.cashAdvanceRepo.Update(ctx, cashAdvance); err != nil {
return nil, fmt.Errorf("failed to update cash advance: %w", err)
}
updated, err := p.cashAdvanceRepo.GetByID(ctx, cashAdvance.ID)
if err != nil {
return nil, fmt.Errorf("failed to get updated cash advance: %w", err)
}
return mappers.CashAdvanceEntityToResponse(updated), nil
}
func (p *CashAdvanceProcessorImpl) DeleteCashAdvance(ctx context.Context, id, organizationID uuid.UUID) error {
if _, err := p.cashAdvanceRepo.GetByIDAndOrganizationID(ctx, id, organizationID); err != nil {
return fmt.Errorf("cash advance not found: %w", err)
}
// The foreign keys would refuse this anyway, but not in words anyone can act on.
count, err := p.cashAdvanceRepo.CountSettlements(ctx, id)
if err != nil {
return fmt.Errorf("failed to check cash advance settlements: %w", err)
}
if count > 0 {
return fmt.Errorf("cash advance cannot be deleted because %d purchase orders or expenses are charged to it", count)
}
if err := p.cashAdvanceRepo.Delete(ctx, id); err != nil {
return fmt.Errorf("failed to delete cash advance: %w", err)
}
return nil
}
func (p *CashAdvanceProcessorImpl) GetCashAdvanceByID(ctx context.Context, id, organizationID uuid.UUID) (*models.CashAdvanceResponse, error) {
cashAdvance, err := p.cashAdvanceRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
if err != nil {
return nil, fmt.Errorf("cash advance not found: %w", err)
}
response := mappers.CashAdvanceEntityToResponse(cashAdvance)
// The detail view is where someone checks a cash advance off, so it carries the
// spending behind the settled figure. The list deliberately does not.
settlements, err := p.cashAdvanceRepo.ListSettlements(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to list cash advance settlements: %w", err)
}
response.Settlements = mappers.CashAdvanceSettlementEntitiesToModels(settlements)
return response, nil
}
func (p *CashAdvanceProcessorImpl) ListCashAdvances(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.CashAdvanceResponse, int, error) {
offset := (page - 1) * limit
cashAdvances, total, err := p.cashAdvanceRepo.List(ctx, organizationID, filters, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("failed to list cash advances: %w", err)
}
responses := mappers.CashAdvanceEntitiesToResponses(cashAdvances)
totalPages := int((total + int64(limit) - 1) / int64(limit))
return responses, totalPages, nil
}
func (p *CashAdvanceProcessorImpl) UpdateCashAdvanceStatus(ctx context.Context, id, organizationID uuid.UUID, status string) (*models.CashAdvanceResponse, error) {
cashAdvance, err := p.cashAdvanceRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
if err != nil {
return nil, fmt.Errorf("cash advance not found: %w", err)
}
if !constants.IsValidCashAdvanceStatus(status) {
return nil, fmt.Errorf("status must be one of: %s", strings.Join(constants.GetAllCashAdvanceStatuses(), ", "))
}
if err := p.guardStatusChange(ctx, cashAdvance, status); err != nil {
return nil, err
}
cashAdvance.Status = status
if err := p.cashAdvanceRepo.Update(ctx, cashAdvance); err != nil {
return nil, fmt.Errorf("failed to update cash advance status: %w", err)
}
updated, err := p.cashAdvanceRepo.GetByID(ctx, cashAdvance.ID)
if err != nil {
return nil, fmt.Errorf("failed to get updated cash advance: %w", err)
}
return mappers.CashAdvanceEntityToResponse(updated), nil
}
func (p *CashAdvanceProcessorImpl) ListCashAdvanceTeams(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) {
return listTeams(ctx, p.categoryRepo, organizationID, outletID)
}
// guardStatusChange refuses to withdraw an advance that spending already points at.
// Rejecting or cancelling it would leave those purchases claiming to have been paid
// out of cash the books say never went out.
func (p *CashAdvanceProcessorImpl) guardStatusChange(ctx context.Context, cashAdvance *entities.CashAdvance, status string) error {
if status != constants.CashAdvanceStatusRejected && status != constants.CashAdvanceStatusCancelled {
return nil
}
count, err := p.cashAdvanceRepo.CountSettlements(ctx, cashAdvance.ID)
if err != nil {
return fmt.Errorf("failed to check cash advance settlements: %w", err)
}
if count > 0 {
return fmt.Errorf("cash advance cannot be %s because %d purchase orders or expenses are charged to it", status, count)
}
return nil
}
// resolveCashAdvanceTeam is resolveTeamSelection with the one rule an advance adds:
// the cash is handed to a team, so there is no such thing as one without a team.
func (p *CashAdvanceProcessorImpl) resolveCashAdvanceTeam(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, scope *string, categoryID *uuid.UUID) (string, *uuid.UUID, error) {
resolvedScope, resolvedCategoryID, err := resolveTeamSelection(ctx, p.categoryRepo, organizationID, outletID, scope, categoryID)
if err != nil {
return "", nil, err
}
if resolvedScope == nil {
return "", nil, fmt.Errorf("team_scope is required")
}
return *resolvedScope, resolvedCategoryID, nil
}
// resolveSpendingCashAdvance checks that a purchase order or expense may be charged
// to the advance it names: same organization and outlet, and the money actually
// approved to leave the drawer. Draft or cancelled advances cannot be spent against.
func resolveSpendingCashAdvance(ctx context.Context, cashAdvanceRepo CashAdvanceRepository, cashAdvanceID, organizationID uuid.UUID, outletID *uuid.UUID) (*entities.CashAdvance, error) {
cashAdvance, err := cashAdvanceRepo.GetByIDAndOrganizationID(ctx, cashAdvanceID, organizationID)
if err != nil {
return nil, fmt.Errorf("cash advance not found: %w", err)
}
if cashAdvance.Status != constants.CashAdvanceStatusApproved {
return nil, fmt.Errorf("cash advance %s is %s, only an approved cash advance can be spent against", cashAdvance.CodeNumber, cashAdvance.Status)
}
if outletID != nil && *outletID != uuid.Nil && cashAdvance.OutletID != *outletID {
return nil, fmt.Errorf("cash advance %s belongs to a different outlet", cashAdvance.CodeNumber)
}
return cashAdvance, nil
}
@@ -1,20 +0,0 @@
package processor
import (
"apskel-pos-be/internal/entities"
"context"
"github.com/google/uuid"
)
type CashAdvanceRepository interface {
Create(ctx context.Context, cashAdvance *entities.CashAdvance) error
GetByID(ctx context.Context, id uuid.UUID) (*entities.CashAdvance, error)
GetByIDAndOrganizationID(ctx context.Context, id, organizationID uuid.UUID) (*entities.CashAdvance, error)
GetByCodeNumber(ctx context.Context, codeNumber string, organizationID uuid.UUID) (*entities.CashAdvance, error)
Update(ctx context.Context, cashAdvance *entities.CashAdvance) error
Delete(ctx context.Context, id uuid.UUID) error
List(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, limit, offset int) ([]*entities.CashAdvance, int64, error)
ListSettlements(ctx context.Context, cashAdvanceID uuid.UUID) ([]*entities.CashAdvanceSettlement, error)
CountSettlements(ctx context.Context, cashAdvanceID uuid.UUID) (int64, error)
}

Some files were not shown because too many files have changed in this diff Show More