Compare commits
43
Commits
6c19876a47
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e5573af75 | ||
|
|
2c6864147b | ||
|
|
b42d141927 | ||
|
|
e6078e3c0b | ||
|
|
dbc143954c | ||
|
|
0726fcecf0 | ||
|
|
9ae5be2c33 | ||
|
|
a230199ce0 | ||
|
|
4f7e774043 | ||
|
|
793ef10ce8 | ||
|
|
a7c2d6cbb3 | ||
|
|
1d412959d7 | ||
|
|
b9ac97178f | ||
|
|
7b46da7007 | ||
|
|
2b80c92caa | ||
|
|
f7dd0bd5e8 | ||
|
|
1533914e4d | ||
|
|
bfce4b865b | ||
|
|
581e4a5453 | ||
|
|
9b0fc9a63b | ||
|
|
793919cf10 | ||
|
|
25024c210a | ||
|
|
3977370079 | ||
|
|
37bcb90ab0 | ||
|
|
e345aeee97 | ||
|
|
486d94335b | ||
|
|
7d5acb33e8 | ||
|
|
2138b44c53 | ||
|
|
503fb5734f | ||
|
|
ac06a4bbe9 | ||
|
|
87540fa1b7 | ||
|
|
66d4c9f0af | ||
|
|
55119b3e91 | ||
|
|
67a5c076e7 | ||
|
|
c1d859ebdd | ||
|
|
7a2060efdc | ||
|
|
2ad9e2f85f | ||
|
|
a8d62bc5e8 | ||
|
|
8816e4addc | ||
|
|
2921631ac3 | ||
|
|
0db838e2c4 | ||
|
|
4b6cbb69c1 | ||
|
|
9e0ba0ce56 |
@@ -9,3 +9,7 @@ vendor
|
|||||||
|
|
||||||
# Firebase service account credentials
|
# Firebase service account credentials
|
||||||
infra/firebase-service-account.json
|
infra/firebase-service-account.json
|
||||||
|
|
||||||
|
# Config files containing secrets (manage manually on each server)
|
||||||
|
# infra/production.yaml
|
||||||
|
# infra/staging.yaml
|
||||||
|
|||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -1,9 +1,21 @@
|
|||||||
#PROJECT_NAME = "enaklo-pos-backend"
|
#PROJECT_NAME = "enaklo-pos-backend"
|
||||||
DB_USERNAME :=apskel
|
|
||||||
DB_PASSWORD :=7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk
|
# ─── Environment (default: staging) ──────────────────────────────────────────
|
||||||
DB_HOST :=62.72.45.250
|
ENV ?= staging
|
||||||
DB_PORT :=5433
|
|
||||||
DB_NAME :=apskel_pos
|
ifeq ($(ENV),production)
|
||||||
|
DB_USERNAME :=apskel
|
||||||
|
DB_PASSWORD :=7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk
|
||||||
|
DB_HOST :=62.72.45.250
|
||||||
|
DB_PORT :=5433
|
||||||
|
DB_NAME :=apskel_pos
|
||||||
|
else
|
||||||
|
DB_USERNAME :=apskel
|
||||||
|
DB_PASSWORD :=7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk
|
||||||
|
DB_HOST :=62.72.45.250
|
||||||
|
DB_PORT :=5433
|
||||||
|
DB_NAME :=apskel_pos_staging
|
||||||
|
endif
|
||||||
|
|
||||||
DB_URL = postgres://$(DB_USERNAME):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=disable
|
DB_URL = postgres://$(DB_USERNAME):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=disable
|
||||||
|
|
||||||
@@ -16,15 +28,19 @@ endif
|
|||||||
.SILENT: help
|
.SILENT: help
|
||||||
help:
|
help:
|
||||||
@echo
|
@echo
|
||||||
@echo "Usage: make [command]"
|
@echo "Usage: make [command] [ENV=staging|production]"
|
||||||
@echo
|
@echo
|
||||||
@echo "Commands:"
|
@echo "Commands:"
|
||||||
|
@echo " run Run server (default: staging)"
|
||||||
|
@echo " run ENV=production Run server with production config"
|
||||||
|
@echo
|
||||||
@echo " rename-project name={name} Rename project"
|
@echo " rename-project name={name} Rename project"
|
||||||
@echo
|
@echo
|
||||||
@echo " build-http Build http server"
|
@echo " build-http Build http server"
|
||||||
@echo
|
@echo
|
||||||
@echo " migration-create name={name} Create migration"
|
@echo " migration-create name={name} Create migration"
|
||||||
@echo " migration-up Up migrations"
|
@echo " migration-up Up migrations"
|
||||||
|
@echo " migration-up ENV=production Up migrations (production DB)"
|
||||||
@echo " migration-down Down last migration"
|
@echo " migration-down Down last migration"
|
||||||
@echo
|
@echo
|
||||||
@echo " docker-up Up docker services"
|
@echo " docker-up Up docker services"
|
||||||
@@ -114,7 +130,11 @@ fmt:
|
|||||||
@go fmt ./...
|
@go fmt ./...
|
||||||
|
|
||||||
start:
|
start:
|
||||||
go run main.go --env-path .env
|
ENV_MODE=$(ENV) go run cmd/server/main.go
|
||||||
|
|
||||||
|
.SILENT: run
|
||||||
|
run:
|
||||||
|
ENV_MODE=$(ENV) go run cmd/server/main.go
|
||||||
|
|
||||||
# Default
|
# Default
|
||||||
|
|
||||||
|
|||||||
@@ -15,15 +15,19 @@ Makefile requires installed dependecies:
|
|||||||
```shell
|
```shell
|
||||||
$ make
|
$ make
|
||||||
|
|
||||||
Usage: make [command]
|
Usage: make [command] [ENV=staging|production]
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
|
run Run server (default: staging)
|
||||||
|
run ENV=production Run server with production config
|
||||||
|
|
||||||
rename-project name={name} Rename project
|
rename-project name={name} Rename project
|
||||||
|
|
||||||
build-http Build http server
|
build-http Build http server
|
||||||
|
|
||||||
migration-create name={name} Create migration
|
migration-create name={name} Create migration
|
||||||
migration-up Up migrations
|
migration-up Up migrations
|
||||||
|
migration-up ENV=production Up migrations (production DB)
|
||||||
migration-down Down last migration
|
migration-down Down last migration
|
||||||
|
|
||||||
docker-up Up docker services
|
docker-up Up docker services
|
||||||
@@ -36,24 +40,16 @@ Commands:
|
|||||||
|
|
||||||
## HTTP Server
|
## HTTP Server
|
||||||
|
|
||||||
```shell
|
The server takes no CLI flags. It reads `ENV_MODE` and loads the matching YAML file from
|
||||||
$ ./bin/http-server --help
|
[infra/](infra/) — see [Running the Application](#running-the-application) for details.
|
||||||
|
|
||||||
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
|
```shell
|
||||||
# Expose env vars before and start server
|
# Build, then start with the staging config (default)
|
||||||
$ ./bin/http-server
|
$ go build -o ./bin/http-server ./cmd/server/main.go
|
||||||
|
$ ENV_MODE=staging ./bin/http-server
|
||||||
|
|
||||||
# Expose env vars from the file and start server
|
# Start with the production config
|
||||||
$ ./bin/http-server --env-path ./config/env/.env
|
$ ENV_MODE=production ./bin/http-server
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Docs
|
## API Docs
|
||||||
@@ -124,7 +120,7 @@ Handler → Service → Processor → Repository
|
|||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
### Health Check
|
### Health Check
|
||||||
- `GET /api/v1/health` - Health check endpoint
|
- `GET /health` - Health check endpoint (registered at the root, not under `/api/v1`)
|
||||||
|
|
||||||
### Organizations
|
### Organizations
|
||||||
- `POST /api/v1/organizations` - Create organization
|
- `POST /api/v1/organizations` - Create organization
|
||||||
@@ -157,73 +153,139 @@ Handler → Service → Processor → Repository
|
|||||||
- `PUT /api/v1/order-items/{id}` - Update order item
|
- `PUT /api/v1/order-items/{id}` - Update order item
|
||||||
- `DELETE /api/v1/order-items/{id}` - Remove order item
|
- `DELETE /api/v1/order-items/{id}` - Remove order item
|
||||||
|
|
||||||
## Installation
|
## Running the Application
|
||||||
|
|
||||||
1. **Clone the repository**
|
### Prerequisites
|
||||||
```bash
|
|
||||||
git clone <repository-url>
|
|
||||||
cd apskel-pos-backend
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Install dependencies**
|
| Tool | Version | Needed for |
|
||||||
```bash
|
|------|---------|------------|
|
||||||
go mod tidy
|
| [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) |
|
||||||
|
|
||||||
3. **Set up database**
|
### 1. Clone & install dependencies
|
||||||
```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
|
```bash
|
||||||
# Start the server
|
git clone <repository-url>
|
||||||
go run cmd/server/main.go -port 8080 -db-url "postgres://username:password@localhost:5432/apskel_pos?sslmode=disable"
|
cd apskel-pos-backend
|
||||||
|
go mod download
|
||||||
# Or using environment variable
|
|
||||||
export DATABASE_URL="postgres://username:password@localhost:5432/apskel_pos?sslmode=disable"
|
|
||||||
go run cmd/server/main.go -port 8080
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using Make Commands
|
### 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:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run the application
|
cp infra/staging.yaml infra/local.yaml
|
||||||
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
|
## Example API Usage
|
||||||
|
|
||||||
### Create Organization
|
### Create Organization
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8080/api/v1/organizations \
|
curl -X POST http://localhost:4000/api/v1/organizations \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"name": "My Restaurant",
|
"name": "My Restaurant",
|
||||||
@@ -233,7 +295,7 @@ curl -X POST http://localhost:8080/api/v1/organizations \
|
|||||||
|
|
||||||
### Create User
|
### Create User
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8080/api/v1/users \
|
curl -X POST http://localhost:4000/api/v1/users \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"organization_id": "uuid-here",
|
"organization_id": "uuid-here",
|
||||||
@@ -247,7 +309,7 @@ curl -X POST http://localhost:8080/api/v1/users \
|
|||||||
|
|
||||||
### Create Order with Items
|
### Create Order with Items
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8080/api/v1/orders \
|
curl -X POST http://localhost:4000/api/v1/orders \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"outlet_id": "uuid-here",
|
"outlet_id": "uuid-here",
|
||||||
|
|||||||
+2
-1
@@ -12,13 +12,14 @@ import (
|
|||||||
const (
|
const (
|
||||||
YAML_PATH = "infra/%s"
|
YAML_PATH = "infra/%s"
|
||||||
ENV_MODE = "ENV_MODE"
|
ENV_MODE = "ENV_MODE"
|
||||||
DEFAULT_ENV_MODE = "development"
|
DEFAULT_ENV_MODE = "staging"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
validEnvMode = map[string]struct{}{
|
validEnvMode = map[string]struct{}{
|
||||||
"local": {},
|
"local": {},
|
||||||
"development": {},
|
"development": {},
|
||||||
|
"staging": {},
|
||||||
"production": {},
|
"production": {},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
+46
-8
@@ -2,23 +2,61 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
APP_NAME="apskel-pos"
|
APP_NAME="apskel-pos"
|
||||||
PORT="4000"
|
|
||||||
|
# ─── Deteksi environment dari branch aktif ───────────────────────────────────
|
||||||
|
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||||
|
|
||||||
|
case "$CURRENT_BRANCH" in
|
||||||
|
main)
|
||||||
|
ENV_MODE="production"
|
||||||
|
PORT="4000"
|
||||||
|
;;
|
||||||
|
staging)
|
||||||
|
ENV_MODE="staging"
|
||||||
|
PORT="4001"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "❌ Branch '$CURRENT_BRANCH' tidak dikenali untuk deployment."
|
||||||
|
echo " Gunakan branch 'main' (production) atau 'staging' (staging)."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CONTAINER_NAME="$APP_NAME"
|
||||||
|
IMAGE_NAME="$APP_NAME:$ENV_MODE"
|
||||||
|
|
||||||
|
echo "📦 Environment : $ENV_MODE"
|
||||||
|
echo "🌿 Branch : $CURRENT_BRANCH"
|
||||||
|
echo "🐳 Container : $CONTAINER_NAME"
|
||||||
|
echo "🔌 Port : $PORT"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ─── Pastikan config file ada ─────────────────────────────────────────────────
|
||||||
|
CONFIG_FILE="infra/$ENV_MODE.yaml"
|
||||||
|
if [ ! -f "$CONFIG_FILE" ]; then
|
||||||
|
echo "❌ Config file '$CONFIG_FILE' tidak ditemukan."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "🔄 Pulling latest code..."
|
echo "🔄 Pulling latest code..."
|
||||||
git pull
|
git pull
|
||||||
|
|
||||||
echo "🐳 Building Docker image (production target)..."
|
echo "🐳 Building Docker image ($ENV_MODE)..."
|
||||||
docker build --target production -t $APP_NAME:latest .
|
docker build --target production -t "$IMAGE_NAME" .
|
||||||
|
|
||||||
echo "🛑 Stopping and removing old container..."
|
echo "🛑 Stopping and removing old container..."
|
||||||
docker rm -f $APP_NAME 2>/dev/null || true
|
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||||
|
|
||||||
echo "🚀 Running new container..."
|
echo "🚀 Running new container..."
|
||||||
docker run -d --name $APP_NAME \
|
docker run -d --name "$CONTAINER_NAME" \
|
||||||
-p $PORT:$PORT \
|
-p "$PORT:4000" \
|
||||||
-e TZ=Asia/Jakarta \
|
-e TZ=Asia/Jakarta \
|
||||||
|
-e ENV_MODE="$ENV_MODE" \
|
||||||
-v "$(pwd)/infra":/infra:ro \
|
-v "$(pwd)/infra":/infra:ro \
|
||||||
-v "$(pwd)/templates":/templates:ro \
|
-v "$(pwd)/templates":/templates:ro \
|
||||||
$APP_NAME:latest
|
"$IMAGE_NAME"
|
||||||
|
|
||||||
echo "✅ Deployment complete."
|
echo ""
|
||||||
|
echo "✅ Deployment $ENV_MODE complete."
|
||||||
|
echo " Container : $CONTAINER_NAME"
|
||||||
|
echo " Port : $PORT"
|
||||||
|
|||||||
@@ -351,6 +351,8 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
|
|||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
|
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
|
||||||
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
||||||
|
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||||
|
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||||
@@ -380,7 +382,6 @@ go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J
|
|||||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
|
|
||||||
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
server:
|
server:
|
||||||
base-url:
|
base-url:
|
||||||
local-url:
|
local-url:
|
||||||
self-order-url: http://localhost:5173
|
self-order-url:
|
||||||
port: 4000
|
port: 4000
|
||||||
|
|
||||||
jwt:
|
jwt:
|
||||||
@@ -9,7 +9,7 @@ jwt:
|
|||||||
expires-ttl: 144000
|
expires-ttl: 144000
|
||||||
secret: "5Lm25V3Qd7aut8dr4QUxm5PZUrSFs"
|
secret: "5Lm25V3Qd7aut8dr4QUxm5PZUrSFs"
|
||||||
refresh_token:
|
refresh_token:
|
||||||
expires-ttl: 7776000 # 3 months in minutes (90 days * 24 hours * 60 minutes)
|
expires-ttl: 7776000
|
||||||
secret: "R3fr3sh_T0k3n_S3cr3t_K3y_2024_P0S"
|
secret: "R3fr3sh_T0k3n_S3cr3t_K3y_2024_P0S"
|
||||||
customer:
|
customer:
|
||||||
expires-ttl: 7776000
|
expires-ttl: 7776000
|
||||||
@@ -21,7 +21,7 @@ postgresql:
|
|||||||
driver: postgres
|
driver: postgres
|
||||||
db: apskel_pos
|
db: apskel_pos
|
||||||
username: apskel
|
username: apskel
|
||||||
password: '7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk'
|
password: "7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk"
|
||||||
ssl-mode: disable
|
ssl-mode: disable
|
||||||
max-idle-connections-in-second: 600
|
max-idle-connections-in-second: 600
|
||||||
max-open-connections-in-second: 600
|
max-open-connections-in-second: 600
|
||||||
@@ -45,11 +45,11 @@ s3:
|
|||||||
endpoint: sin1.contabostorage.com
|
endpoint: sin1.contabostorage.com
|
||||||
bucket_name: enaklo
|
bucket_name: enaklo
|
||||||
log_level: Error
|
log_level: Error
|
||||||
host_url: 'https://sin1.contabostorage.com/fda98c2228f246f29a7e466b86b3b9e7:'
|
host_url: "https://sin1.contabostorage.com/fda98c2228f246f29a7e466b86b3b9e7:"
|
||||||
|
|
||||||
log:
|
log:
|
||||||
log_format: 'json'
|
log_format: "json"
|
||||||
log_level: 'debug'
|
log_level: "info"
|
||||||
|
|
||||||
fonnte:
|
fonnte:
|
||||||
api_url: "https://api.fonnte.com/send"
|
api_url: "https://api.fonnte.com/send"
|
||||||
@@ -58,4 +58,4 @@ fonnte:
|
|||||||
|
|
||||||
fcm:
|
fcm:
|
||||||
credentials_file: "infra/firebase-service-account.json"
|
credentials_file: "infra/firebase-service-account.json"
|
||||||
project_id: "apskel-pos-v2"
|
project_id: "apskel-pos-v2"
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
server:
|
||||||
|
base-url:
|
||||||
|
local-url:
|
||||||
|
self-order-url:
|
||||||
|
port: 4000
|
||||||
|
|
||||||
|
jwt:
|
||||||
|
token:
|
||||||
|
expires-ttl: 144000
|
||||||
|
secret: "eZ7LAZJuSOGSHxb1ZYaZCkrBo5YBvc"
|
||||||
|
refresh_token:
|
||||||
|
expires-ttl: 7776000
|
||||||
|
secret: "EMx2DKPtMp0jQNpLvzzCsZkoUHe0d9"
|
||||||
|
customer:
|
||||||
|
expires-ttl: 7776000
|
||||||
|
secret: "layCV2rne0X57acWzSS3NxENmYJs7B"
|
||||||
|
|
||||||
|
postgresql:
|
||||||
|
host: 62.72.45.250
|
||||||
|
port: 5433
|
||||||
|
driver: postgres
|
||||||
|
db: apskel_pos_staging
|
||||||
|
username: apskel
|
||||||
|
password: "7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk"
|
||||||
|
ssl-mode: disable
|
||||||
|
max-idle-connections-in-second: 600
|
||||||
|
max-open-connections-in-second: 600
|
||||||
|
connection-max-life-time-in-second: 600
|
||||||
|
debug: false
|
||||||
|
|
||||||
|
redis:
|
||||||
|
host: 62.72.45.250
|
||||||
|
port: 6380
|
||||||
|
password: "CmICdmnX1EZPhVBYzQPEGw==U"
|
||||||
|
db: 1
|
||||||
|
dial_timeout: 5s
|
||||||
|
read_timeout: 3s
|
||||||
|
write_timeout: 3s
|
||||||
|
pool_size: 10
|
||||||
|
min_idle_connections: 5
|
||||||
|
|
||||||
|
s3:
|
||||||
|
access_key_id: cf9a475e18bc7626cbdbf09709d82a64
|
||||||
|
access_key_secret: 91f3321294d3e23035427a0ecb893ada
|
||||||
|
endpoint: sin1.contabostorage.com
|
||||||
|
bucket_name: enaklo
|
||||||
|
log_level: Error
|
||||||
|
host_url: "https://sin1.contabostorage.com/fda98c2228f246f29a7e466b86b3b9e7:"
|
||||||
|
|
||||||
|
log:
|
||||||
|
log_format: "json"
|
||||||
|
log_level: "info"
|
||||||
|
|
||||||
|
fonnte:
|
||||||
|
api_url: "https://api.fonnte.com/send"
|
||||||
|
token: "bADQrf9NTXfLZQCK2wGg"
|
||||||
|
timeout: 30
|
||||||
|
|
||||||
|
fcm:
|
||||||
|
credentials_file: "infra/firebase-service-account.json"
|
||||||
|
project_id: "apskel-pos-v2"
|
||||||
+12
-2
@@ -140,6 +140,8 @@ func (a *App) Initialize(cfg *config.Config) error {
|
|||||||
selfOrderHandler,
|
selfOrderHandler,
|
||||||
services.expenseService,
|
services.expenseService,
|
||||||
validators.expenseValidator,
|
validators.expenseValidator,
|
||||||
|
services.cashAdvanceService,
|
||||||
|
validators.cashAdvanceValidator,
|
||||||
a.redisClient,
|
a.redisClient,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -244,6 +246,7 @@ type repositories struct {
|
|||||||
notificationDeliveryRepo *repository.NotificationDeliveryRepositoryImpl
|
notificationDeliveryRepo *repository.NotificationDeliveryRepositoryImpl
|
||||||
productOutletPriceRepo *repository.ProductOutletPriceRepositoryImpl
|
productOutletPriceRepo *repository.ProductOutletPriceRepositoryImpl
|
||||||
expenseRepo *repository.ExpenseRepositoryImpl
|
expenseRepo *repository.ExpenseRepositoryImpl
|
||||||
|
cashAdvanceRepo *repository.CashAdvanceRepositoryImpl
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) initRepositories() *repositories {
|
func (a *App) initRepositories() *repositories {
|
||||||
@@ -298,6 +301,7 @@ func (a *App) initRepositories() *repositories {
|
|||||||
notificationDeliveryRepo: repository.NewNotificationDeliveryRepository(a.db),
|
notificationDeliveryRepo: repository.NewNotificationDeliveryRepository(a.db),
|
||||||
productOutletPriceRepo: repository.NewProductOutletPriceRepositoryImpl(a.db),
|
productOutletPriceRepo: repository.NewProductOutletPriceRepositoryImpl(a.db),
|
||||||
expenseRepo: repository.NewExpenseRepositoryImpl(a.db),
|
expenseRepo: repository.NewExpenseRepositoryImpl(a.db),
|
||||||
|
cashAdvanceRepo: repository.NewCashAdvanceRepositoryImpl(a.db),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +349,7 @@ type processors struct {
|
|||||||
notificationProcessor *processor.NotificationProcessorImpl
|
notificationProcessor *processor.NotificationProcessorImpl
|
||||||
productOutletPriceProcessor processor.ProductOutletPriceProcessor
|
productOutletPriceProcessor processor.ProductOutletPriceProcessor
|
||||||
expenseProcessor *processor.ExpenseProcessorImpl
|
expenseProcessor *processor.ExpenseProcessorImpl
|
||||||
|
cashAdvanceProcessor *processor.CashAdvanceProcessorImpl
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
|
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
|
||||||
@@ -372,7 +377,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
|||||||
ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo),
|
ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo),
|
||||||
productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo),
|
productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo),
|
||||||
vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo),
|
vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo),
|
||||||
purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.purchaseCategoryRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo),
|
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),
|
purchaseCategoryProcessor: processor.NewPurchaseCategoryProcessorImpl(repos.purchaseCategoryRepo),
|
||||||
unitConverterProcessor: processor.NewIngredientUnitConverterProcessorImpl(repos.unitConverterRepo, repos.ingredientRepo, repos.unitRepo),
|
unitConverterProcessor: processor.NewIngredientUnitConverterProcessorImpl(repos.unitConverterRepo, repos.ingredientRepo, repos.unitRepo),
|
||||||
chartOfAccountTypeProcessor: processor.NewChartOfAccountTypeProcessorImpl(repos.chartOfAccountTypeRepo),
|
chartOfAccountTypeProcessor: processor.NewChartOfAccountTypeProcessorImpl(repos.chartOfAccountTypeRepo),
|
||||||
@@ -396,7 +401,8 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
|||||||
userDeviceProcessor: processor.NewUserDeviceProcessorImpl(repos.userDeviceRepo),
|
userDeviceProcessor: processor.NewUserDeviceProcessorImpl(repos.userDeviceRepo),
|
||||||
notificationProcessor: buildNotificationProcessor(cfg, repos),
|
notificationProcessor: buildNotificationProcessor(cfg, repos),
|
||||||
productOutletPriceProcessor: processor.NewProductOutletPriceProcessorImpl(repos.productOutletPriceRepo, repos.productRepo, repos.outletRepo),
|
productOutletPriceProcessor: processor.NewProductOutletPriceProcessorImpl(repos.productOutletPriceRepo, repos.productRepo, repos.outletRepo),
|
||||||
expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo),
|
expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo, repos.cashAdvanceRepo),
|
||||||
|
cashAdvanceProcessor: processor.NewCashAdvanceProcessorImpl(repos.cashAdvanceRepo, repos.categoryRepo),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,6 +444,7 @@ type services struct {
|
|||||||
notificationService service.NotificationService
|
notificationService service.NotificationService
|
||||||
productOutletPriceService service.ProductOutletPriceService
|
productOutletPriceService service.ProductOutletPriceService
|
||||||
expenseService *service.ExpenseServiceImpl
|
expenseService *service.ExpenseServiceImpl
|
||||||
|
cashAdvanceService *service.CashAdvanceServiceImpl
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
|
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
|
||||||
@@ -518,6 +525,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
|||||||
notificationService: notificationService,
|
notificationService: notificationService,
|
||||||
productOutletPriceService: service.NewProductOutletPriceService(processors.productOutletPriceProcessor),
|
productOutletPriceService: service.NewProductOutletPriceService(processors.productOutletPriceProcessor),
|
||||||
expenseService: service.NewExpenseService(processors.expenseProcessor),
|
expenseService: service.NewExpenseService(processors.expenseProcessor),
|
||||||
|
cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,6 +570,7 @@ type validators struct {
|
|||||||
notificationValidator *validator.NotificationValidatorImpl
|
notificationValidator *validator.NotificationValidatorImpl
|
||||||
productOutletPriceValidator *validator.ProductOutletPriceValidatorImpl
|
productOutletPriceValidator *validator.ProductOutletPriceValidatorImpl
|
||||||
expenseValidator *validator.ExpenseValidatorImpl
|
expenseValidator *validator.ExpenseValidatorImpl
|
||||||
|
cashAdvanceValidator *validator.CashAdvanceValidatorImpl
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) initValidators() *validators {
|
func (a *App) initValidators() *validators {
|
||||||
@@ -594,6 +603,7 @@ func (a *App) initValidators() *validators {
|
|||||||
notificationValidator: validator.NewNotificationValidator(),
|
notificationValidator: validator.NewNotificationValidator(),
|
||||||
productOutletPriceValidator: validator.NewProductOutletPriceValidator(),
|
productOutletPriceValidator: validator.NewProductOutletPriceValidator(),
|
||||||
expenseValidator: validator.NewExpenseValidator(),
|
expenseValidator: validator.NewExpenseValidator(),
|
||||||
|
cashAdvanceValidator: validator.NewCashAdvanceValidator(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package constants
|
||||||
|
|
||||||
|
// Budget allocation of revenue used by the parent category cut-off report.
|
||||||
|
// The three shares are expected to add up to 100.
|
||||||
|
const (
|
||||||
|
BudgetLimitPurchasePercent = 60.0
|
||||||
|
BudgetLimitOwnerPercent = 20.0
|
||||||
|
BudgetLimitTeamPercent = 20.0
|
||||||
|
)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
@@ -62,6 +62,7 @@ const (
|
|||||||
NotificationHandlerEntity = "notification_handler"
|
NotificationHandlerEntity = "notification_handler"
|
||||||
ProductOutletPriceServiceEntity = "product_outlet_price_service"
|
ProductOutletPriceServiceEntity = "product_outlet_price_service"
|
||||||
ExpenseServiceEntity = "expense_service"
|
ExpenseServiceEntity = "expense_service"
|
||||||
|
CashAdvanceServiceEntity = "cash_advance_service"
|
||||||
)
|
)
|
||||||
|
|
||||||
var HttpErrorMap = map[string]int{
|
var HttpErrorMap = map[string]int{
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package constants
|
||||||
|
|
||||||
|
// A purchase order is charged to a team. Teams come from the parent product
|
||||||
|
// categories, plus Pusat for spending that belongs to no single team.
|
||||||
|
const (
|
||||||
|
PurchaseTeamScopeCategory = "category"
|
||||||
|
PurchaseTeamScopeCentral = "central"
|
||||||
|
|
||||||
|
// PurchaseTeamCentralName is what Pusat is called in the picker. Pusat has no
|
||||||
|
// row of its own, so the name lives here rather than in the database.
|
||||||
|
PurchaseTeamCentralName = "Pusat"
|
||||||
|
|
||||||
|
// PurchaseTeamNone is the value the list filter takes to ask for purchases
|
||||||
|
// that have not been charged to any team yet.
|
||||||
|
PurchaseTeamNone = "none"
|
||||||
|
|
||||||
|
// PurchaseTeamNoneName labels those purchases in the reports.
|
||||||
|
PurchaseTeamNoneName = "Tanpa Team"
|
||||||
|
)
|
||||||
@@ -3,11 +3,12 @@ package constants
|
|||||||
type UserRole string
|
type UserRole string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
RoleAdmin UserRole = "admin"
|
RoleAdmin UserRole = "admin"
|
||||||
RoleManager UserRole = "manager"
|
RoleManager UserRole = "manager"
|
||||||
RoleCashier UserRole = "cashier"
|
RoleCashier UserRole = "cashier"
|
||||||
RoleWaiter UserRole = "waiter"
|
RoleWaiter UserRole = "waiter"
|
||||||
RoleOwner UserRole = "owner"
|
RoleOwner UserRole = "owner"
|
||||||
|
RolePurchasing UserRole = "purchasing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetAllUserRoles() []UserRole {
|
func GetAllUserRoles() []UserRole {
|
||||||
@@ -17,6 +18,7 @@ func GetAllUserRoles() []UserRole {
|
|||||||
RoleCashier,
|
RoleCashier,
|
||||||
RoleWaiter,
|
RoleWaiter,
|
||||||
RoleOwner,
|
RoleOwner,
|
||||||
|
RolePurchasing,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type PaymentMethodAnalyticsRequest struct {
|
|||||||
type PaymentMethodAnalyticsResponse struct {
|
type PaymentMethodAnalyticsResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
GroupBy string `json:"group_by"`
|
GroupBy string `json:"group_by"`
|
||||||
@@ -54,6 +55,7 @@ type SalesAnalyticsRequest struct {
|
|||||||
type SalesAnalyticsResponse struct {
|
type SalesAnalyticsResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
GroupBy string `json:"group_by"`
|
GroupBy string `json:"group_by"`
|
||||||
@@ -86,15 +88,19 @@ type SalesAnalyticsData struct {
|
|||||||
type PurchasingAnalyticsRequest struct {
|
type PurchasingAnalyticsRequest struct {
|
||||||
OrganizationID uuid.UUID
|
OrganizationID uuid.UUID
|
||||||
OutletID *string `form:"outlet_id,omitempty"`
|
OutletID *string `form:"outlet_id,omitempty"`
|
||||||
DateFrom string `form:"date_from" validate:"required"`
|
// Team narrows the report to one team: a parent category id, "central" for
|
||||||
DateTo string `form:"date_to" validate:"required"`
|
// Pusat, or "none" for purchases charged to no team. Empty covers all teams.
|
||||||
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
|
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 {
|
type PurchasingAnalyticsResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
OutletName *string `json:"outlet_name,omitempty"`
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
|
Team string `json:"team,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
GroupBy string `json:"group_by"`
|
GroupBy string `json:"group_by"`
|
||||||
@@ -102,6 +108,21 @@ type PurchasingAnalyticsResponse struct {
|
|||||||
Data []PurchasingAnalyticsData `json:"data"`
|
Data []PurchasingAnalyticsData `json:"data"`
|
||||||
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
||||||
VendorData []PurchasingVendorData `json:"vendor_data"`
|
VendorData []PurchasingVendorData `json:"vendor_data"`
|
||||||
|
TeamData []PurchasingTeamData `json:"team_data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurchasingTeamData is one team's share of the purchases. Scope and CategoryID
|
||||||
|
// are exactly what the team filter takes, so a row doubles as a drill-down link.
|
||||||
|
type PurchasingTeamData struct {
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
CategoryID *uuid.UUID `json:"category_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
TotalPurchases float64 `json:"total_purchases"`
|
||||||
|
RawMaterialPurchases float64 `json:"raw_material_purchases"`
|
||||||
|
ExpensePurchases float64 `json:"expense_purchases"`
|
||||||
|
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Percentage float64 `json:"percentage"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PurchasingSummary struct {
|
type PurchasingSummary struct {
|
||||||
@@ -115,6 +136,7 @@ type PurchasingSummary struct {
|
|||||||
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
|
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
|
||||||
TotalIngredients int64 `json:"total_ingredients"`
|
TotalIngredients int64 `json:"total_ingredients"`
|
||||||
TotalVendors int64 `json:"total_vendors"`
|
TotalVendors int64 `json:"total_vendors"`
|
||||||
|
TotalTeams int64 `json:"total_teams"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PurchasingAnalyticsData struct {
|
type PurchasingAnalyticsData struct {
|
||||||
@@ -140,12 +162,12 @@ type PurchasingIngredientData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PurchasingVendorData struct {
|
type PurchasingVendorData struct {
|
||||||
VendorID uuid.UUID `json:"vendor_id"`
|
VendorID *uuid.UUID `json:"vendor_id"`
|
||||||
VendorName string `json:"vendor_name"`
|
VendorName string `json:"vendor_name"`
|
||||||
TotalCost float64 `json:"total_cost"`
|
TotalCost float64 `json:"total_cost"`
|
||||||
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||||
IngredientCount int64 `json:"ingredient_count"`
|
IngredientCount int64 `json:"ingredient_count"`
|
||||||
Quantity float64 `json:"quantity"`
|
Quantity float64 `json:"quantity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProductAnalyticsRequest represents the request for product analytics
|
// ProductAnalyticsRequest represents the request for product analytics
|
||||||
@@ -161,6 +183,7 @@ type ProductAnalyticsRequest struct {
|
|||||||
type ProductAnalyticsResponse struct {
|
type ProductAnalyticsResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
Data []ProductAnalyticsData `json:"data"`
|
Data []ProductAnalyticsData `json:"data"`
|
||||||
@@ -198,6 +221,7 @@ type ProductAnalyticsPerCategoryRequest struct {
|
|||||||
type ProductAnalyticsPerCategoryResponse struct {
|
type ProductAnalyticsPerCategoryResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
Data []ProductAnalyticsPerCategoryData `json:"data"`
|
Data []ProductAnalyticsPerCategoryData `json:"data"`
|
||||||
@@ -215,6 +239,135 @@ type ProductAnalyticsPerCategoryData struct {
|
|||||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProductAnalyticsPerParentCategoryRequest represents the request for product analytics per parent category
|
||||||
|
type ProductAnalyticsPerParentCategoryRequest struct {
|
||||||
|
OrganizationID uuid.UUID
|
||||||
|
OutletID *string `form:"outlet_id,omitempty"`
|
||||||
|
DateFrom string `form:"date_from" validate:"required"`
|
||||||
|
DateTo string `form:"date_to" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductAnalyticsPerParentCategoryResponse represents the response for product analytics per parent category
|
||||||
|
type ProductAnalyticsPerParentCategoryResponse struct {
|
||||||
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
|
DateFrom time.Time `json:"date_from"`
|
||||||
|
DateTo time.Time `json:"date_to"`
|
||||||
|
Data []ProductAnalyticsPerParentCategoryData `json:"data"`
|
||||||
|
Budget BudgetCutOff `json:"budget"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductAnalyticsPerParentCategoryData struct {
|
||||||
|
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||||
|
ParentCategoryName string `json:"parent_category_name"`
|
||||||
|
TotalRevenue float64 `json:"total_revenue"`
|
||||||
|
TotalQuantity int64 `json:"total_quantity"`
|
||||||
|
CategoryCount int64 `json:"category_count"`
|
||||||
|
ProductCount int64 `json:"product_count"`
|
||||||
|
OrderCount int64 `json:"order_count"`
|
||||||
|
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||||
|
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||||
|
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParentCategoryAnalyticsDetailRequest represents the request for the drill-down of one parent category
|
||||||
|
type ParentCategoryAnalyticsDetailRequest struct {
|
||||||
|
OrganizationID uuid.UUID
|
||||||
|
ParentCategoryID string
|
||||||
|
OutletID *string `form:"outlet_id,omitempty"`
|
||||||
|
DateFrom string `form:"date_from" validate:"required"`
|
||||||
|
DateTo string `form:"date_to" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParentCategoryAnalyticsDetailResponse represents the drill-down of one parent category
|
||||||
|
type ParentCategoryAnalyticsDetailResponse struct {
|
||||||
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
|
DateFrom time.Time `json:"date_from"`
|
||||||
|
DateTo time.Time `json:"date_to"`
|
||||||
|
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||||
|
ParentCategoryName string `json:"parent_category_name"`
|
||||||
|
Summary ParentCategoryAnalyticsDetailSummary `json:"summary"`
|
||||||
|
Categories []ParentCategoryAnalyticsDetailData `json:"categories"`
|
||||||
|
Budget BudgetCutOff `json:"budget"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParentCategoryAnalyticsDetailSummary struct {
|
||||||
|
TotalRevenue float64 `json:"total_revenue"`
|
||||||
|
TotalQuantity int64 `json:"total_quantity"`
|
||||||
|
CategoryCount int64 `json:"category_count"`
|
||||||
|
ProductCount int64 `json:"product_count"`
|
||||||
|
OrderCount int64 `json:"order_count"`
|
||||||
|
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||||
|
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||||
|
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParentCategoryAnalyticsDetailData struct {
|
||||||
|
CategoryID uuid.UUID `json:"category_id"`
|
||||||
|
CategoryName string `json:"category_name"`
|
||||||
|
TotalRevenue float64 `json:"total_revenue"`
|
||||||
|
TotalQuantity int64 `json:"total_quantity"`
|
||||||
|
ProductCount int64 `json:"product_count"`
|
||||||
|
OrderCount int64 `json:"order_count"`
|
||||||
|
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||||
|
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||||
|
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||||
|
Products []ParentCategoryAnalyticsProductData `json:"products"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParentCategoryAnalyticsProductData struct {
|
||||||
|
ProductID uuid.UUID `json:"product_id"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
ProductSku string `json:"product_sku"`
|
||||||
|
ProductPrice float64 `json:"product_price"`
|
||||||
|
QuantitySold int64 `json:"quantity_sold"`
|
||||||
|
Revenue float64 `json:"revenue"`
|
||||||
|
AveragePrice float64 `json:"average_price"`
|
||||||
|
OrderCount int64 `json:"order_count"`
|
||||||
|
StandardHppPerUnit float64 `json:"standard_hpp_per_unit"`
|
||||||
|
StandardHppTotal float64 `json:"standard_hpp_total"`
|
||||||
|
FifoHppPerUnit float64 `json:"fifo_hpp_per_unit"`
|
||||||
|
FifoHppTotal float64 `json:"fifo_hpp_total"`
|
||||||
|
MovingAverageHppPerUnit float64 `json:"moving_average_hpp_per_unit"`
|
||||||
|
MovingAverageHppTotal float64 `json:"moving_average_hpp_total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BudgetCutOff is the Monday-to-Sunday spending limit breakdown attached to the
|
||||||
|
// parent category reports.
|
||||||
|
type BudgetCutOff struct {
|
||||||
|
Percentages BudgetPercentages `json:"percentages"`
|
||||||
|
CutOffFrom time.Time `json:"cut_off_from"`
|
||||||
|
CutOffTo time.Time `json:"cut_off_to"`
|
||||||
|
Total BudgetPeriod `json:"total"`
|
||||||
|
Weekly []BudgetPeriod `json:"weekly"`
|
||||||
|
Monthly []BudgetMonthPeriod `json:"monthly"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BudgetPercentages struct {
|
||||||
|
Purchase float64 `json:"purchase"`
|
||||||
|
Owner float64 `json:"owner"`
|
||||||
|
Team float64 `json:"team"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BudgetPeriod struct {
|
||||||
|
PeriodStart time.Time `json:"period_start"`
|
||||||
|
PeriodEnd time.Time `json:"period_end"`
|
||||||
|
Revenue float64 `json:"revenue"`
|
||||||
|
OrderCount int64 `json:"order_count"`
|
||||||
|
LimitPurchase float64 `json:"limit_purchase"`
|
||||||
|
LimitOwner float64 `json:"limit_owner"`
|
||||||
|
LimitTeam float64 `json:"limit_team"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BudgetMonthPeriod struct {
|
||||||
|
Month string `json:"month"`
|
||||||
|
WeekCount int `json:"week_count"`
|
||||||
|
BudgetPeriod
|
||||||
|
}
|
||||||
|
|
||||||
// DashboardAnalyticsRequest represents the request for dashboard analytics
|
// DashboardAnalyticsRequest represents the request for dashboard analytics
|
||||||
type DashboardAnalyticsRequest struct {
|
type DashboardAnalyticsRequest struct {
|
||||||
OrganizationID uuid.UUID
|
OrganizationID uuid.UUID
|
||||||
@@ -227,6 +380,7 @@ type DashboardAnalyticsRequest struct {
|
|||||||
type DashboardAnalyticsResponse struct {
|
type DashboardAnalyticsResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
Overview DashboardOverview `json:"overview"`
|
Overview DashboardOverview `json:"overview"`
|
||||||
@@ -237,12 +391,15 @@ type DashboardAnalyticsResponse struct {
|
|||||||
|
|
||||||
// DashboardOverview represents the overview data for dashboard
|
// DashboardOverview represents the overview data for dashboard
|
||||||
type DashboardOverview struct {
|
type DashboardOverview struct {
|
||||||
TotalSales float64 `json:"total_sales"`
|
TotalSales float64 `json:"total_sales"`
|
||||||
TotalOrders int64 `json:"total_orders"`
|
TotalOrders int64 `json:"total_orders"`
|
||||||
AverageOrderValue float64 `json:"average_order_value"`
|
AverageOrderValue float64 `json:"average_order_value"`
|
||||||
TotalCustomers int64 `json:"total_customers"`
|
TotalCustomers int64 `json:"total_customers"`
|
||||||
VoidedOrders int64 `json:"voided_orders"`
|
VoidedOrders int64 `json:"voided_orders"`
|
||||||
RefundedOrders int64 `json:"refunded_orders"`
|
RefundedOrders int64 `json:"refunded_orders"`
|
||||||
|
TotalItemSold int64 `json:"total_item_sold"`
|
||||||
|
TotalLowStock int64 `json:"total_low_stock"`
|
||||||
|
TotalProductActive int64 `json:"total_product_active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProfitLossAnalyticsRequest struct {
|
type ProfitLossAnalyticsRequest struct {
|
||||||
@@ -256,6 +413,7 @@ type ProfitLossAnalyticsRequest struct {
|
|||||||
type ProfitLossAnalyticsResponse struct {
|
type ProfitLossAnalyticsResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
GroupBy string `json:"group_by"`
|
GroupBy string `json:"group_by"`
|
||||||
@@ -263,10 +421,28 @@ type ProfitLossAnalyticsResponse struct {
|
|||||||
Data []ProfitLossData `json:"data"`
|
Data []ProfitLossData `json:"data"`
|
||||||
ProductData []ProductProfitData `json:"product_data"`
|
ProductData []ProductProfitData `json:"product_data"`
|
||||||
MainSummary []ProfitLossSummaryRow `json:"main_summary"`
|
MainSummary []ProfitLossSummaryRow `json:"main_summary"`
|
||||||
|
Purchasing ProfitLossPurchasing `json:"purchasing"`
|
||||||
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
|
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
|
||||||
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProfitLossPurchasing struct {
|
||||||
|
TodayTotal float64 `json:"today_total"`
|
||||||
|
MtdTotal float64 `json:"mtd_total"`
|
||||||
|
TodayRawMaterial float64 `json:"today_raw_material"`
|
||||||
|
MtdRawMaterial float64 `json:"mtd_raw_material"`
|
||||||
|
TodayExpense float64 `json:"today_expense"`
|
||||||
|
MtdExpense float64 `json:"mtd_expense"`
|
||||||
|
Items []ProfitLossPurchasingItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfitLossPurchasingItem struct {
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Item string `json:"item"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Nominal float64 `json:"nominal"`
|
||||||
|
}
|
||||||
|
|
||||||
type ProfitLossSummary struct {
|
type ProfitLossSummary struct {
|
||||||
TotalRevenue float64 `json:"total_revenue"`
|
TotalRevenue float64 `json:"total_revenue"`
|
||||||
TotalCost float64 `json:"total_cost"`
|
TotalCost float64 `json:"total_cost"`
|
||||||
@@ -339,9 +515,17 @@ type ExclusiveSummaryMonthlyRequest struct {
|
|||||||
Month string `form:"month" validate:"required"`
|
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 {
|
type ExclusiveSummaryPeriodResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
Period ExclusiveSummaryPeriodRange `json:"period"`
|
Period ExclusiveSummaryPeriodRange `json:"period"`
|
||||||
Summary ExclusiveSummaryPeriodSummary `json:"summary"`
|
Summary ExclusiveSummaryPeriodSummary `json:"summary"`
|
||||||
Reimburse ExclusiveSummaryReimburse `json:"reimburse"`
|
Reimburse ExclusiveSummaryReimburse `json:"reimburse"`
|
||||||
@@ -401,6 +585,7 @@ type ExclusiveSummaryDailyTransaction struct {
|
|||||||
type ExclusiveSummaryMonthlyResponse struct {
|
type ExclusiveSummaryMonthlyResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
Month string `json:"month"`
|
Month string `json:"month"`
|
||||||
Summary ExclusiveSummaryMonthlySummary `json:"summary"`
|
Summary ExclusiveSummaryMonthlySummary `json:"summary"`
|
||||||
Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"`
|
Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"`
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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"`
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ type CreateCategoryRequest struct {
|
|||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
BusinessType *string `json:"business_type,omitempty"`
|
BusinessType *string `json:"business_type,omitempty"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||||
Order *int `json:"order,omitempty"`
|
Order *int `json:"order,omitempty"`
|
||||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -20,6 +21,7 @@ type UpdateCategoryRequest struct {
|
|||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
BusinessType *string `json:"business_type,omitempty"`
|
BusinessType *string `json:"business_type,omitempty"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||||
Order *int `json:"order,omitempty"`
|
Order *int `json:"order,omitempty"`
|
||||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -27,6 +29,8 @@ type UpdateCategoryRequest struct {
|
|||||||
type ListCategoriesRequest struct {
|
type ListCategoriesRequest struct {
|
||||||
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
|
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||||
|
Type string `json:"type,omitempty" validate:"omitempty,oneof=parent child"`
|
||||||
BusinessType string `json:"business_type,omitempty"`
|
BusinessType string `json:"business_type,omitempty"`
|
||||||
Search string `json:"search,omitempty"`
|
Search string `json:"search,omitempty"`
|
||||||
Page int `json:"page" validate:"required,min=1"`
|
Page int `json:"page" validate:"required,min=1"`
|
||||||
@@ -38,6 +42,8 @@ type CategoryResponse struct {
|
|||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id"`
|
OutletID *uuid.UUID `json:"outlet_id"`
|
||||||
|
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||||
|
ParentName *string `json:"parent_name,omitempty"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
BusinessType string `json:"business_type"`
|
BusinessType string `json:"business_type"`
|
||||||
|
|||||||
@@ -7,15 +7,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type CreateExpenseRequest struct {
|
type CreateExpenseRequest struct {
|
||||||
Receiver string `json:"receiver" validate:"required"`
|
Receiver string `json:"receiver" validate:"required"`
|
||||||
TransactionDate string `json:"transaction_date" validate:"required"`
|
TransactionDate string `json:"transaction_date" validate:"required"`
|
||||||
CodeNumber string `json:"code_number" validate:"required"`
|
CodeNumber string `json:"code_number" validate:"required"`
|
||||||
OutletID string `json:"outlet_id" validate:"required"`
|
OutletID string `json:"outlet_id" validate:"required"`
|
||||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"`
|
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
Tax float64 `json:"tax"`
|
Tax float64 `json:"tax"`
|
||||||
Total float64 `json:"total" validate:"required"`
|
Total float64 `json:"total" validate:"required"`
|
||||||
Items []CreateExpenseItemRequest `json:"items" 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 {
|
type CreateExpenseItemRequest struct {
|
||||||
@@ -27,16 +30,18 @@ type CreateExpenseItemRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UpdateExpenseRequest struct {
|
type UpdateExpenseRequest struct {
|
||||||
Receiver *string `json:"receiver,omitempty"`
|
Receiver *string `json:"receiver,omitempty"`
|
||||||
TransactionDate *string `json:"transaction_date,omitempty"`
|
TransactionDate *string `json:"transaction_date,omitempty"`
|
||||||
CodeNumber *string `json:"code_number,omitempty"`
|
CodeNumber *string `json:"code_number,omitempty"`
|
||||||
OutletID *string `json:"outlet_id,omitempty"`
|
OutletID *string `json:"outlet_id,omitempty"`
|
||||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"`
|
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
Tax *float64 `json:"tax,omitempty"`
|
Tax *float64 `json:"tax,omitempty"`
|
||||||
Total *float64 `json:"total,omitempty"`
|
Total *float64 `json:"total,omitempty"`
|
||||||
Reserved1 *string `json:"reserved1,omitempty"`
|
Reserved1 *string `json:"reserved1,omitempty"`
|
||||||
Items []UpdateExpenseItemRequest `json:"items,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 {
|
type UpdateExpenseItemRequest struct {
|
||||||
@@ -59,6 +64,7 @@ type ExpenseResponse struct {
|
|||||||
Tax float64 `json:"tax"`
|
Tax float64 `json:"tax"`
|
||||||
Total float64 `json:"total"`
|
Total float64 `json:"total"`
|
||||||
Reserved1 *string `json:"reserved1,omitempty"`
|
Reserved1 *string `json:"reserved1,omitempty"`
|
||||||
|
CashAdvanceID *uuid.UUID `json:"cash_advance_id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
Items []ExpenseItemResponse `json:"items,omitempty"`
|
Items []ExpenseItemResponse `json:"items,omitempty"`
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ type ListIngredientUnitConvertersResponse struct {
|
|||||||
type IngredientUnitsResponse struct {
|
type IngredientUnitsResponse struct {
|
||||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||||
IngredientName string `json:"ingredient_name"`
|
IngredientName string `json:"ingredient_name"`
|
||||||
BaseUnitID uuid.UUID `json:"base_unit_id"`
|
BaseUnitID *uuid.UUID `json:"base_unit_id"`
|
||||||
BaseUnitName string `json:"base_unit_name"`
|
BaseUnitName string `json:"base_unit_name"`
|
||||||
Units []*UnitResponse `json:"units"`
|
Units []*UnitResponse `json:"units"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ type ProductRecipeIngredientResponse struct {
|
|||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id"`
|
OutletID *uuid.UUID `json:"outlet_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
UnitID uuid.UUID `json:"unit_id"`
|
UnitID *uuid.UUID `json:"unit_id"`
|
||||||
Cost float64 `json:"cost"`
|
Cost float64 `json:"cost"`
|
||||||
Stock float64 `json:"stock"`
|
Stock float64 `json:"stock"`
|
||||||
IsSemiFinished bool `json:"is_semi_finished"`
|
IsSemiFinished bool `json:"is_semi_finished"`
|
||||||
|
|||||||
@@ -7,52 +7,63 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type CreatePurchaseOrderRequest struct {
|
type CreatePurchaseOrderRequest struct {
|
||||||
VendorID uuid.UUID `json:"vendor_id" validate:"required"`
|
VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"`
|
||||||
PONumber string `json:"po_number" validate:"required,min=1,max=50"`
|
PONumber string `json:"po_number" validate:"required,min=1,max=50"`
|
||||||
TransactionDate string `json:"transaction_date" validate:"required"` // Format: YYYY-MM-DD
|
TransactionDate string `json:"transaction_date" validate:"required"` // Format: YYYY-MM-DD
|
||||||
DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
|
DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
|
||||||
Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"`
|
Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"`
|
||||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||||
Message *string `json:"message,omitempty" validate:"omitempty"`
|
Message *string `json:"message,omitempty" validate:"omitempty"`
|
||||||
|
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"`
|
Items []CreatePurchaseOrderItemRequest `json:"items" validate:"required,min=1,dive"`
|
||||||
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreatePurchaseOrderItemRequest struct {
|
type CreatePurchaseOrderItemRequest struct {
|
||||||
IngredientID uuid.UUID `json:"ingredient_id" validate:"required"`
|
IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"`
|
||||||
PurchaseCategoryID uuid.UUID `json:"purchase_category_id" validate:"required"`
|
PurchaseCategoryID uuid.UUID `json:"purchase_category_id" validate:"required"`
|
||||||
Description *string `json:"description,omitempty" validate:"omitempty"`
|
Description *string `json:"description,omitempty" validate:"omitempty"`
|
||||||
Quantity float64 `json:"quantity" validate:"required,gt=0"`
|
Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"`
|
||||||
UnitID uuid.UUID `json:"unit_id" validate:"required"`
|
UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"`
|
||||||
Amount float64 `json:"amount" validate:"required,gte=0"`
|
Amount float64 `json:"amount" validate:"required,gte=0"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdatePurchaseOrderRequest struct {
|
type UpdatePurchaseOrderRequest struct {
|
||||||
VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"`
|
VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"`
|
||||||
PONumber *string `json:"po_number,omitempty" validate:"omitempty,min=1,max=50"`
|
PONumber *string `json:"po_number,omitempty" validate:"omitempty,min=1,max=50"`
|
||||||
TransactionDate *string `json:"transaction_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
|
TransactionDate *string `json:"transaction_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
|
||||||
DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
|
DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
|
||||||
Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"`
|
Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"`
|
||||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||||
Message *string `json:"message,omitempty" validate:"omitempty"`
|
Message *string `json:"message,omitempty" validate:"omitempty"`
|
||||||
|
// An empty string clears the team; omitting the field leaves it untouched.
|
||||||
|
TeamScope *string `json:"team_scope,omitempty" validate:"omitempty"`
|
||||||
|
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"`
|
||||||
|
// 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"`
|
Items []UpdatePurchaseOrderItemRequest `json:"items,omitempty" validate:"omitempty,dive"`
|
||||||
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdatePurchaseOrderItemRequest struct {
|
type UpdatePurchaseOrderItemRequest struct {
|
||||||
ID *uuid.UUID `json:"id,omitempty"` // Ignored. Supplying items replaces all existing PO items.
|
ID *uuid.UUID `json:"id,omitempty"` // For existing items
|
||||||
IngredientID *uuid.UUID `json:"ingredient_id" validate:"required"`
|
IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"`
|
||||||
PurchaseCategoryID *uuid.UUID `json:"purchase_category_id" validate:"required"`
|
PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty" validate:"omitempty"`
|
||||||
Description *string `json:"description,omitempty" validate:"omitempty"`
|
Description *string `json:"description,omitempty" validate:"omitempty"`
|
||||||
Quantity *float64 `json:"quantity" validate:"required,gt=0"`
|
Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"`
|
||||||
UnitID *uuid.UUID `json:"unit_id" validate:"required"`
|
UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"`
|
||||||
Amount *float64 `json:"amount,omitempty" validate:"omitempty,gte=0"`
|
Amount *float64 `json:"amount,omitempty" validate:"omitempty,gte=0"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PurchaseOrderResponse struct {
|
type PurchaseOrderResponse struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
VendorID uuid.UUID `json:"vendor_id"`
|
OutletID *uuid.UUID `json:"outlet_id"`
|
||||||
|
VendorID *uuid.UUID `json:"vendor_id"`
|
||||||
PONumber string `json:"po_number"`
|
PONumber string `json:"po_number"`
|
||||||
TransactionDate time.Time `json:"transaction_date"`
|
TransactionDate time.Time `json:"transaction_date"`
|
||||||
DueDate *time.Time `json:"due_date"`
|
DueDate *time.Time `json:"due_date"`
|
||||||
@@ -60,21 +71,38 @@ type PurchaseOrderResponse struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Message *string `json:"message"`
|
Message *string `json:"message"`
|
||||||
TotalAmount float64 `json:"total_amount"`
|
TotalAmount float64 `json:"total_amount"`
|
||||||
|
TeamScope *string `json:"team_scope"`
|
||||||
|
TeamCategoryID *uuid.UUID `json:"team_category_id"`
|
||||||
|
CashAdvanceID *uuid.UUID `json:"cash_advance_id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
Team *PurchaseTeamResponse `json:"team,omitempty"`
|
||||||
Vendor *VendorResponse `json:"vendor,omitempty"`
|
Vendor *VendorResponse `json:"vendor,omitempty"`
|
||||||
Items []PurchaseOrderItemResponse `json:"items,omitempty"`
|
Items []PurchaseOrderItemResponse `json:"items,omitempty"`
|
||||||
Attachments []PurchaseOrderAttachmentResponse `json:"attachments,omitempty"`
|
Attachments []PurchaseOrderAttachmentResponse `json:"attachments,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PurchaseTeamResponse is one entry of the team picker. Teams come from the parent
|
||||||
|
// product categories; Pusat is the extra entry that has no category behind it, so
|
||||||
|
// its CategoryID is null.
|
||||||
|
type PurchaseTeamResponse struct {
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
CategoryID *uuid.UUID `json:"category_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListPurchaseTeamsResponse struct {
|
||||||
|
Teams []PurchaseTeamResponse `json:"teams"`
|
||||||
|
}
|
||||||
|
|
||||||
type PurchaseOrderItemResponse struct {
|
type PurchaseOrderItemResponse struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
|
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
|
||||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
IngredientID *uuid.UUID `json:"ingredient_id"`
|
||||||
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
|
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Quantity float64 `json:"quantity"`
|
Quantity *float64 `json:"quantity"`
|
||||||
UnitID uuid.UUID `json:"unit_id"`
|
UnitID *uuid.UUID `json:"unit_id"`
|
||||||
Amount float64 `json:"amount"`
|
Amount float64 `json:"amount"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
@@ -92,13 +120,20 @@ type PurchaseOrderAttachmentResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ListPurchaseOrdersRequest struct {
|
type ListPurchaseOrdersRequest struct {
|
||||||
Page int `json:"page" validate:"min=1"`
|
Page int `json:"page" validate:"min=1"`
|
||||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||||
Search string `json:"search,omitempty"`
|
Search string `json:"search,omitempty"`
|
||||||
Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||||
VendorID *uuid.UUID `json:"vendor_id,omitempty"`
|
VendorID *uuid.UUID `json:"vendor_id,omitempty"`
|
||||||
StartDate *time.Time `json:"start_date,omitempty"`
|
// Team is the single-value form of the two filters below, so the team picker
|
||||||
EndDate *time.Time `json:"end_date,omitempty"`
|
// can send back what it was given: a parent category id, "central" for Pusat,
|
||||||
|
// or "none" for purchases with no team yet. It replaces them rather than
|
||||||
|
// narrowing alongside them.
|
||||||
|
Team string `json:"team,omitempty"`
|
||||||
|
TeamScope string `json:"team_scope,omitempty" validate:"omitempty,oneof=category central"`
|
||||||
|
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"`
|
||||||
|
StartDate *time.Time `json:"start_date,omitempty"`
|
||||||
|
EndDate *time.Time `json:"end_date,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListPurchaseOrdersResponse struct {
|
type ListPurchaseOrdersResponse struct {
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ type CreateUserRequest struct {
|
|||||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||||
Email string `json:"email" validate:"required,email"`
|
Email string `json:"email" validate:"required,email"`
|
||||||
Password string `json:"password" validate:"required,min=6"`
|
Password string `json:"password" validate:"required,min=6"`
|
||||||
Role string `json:"role" validate:"required,oneof=admin manager cashier waiter"`
|
Role string `json:"role" validate:"required,oneof=admin manager cashier waiter owner purchasing"`
|
||||||
Permissions map[string]interface{} `json:"permissions,omitempty"`
|
Permissions map[string]interface{} `json:"permissions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateUserRequest struct {
|
type UpdateUserRequest struct {
|
||||||
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
|
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
|
||||||
Email *string `json:"email,omitempty" validate:"omitempty,email"`
|
Email *string `json:"email,omitempty" validate:"omitempty,email"`
|
||||||
Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter"`
|
Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter owner purchasing"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
IsActive *bool `json:"is_active,omitempty"`
|
IsActive *bool `json:"is_active,omitempty"`
|
||||||
Permissions *map[string]interface{} `json:"permissions,omitempty"`
|
Permissions *map[string]interface{} `json:"permissions,omitempty"`
|
||||||
|
|||||||
+100
-16
@@ -27,6 +27,14 @@ type SalesAnalytics struct {
|
|||||||
NetSales float64 `json:"net_sales"`
|
NetSales float64 `json:"net_sales"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PurchaseTeamFilter narrows purchasing figures to a single team: a parent
|
||||||
|
// category, Pusat, or the purchases that carry no team at all. A nil filter
|
||||||
|
// leaves the figures spanning every team.
|
||||||
|
type PurchaseTeamFilter struct {
|
||||||
|
Scope string
|
||||||
|
CategoryID *uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
// PurchasingAnalytics represents purchasing analytics data
|
// PurchasingAnalytics represents purchasing analytics data
|
||||||
type PurchasingAnalytics struct {
|
type PurchasingAnalytics struct {
|
||||||
OutletName *string `json:"outlet_name,omitempty"`
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
@@ -34,6 +42,22 @@ type PurchasingAnalytics struct {
|
|||||||
Data []PurchasingAnalyticsData `json:"data"`
|
Data []PurchasingAnalyticsData `json:"data"`
|
||||||
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
||||||
VendorData []PurchasingVendorData `json:"vendor_data"`
|
VendorData []PurchasingVendorData `json:"vendor_data"`
|
||||||
|
TeamData []PurchasingTeamData `json:"team_data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurchasingTeamData is one team's share of the purchases: a parent category,
|
||||||
|
// Pusat, or the purchases charged to no team at all. Scope and CategoryID are
|
||||||
|
// what the team filter takes back, so a row can be clicked straight through.
|
||||||
|
type PurchasingTeamData struct {
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
CategoryID *uuid.UUID `json:"category_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
TotalPurchases float64 `json:"total_purchases"`
|
||||||
|
RawMaterialPurchases float64 `json:"raw_material_purchases"`
|
||||||
|
ExpensePurchases float64 `json:"expense_purchases"`
|
||||||
|
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Percentage float64 `json:"percentage"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PurchasingSummary struct {
|
type PurchasingSummary struct {
|
||||||
@@ -47,6 +71,7 @@ type PurchasingSummary struct {
|
|||||||
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
|
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
|
||||||
TotalIngredients int64 `json:"total_ingredients"`
|
TotalIngredients int64 `json:"total_ingredients"`
|
||||||
TotalVendors int64 `json:"total_vendors"`
|
TotalVendors int64 `json:"total_vendors"`
|
||||||
|
TotalTeams int64 `json:"total_teams"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PurchasingAnalyticsData struct {
|
type PurchasingAnalyticsData struct {
|
||||||
@@ -72,12 +97,12 @@ type PurchasingIngredientData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PurchasingVendorData struct {
|
type PurchasingVendorData struct {
|
||||||
VendorID uuid.UUID `json:"vendor_id"`
|
VendorID *uuid.UUID `json:"vendor_id"`
|
||||||
VendorName string `json:"vendor_name"`
|
VendorName string `json:"vendor_name"`
|
||||||
TotalCost float64 `json:"total_cost"`
|
TotalCost float64 `json:"total_cost"`
|
||||||
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||||
IngredientCount int64 `json:"ingredient_count"`
|
IngredientCount int64 `json:"ingredient_count"`
|
||||||
Quantity float64 `json:"quantity"`
|
Quantity float64 `json:"quantity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProductAnalytics struct {
|
type ProductAnalytics struct {
|
||||||
@@ -112,6 +137,39 @@ type ProductAnalyticsPerCategory struct {
|
|||||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProductAnalyticsPerParentCategory rolls the per-category figures up to the
|
||||||
|
// top-level category. A category without a parent is its own group.
|
||||||
|
type ProductAnalyticsPerParentCategory struct {
|
||||||
|
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||||
|
ParentCategoryName string `json:"parent_category_name"`
|
||||||
|
TotalRevenue float64 `json:"total_revenue"`
|
||||||
|
TotalQuantity int64 `json:"total_quantity"`
|
||||||
|
CategoryCount int64 `json:"category_count"`
|
||||||
|
ProductCount int64 `json:"product_count"`
|
||||||
|
OrderCount int64 `json:"order_count"`
|
||||||
|
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||||
|
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||||
|
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParentCategoryAnalyticsDetail is the drill-down for a single parent category:
|
||||||
|
// its own totals, the sub-categories underneath it, and the products in each.
|
||||||
|
type ParentCategoryAnalyticsDetail struct {
|
||||||
|
ParentCategoryID uuid.UUID
|
||||||
|
ParentCategoryName string
|
||||||
|
Summary *ProductAnalyticsPerParentCategory
|
||||||
|
Categories []*ProductAnalyticsPerCategory
|
||||||
|
Products []*ProductAnalytics
|
||||||
|
}
|
||||||
|
|
||||||
|
// BudgetCutOffWeek is one Monday-to-Sunday bucket of revenue, used to derive the
|
||||||
|
// weekly spending limits.
|
||||||
|
type BudgetCutOffWeek struct {
|
||||||
|
WeekStart time.Time `json:"week_start"`
|
||||||
|
Revenue float64 `json:"revenue"`
|
||||||
|
OrderCount int64 `json:"order_count"`
|
||||||
|
}
|
||||||
|
|
||||||
// DashboardOverview represents dashboard overview data
|
// DashboardOverview represents dashboard overview data
|
||||||
type DashboardOverview struct {
|
type DashboardOverview struct {
|
||||||
TotalSales float64 `json:"total_sales"`
|
TotalSales float64 `json:"total_sales"`
|
||||||
@@ -120,19 +178,36 @@ type DashboardOverview struct {
|
|||||||
TotalCustomers int64 `json:"total_customers"`
|
TotalCustomers int64 `json:"total_customers"`
|
||||||
VoidedOrders int64 `json:"voided_orders"`
|
VoidedOrders int64 `json:"voided_orders"`
|
||||||
RefundedOrders int64 `json:"refunded_orders"`
|
RefundedOrders int64 `json:"refunded_orders"`
|
||||||
|
TotalItemSold int64 `json:"total_item_sold"`
|
||||||
|
TotalLowStock int64 `json:"total_low_stock"`
|
||||||
|
TotalProductActive int64 `json:"total_product_active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProfitLossAnalytics struct {
|
type ProfitLossAnalytics struct {
|
||||||
Summary ProfitLossSummary
|
Summary ProfitLossSummary
|
||||||
Data []ProfitLossData
|
Data []ProfitLossData
|
||||||
ProductData []ProductProfitData
|
ProductData []ProductProfitData
|
||||||
TodayRevenue float64
|
TodayRevenue float64
|
||||||
TodayCost float64
|
TodayCost float64
|
||||||
MtdRevenue float64
|
MtdRevenue float64
|
||||||
MtdCost float64
|
MtdCost float64
|
||||||
TodayExpenseByCategory []ExpenseCategoryTotal
|
TodayPurchasing float64
|
||||||
MtdExpenseByCategory []ExpenseCategoryTotal
|
MtdPurchasing float64
|
||||||
OperationalExpenseItems []OperationalExpenseItem
|
TodayPurchasingRawMaterial float64
|
||||||
|
MtdPurchasingRawMaterial float64
|
||||||
|
TodayPurchasingExpense float64
|
||||||
|
MtdPurchasingExpense float64
|
||||||
|
PurchasingItems []PurchasingItemDetail
|
||||||
|
TodayExpenseByCategory []ExpenseCategoryTotal
|
||||||
|
MtdExpenseByCategory []ExpenseCategoryTotal
|
||||||
|
OperationalExpenseItems []OperationalExpenseItem
|
||||||
|
}
|
||||||
|
|
||||||
|
type PurchasingItemDetail struct {
|
||||||
|
Date time.Time
|
||||||
|
Item string
|
||||||
|
Quantity float64
|
||||||
|
Amount float64
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProfitLossSummary struct {
|
type ProfitLossSummary struct {
|
||||||
@@ -216,3 +291,12 @@ type ExclusiveSummaryDailyTransaction struct {
|
|||||||
Amount float64
|
Amount float64
|
||||||
Source string
|
Source string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryBankBalance struct {
|
||||||
|
Bank string
|
||||||
|
OpeningBalance *float64
|
||||||
|
IncomingMutation *float64
|
||||||
|
OutgoingMutation *float64
|
||||||
|
ClosingBalance *float64
|
||||||
|
Notes *string
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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"`
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ type Category struct {
|
|||||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
|
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
|
||||||
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"`
|
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"`
|
||||||
|
ParentID *uuid.UUID `gorm:"type:uuid;index" json:"parent_id"`
|
||||||
|
Parent *Category `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
|
||||||
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
|
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
|
||||||
Description *string `gorm:"type:text" json:"description"`
|
Description *string `gorm:"type:text" json:"description"`
|
||||||
Order int `gorm:"default:0" json:"order"`
|
Order int `gorm:"default:0" json:"order"`
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ func GetAllEntities() []interface{} {
|
|||||||
&NotificationDelivery{},
|
&NotificationDelivery{},
|
||||||
&ProductOutletPrice{},
|
&ProductOutletPrice{},
|
||||||
&Expense{},
|
&Expense{},
|
||||||
|
&CashAdvance{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,14 @@ type Expense struct {
|
|||||||
Tax float64 `gorm:"type:decimal(15,2);not null;default:0" json:"tax"`
|
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"`
|
Total float64 `gorm:"type:decimal(15,2);not null;default:0" json:"total"`
|
||||||
Reserved1 *string `gorm:"type:text" json:"reserved1"`
|
Reserved1 *string `gorm:"type:text" json:"reserved1"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
// CashAdvanceID is set when the expense was paid out of cash advanced to a team.
|
||||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
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"`
|
Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||||
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,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"`
|
Items []ExpenseItem `gorm:"foreignKey:ExpenseID" json:"items,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ type Ingredient struct {
|
|||||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"`
|
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"`
|
||||||
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"`
|
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"`
|
||||||
Name string `gorm:"not null;size:255" json:"name"`
|
Name string `gorm:"not null;size:255" json:"name"`
|
||||||
UnitID uuid.UUID `gorm:"type:uuid;not null;index" json:"unit_id"`
|
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"`
|
||||||
Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost"`
|
Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost"`
|
||||||
Stock float64 `gorm:"type:decimal(10,2);default:0.00" json:"stock"`
|
Stock float64 `gorm:"type:decimal(10,2);default:0.00" json:"stock"`
|
||||||
IsSemiFinished bool `gorm:"default:false" json:"is_semi_finished"`
|
IsSemiFinished bool `gorm:"default:false" json:"is_semi_finished"`
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import (
|
|||||||
type PurchaseOrder struct {
|
type PurchaseOrder struct {
|
||||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id" validate:"required"`
|
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id" validate:"required"`
|
||||||
VendorID uuid.UUID `gorm:"type:uuid;not null" json:"vendor_id" validate:"required"`
|
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id" validate:"omitempty"`
|
||||||
|
VendorID *uuid.UUID `gorm:"type:uuid" json:"vendor_id" validate:"omitempty"`
|
||||||
PONumber string `gorm:"not null;size:50" json:"po_number" validate:"required,min=1,max=50"`
|
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"`
|
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" json:"due_date" validate:"omitempty"`
|
||||||
@@ -19,11 +20,21 @@ type PurchaseOrder struct {
|
|||||||
Status string `gorm:"not null;size:20;default:'draft'" json:"status" validate:"required,oneof=draft sent approved received cancelled"`
|
Status string `gorm:"not null;size:20;default:'draft'" json:"status" validate:"required,oneof=draft sent approved received cancelled"`
|
||||||
Message *string `gorm:"type:text" json:"message" validate:"omitempty"`
|
Message *string `gorm:"type:text" json:"message" validate:"omitempty"`
|
||||||
TotalAmount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"total_amount"`
|
TotalAmount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"total_amount"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
// TeamScope is 'category' when the purchase is charged to a parent category, or
|
||||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
// '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"`
|
Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||||
|
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
|
||||||
Vendor *Vendor `gorm:"foreignKey:VendorID" json:"vendor,omitempty"`
|
Vendor *Vendor `gorm:"foreignKey:VendorID" json:"vendor,omitempty"`
|
||||||
|
TeamCategory *Category `gorm:"foreignKey:TeamCategoryID" json:"team_category,omitempty"`
|
||||||
|
CashAdvance *CashAdvance `gorm:"foreignKey:CashAdvanceID" json:"cash_advance,omitempty"`
|
||||||
Items []PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderID" json:"items,omitempty"`
|
Items []PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderID" json:"items,omitempty"`
|
||||||
Attachments []PurchaseOrderAttachment `gorm:"foreignKey:PurchaseOrderID" json:"attachments,omitempty"`
|
Attachments []PurchaseOrderAttachment `gorm:"foreignKey:PurchaseOrderID" json:"attachments,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -41,16 +52,16 @@ func (PurchaseOrder) TableName() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PurchaseOrderItem struct {
|
type PurchaseOrderItem struct {
|
||||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||||
PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"`
|
PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"`
|
||||||
IngredientID uuid.UUID `gorm:"type:uuid;not null" json:"ingredient_id" validate:"required"`
|
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"`
|
PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id" validate:"required"`
|
||||||
Description *string `gorm:"type:text" json:"description" validate:"omitempty"`
|
Description *string `gorm:"type:text" json:"description" validate:"omitempty"`
|
||||||
Quantity float64 `gorm:"type:decimal(10,3);not null" json:"quantity" validate:"required,gt=0"`
|
Quantity *float64 `gorm:"type:decimal(10,3)" json:"quantity" validate:"omitempty,gt=0"`
|
||||||
UnitID uuid.UUID `gorm:"type:uuid;not null" json:"unit_id" validate:"required"`
|
UnitID *uuid.UUID `gorm:"type:uuid" json:"unit_id" validate:"omitempty"`
|
||||||
Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"`
|
Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||||
|
|
||||||
PurchaseOrder *PurchaseOrder `gorm:"foreignKey:PurchaseOrderID" json:"purchase_order,omitempty"`
|
PurchaseOrder *PurchaseOrder `gorm:"foreignKey:PurchaseOrderID" json:"purchase_order,omitempty"`
|
||||||
Ingredient *Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"`
|
Ingredient *Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"`
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import (
|
|||||||
type UserRole string
|
type UserRole string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
RoleAdmin UserRole = "admin"
|
RoleAdmin UserRole = "admin"
|
||||||
RoleManager UserRole = "manager"
|
RoleManager UserRole = "manager"
|
||||||
RoleCashier UserRole = "cashier"
|
RoleCashier UserRole = "cashier"
|
||||||
RoleWaiter UserRole = "waiter"
|
RoleWaiter UserRole = "waiter"
|
||||||
|
RoleOwner UserRole = "owner"
|
||||||
|
RolePurchasing UserRole = "purchasing"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Permissions map[string]interface{}
|
type Permissions map[string]interface{}
|
||||||
@@ -46,7 +48,7 @@ type User struct {
|
|||||||
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
|
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"`
|
Email string `gorm:"uniqueIndex;not null;size:255" json:"email" validate:"required,email"`
|
||||||
PasswordHash string `gorm:"not null;size:255" json:"-"`
|
PasswordHash string `gorm:"not null;size:255" json:"-"`
|
||||||
Role UserRole `gorm:"not null;size:50" json:"role" validate:"required,oneof=admin manager cashier waiter"`
|
Role UserRole `gorm:"not null;size:50" json:"role" validate:"required,oneof=admin manager cashier waiter owner purchasing"`
|
||||||
Permissions Permissions `gorm:"type:jsonb;default:'{}'" json:"permissions"`
|
Permissions Permissions `gorm:"type:jsonb;default:'{}'" json:"permissions"`
|
||||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
|||||||
@@ -157,6 +157,55 @@ func (h *AnalyticsHandler) GetProductAnalyticsPerCategory(c *gin.Context) {
|
|||||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetProductAnalyticsPerCategory")
|
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetProductAnalyticsPerCategory")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AnalyticsHandler) GetProductAnalyticsPerParentCategory(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
contextInfo := appcontext.FromGinContext(ctx)
|
||||||
|
|
||||||
|
var req contract.ProductAnalyticsPerParentCategoryRequest
|
||||||
|
if err := c.ShouldBindQuery(&req); err != nil {
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetProductAnalyticsPerParentCategory", err.Error())}), "AnalyticsHandler::GetProductAnalyticsPerParentCategory")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.OrganizationID = contextInfo.OrganizationID
|
||||||
|
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
|
||||||
|
modelReq := transformer.ProductAnalyticsPerParentCategoryContractToModel(&req)
|
||||||
|
|
||||||
|
response, err := h.analyticsService.GetProductAnalyticsPerParentCategory(ctx, modelReq)
|
||||||
|
if err != nil {
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetProductAnalyticsPerParentCategory", err.Error())}), "AnalyticsHandler::GetProductAnalyticsPerParentCategory")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contractResp := transformer.ProductAnalyticsPerParentCategoryModelToContract(response)
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetProductAnalyticsPerParentCategory")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnalyticsHandler) GetParentCategoryAnalyticsDetail(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
contextInfo := appcontext.FromGinContext(ctx)
|
||||||
|
|
||||||
|
var req contract.ParentCategoryAnalyticsDetailRequest
|
||||||
|
if err := c.ShouldBindQuery(&req); err != nil {
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetParentCategoryAnalyticsDetail", err.Error())}), "AnalyticsHandler::GetParentCategoryAnalyticsDetail")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.OrganizationID = contextInfo.OrganizationID
|
||||||
|
req.ParentCategoryID = c.Param("parent_category_id")
|
||||||
|
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
|
||||||
|
modelReq := transformer.ParentCategoryAnalyticsDetailContractToModel(&req)
|
||||||
|
|
||||||
|
response, err := h.analyticsService.GetParentCategoryAnalyticsDetail(ctx, modelReq)
|
||||||
|
if err != nil {
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetParentCategoryAnalyticsDetail", err.Error())}), "AnalyticsHandler::GetParentCategoryAnalyticsDetail")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contractResp := transformer.ParentCategoryAnalyticsDetailModelToContract(response)
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetParentCategoryAnalyticsDetail")
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AnalyticsHandler) GetDashboardAnalytics(c *gin.Context) {
|
func (h *AnalyticsHandler) GetDashboardAnalytics(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
contextInfo := appcontext.FromGinContext(ctx)
|
contextInfo := appcontext.FromGinContext(ctx)
|
||||||
@@ -266,3 +315,31 @@ func (h *AnalyticsHandler) GetExclusiveSummaryMonthly(c *gin.Context) {
|
|||||||
contractResp := transformer.ExclusiveSummaryMonthlyModelToContract(response)
|
contractResp := transformer.ExclusiveSummaryMonthlyModelToContract(response)
|
||||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryMonthly")
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
@@ -191,6 +191,18 @@ func (h *CategoryHandler) ListCategories(c *gin.Context) {
|
|||||||
req.OutletID = &outletID
|
req.OutletID = &outletID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if parentIDStr := c.Query("parent_id"); parentIDStr != "" {
|
||||||
|
if parentID, err := uuid.Parse(parentIDStr); err == nil {
|
||||||
|
req.ParentID = &parentID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// type=parent -> top level categories only
|
||||||
|
// type=child -> leaf categories (sub categories + top level ones without children)
|
||||||
|
if categoryType := c.Query("type"); categoryType != "" {
|
||||||
|
req.Type = categoryType
|
||||||
|
}
|
||||||
|
|
||||||
validationError, validationErrorCode := h.categoryValidator.ValidateListCategoriesRequest(req)
|
validationError, validationErrorCode := h.categoryValidator.ValidateListCategoriesRequest(req)
|
||||||
if validationError != nil {
|
if validationError != nil {
|
||||||
logger.FromContext(ctx).WithError(validationError).Error("CategoryHandler::ListCategories -> request validation failed")
|
logger.FromContext(ctx).WithError(validationError).Error("CategoryHandler::ListCategories -> request validation failed")
|
||||||
|
|||||||
@@ -176,6 +176,20 @@ func (h *PurchaseOrderHandler) ListPurchaseOrders(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if team := c.Query("team"); team != "" {
|
||||||
|
req.Team = team
|
||||||
|
}
|
||||||
|
|
||||||
|
if teamScope := c.Query("team_scope"); teamScope != "" {
|
||||||
|
req.TeamScope = teamScope
|
||||||
|
}
|
||||||
|
|
||||||
|
if teamCategoryIDStr := c.Query("team_category_id"); teamCategoryIDStr != "" {
|
||||||
|
if teamCategoryID, err := uuid.Parse(teamCategoryIDStr); err == nil {
|
||||||
|
req.TeamCategoryID = &teamCategoryID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if startDateStr := c.Query("start_date"); startDateStr != "" {
|
if startDateStr := c.Query("start_date"); startDateStr != "" {
|
||||||
if startDate, err := time.Parse("2006-01-02", startDateStr); err == nil {
|
if startDate, err := time.Parse("2006-01-02", startDateStr); err == nil {
|
||||||
req.StartDate = &startDate
|
req.StartDate = &startDate
|
||||||
@@ -224,6 +238,21 @@ func (h *PurchaseOrderHandler) GetPurchaseOrdersByStatus(c *gin.Context) {
|
|||||||
util.HandleResponse(c.Writer, c.Request, poResponse, "PurchaseOrderHandler::GetPurchaseOrdersByStatus")
|
util.HandleResponse(c.Writer, c.Request, poResponse, "PurchaseOrderHandler::GetPurchaseOrdersByStatus")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListPurchaseTeams serves the team picker for the purchase form: the parent
|
||||||
|
// categories of the caller's outlet, plus Pusat.
|
||||||
|
func (h *PurchaseOrderHandler) ListPurchaseTeams(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
contextInfo := appcontext.FromGinContext(ctx)
|
||||||
|
|
||||||
|
teamsResponse := h.purchaseOrderService.ListPurchaseTeams(ctx, contextInfo)
|
||||||
|
if teamsResponse.HasErrors() {
|
||||||
|
errorResp := teamsResponse.GetErrors()[0]
|
||||||
|
logger.FromContext(ctx).WithError(errorResp).Error("PurchaseOrderHandler::ListPurchaseTeams -> Failed to list purchase teams from service")
|
||||||
|
}
|
||||||
|
|
||||||
|
util.HandleResponse(c.Writer, c.Request, teamsResponse, "PurchaseOrderHandler::ListPurchaseTeams")
|
||||||
|
}
|
||||||
|
|
||||||
func (h *PurchaseOrderHandler) GetOverduePurchaseOrders(c *gin.Context) {
|
func (h *PurchaseOrderHandler) GetOverduePurchaseOrders(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
contextInfo := appcontext.FromGinContext(ctx)
|
contextInfo := appcontext.FromGinContext(ctx)
|
||||||
|
|||||||
@@ -66,3 +66,35 @@ func (h *ReportHandler) GetDailyTransactionReportPDF(c *gin.Context) {
|
|||||||
"file_name": fileName,
|
"file_name": fileName,
|
||||||
}), "ReportHandler::GetDailyTransactionReportPDF")
|
}), "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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@ func CreateCategoryRequestToEntity(req *models.CreateCategoryRequest) *entities.
|
|||||||
return &entities.Category{
|
return &entities.Category{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
|
ParentID: req.ParentID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
Order: req.Order,
|
Order: req.Order,
|
||||||
@@ -85,10 +86,19 @@ func CategoryEntityToResponse(entity *entities.Category) *models.CategoryRespons
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parent name is only available when the Parent association is preloaded
|
||||||
|
var parentName *string
|
||||||
|
if entity.Parent != nil {
|
||||||
|
name := entity.Parent.Name
|
||||||
|
parentName = &name
|
||||||
|
}
|
||||||
|
|
||||||
return &models.CategoryResponse{
|
return &models.CategoryResponse{
|
||||||
ID: entity.ID,
|
ID: entity.ID,
|
||||||
OrganizationID: entity.OrganizationID,
|
OrganizationID: entity.OrganizationID,
|
||||||
OutletID: entity.OutletID,
|
OutletID: entity.OutletID,
|
||||||
|
ParentID: entity.ParentID,
|
||||||
|
ParentName: parentName,
|
||||||
Name: entity.Name,
|
Name: entity.Name,
|
||||||
Description: entity.Description,
|
Description: entity.Description,
|
||||||
ImageURL: imageURL,
|
ImageURL: imageURL,
|
||||||
@@ -127,6 +137,10 @@ func UpdateCategoryEntityFromRequest(entity *entities.Category, req *models.Upda
|
|||||||
if req.OutletID != nil {
|
if req.OutletID != nil {
|
||||||
entity.OutletID = req.OutletID
|
entity.OutletID = req.OutletID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.ParentID != nil {
|
||||||
|
entity.ParentID = req.ParentID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func CategoryEntitiesToModels(entities []*entities.Category) []*models.Category {
|
func CategoryEntitiesToModels(entities []*entities.Category) []*models.Category {
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ func ExpenseEntityToResponse(entity *entities.Expense) *models.ExpenseResponse {
|
|||||||
Tax: entity.Tax,
|
Tax: entity.Tax,
|
||||||
Total: entity.Total,
|
Total: entity.Total,
|
||||||
Reserved1: entity.Reserved1,
|
Reserved1: entity.Reserved1,
|
||||||
|
CashAdvanceID: entity.CashAdvanceID,
|
||||||
CreatedAt: entity.CreatedAt,
|
CreatedAt: entity.CreatedAt,
|
||||||
UpdatedAt: entity.UpdatedAt,
|
UpdatedAt: entity.UpdatedAt,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,33 @@
|
|||||||
package mappers
|
package mappers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// purchaseTeamFromEntity renders the team a purchase order is charged to. It returns
|
||||||
|
// nil when no team was chosen, which is distinct from a purchase charged to Pusat.
|
||||||
|
// The category name is only filled in when TeamCategory was preloaded.
|
||||||
|
func purchaseTeamFromEntity(entity *entities.PurchaseOrder) *models.PurchaseTeam {
|
||||||
|
if entity.TeamScope == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
team := &models.PurchaseTeam{Scope: *entity.TeamScope}
|
||||||
|
switch *entity.TeamScope {
|
||||||
|
case constants.PurchaseTeamScopeCentral:
|
||||||
|
team.Name = constants.PurchaseTeamCentralName
|
||||||
|
case constants.PurchaseTeamScopeCategory:
|
||||||
|
team.CategoryID = entity.TeamCategoryID
|
||||||
|
if entity.TeamCategory != nil {
|
||||||
|
team.Name = entity.TeamCategory.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return team
|
||||||
|
}
|
||||||
|
|
||||||
func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.PurchaseOrder {
|
func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.PurchaseOrder {
|
||||||
if entity == nil {
|
if entity == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -13,6 +36,7 @@ func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.Purchase
|
|||||||
return &models.PurchaseOrder{
|
return &models.PurchaseOrder{
|
||||||
ID: entity.ID,
|
ID: entity.ID,
|
||||||
OrganizationID: entity.OrganizationID,
|
OrganizationID: entity.OrganizationID,
|
||||||
|
OutletID: entity.OutletID,
|
||||||
VendorID: entity.VendorID,
|
VendorID: entity.VendorID,
|
||||||
PONumber: entity.PONumber,
|
PONumber: entity.PONumber,
|
||||||
TransactionDate: entity.TransactionDate,
|
TransactionDate: entity.TransactionDate,
|
||||||
@@ -21,6 +45,9 @@ func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.Purchase
|
|||||||
Status: entity.Status,
|
Status: entity.Status,
|
||||||
Message: entity.Message,
|
Message: entity.Message,
|
||||||
TotalAmount: entity.TotalAmount,
|
TotalAmount: entity.TotalAmount,
|
||||||
|
TeamScope: entity.TeamScope,
|
||||||
|
TeamCategoryID: entity.TeamCategoryID,
|
||||||
|
CashAdvanceID: entity.CashAdvanceID,
|
||||||
CreatedAt: entity.CreatedAt,
|
CreatedAt: entity.CreatedAt,
|
||||||
UpdatedAt: entity.UpdatedAt,
|
UpdatedAt: entity.UpdatedAt,
|
||||||
}
|
}
|
||||||
@@ -34,6 +61,7 @@ func PurchaseOrderModelToEntity(model *models.PurchaseOrder) *entities.PurchaseO
|
|||||||
return &entities.PurchaseOrder{
|
return &entities.PurchaseOrder{
|
||||||
ID: model.ID,
|
ID: model.ID,
|
||||||
OrganizationID: model.OrganizationID,
|
OrganizationID: model.OrganizationID,
|
||||||
|
OutletID: model.OutletID,
|
||||||
VendorID: model.VendorID,
|
VendorID: model.VendorID,
|
||||||
PONumber: model.PONumber,
|
PONumber: model.PONumber,
|
||||||
TransactionDate: model.TransactionDate,
|
TransactionDate: model.TransactionDate,
|
||||||
@@ -42,6 +70,9 @@ func PurchaseOrderModelToEntity(model *models.PurchaseOrder) *entities.PurchaseO
|
|||||||
Status: model.Status,
|
Status: model.Status,
|
||||||
Message: model.Message,
|
Message: model.Message,
|
||||||
TotalAmount: model.TotalAmount,
|
TotalAmount: model.TotalAmount,
|
||||||
|
TeamScope: model.TeamScope,
|
||||||
|
TeamCategoryID: model.TeamCategoryID,
|
||||||
|
CashAdvanceID: model.CashAdvanceID,
|
||||||
CreatedAt: model.CreatedAt,
|
CreatedAt: model.CreatedAt,
|
||||||
UpdatedAt: model.UpdatedAt,
|
UpdatedAt: model.UpdatedAt,
|
||||||
}
|
}
|
||||||
@@ -55,6 +86,7 @@ func PurchaseOrderEntityToResponse(entity *entities.PurchaseOrder) *models.Purch
|
|||||||
response := &models.PurchaseOrderResponse{
|
response := &models.PurchaseOrderResponse{
|
||||||
ID: entity.ID,
|
ID: entity.ID,
|
||||||
OrganizationID: entity.OrganizationID,
|
OrganizationID: entity.OrganizationID,
|
||||||
|
OutletID: entity.OutletID,
|
||||||
VendorID: entity.VendorID,
|
VendorID: entity.VendorID,
|
||||||
PONumber: entity.PONumber,
|
PONumber: entity.PONumber,
|
||||||
TransactionDate: entity.TransactionDate,
|
TransactionDate: entity.TransactionDate,
|
||||||
@@ -63,8 +95,12 @@ func PurchaseOrderEntityToResponse(entity *entities.PurchaseOrder) *models.Purch
|
|||||||
Status: entity.Status,
|
Status: entity.Status,
|
||||||
Message: entity.Message,
|
Message: entity.Message,
|
||||||
TotalAmount: entity.TotalAmount,
|
TotalAmount: entity.TotalAmount,
|
||||||
|
TeamScope: entity.TeamScope,
|
||||||
|
TeamCategoryID: entity.TeamCategoryID,
|
||||||
|
CashAdvanceID: entity.CashAdvanceID,
|
||||||
CreatedAt: entity.CreatedAt,
|
CreatedAt: entity.CreatedAt,
|
||||||
UpdatedAt: entity.UpdatedAt,
|
UpdatedAt: entity.UpdatedAt,
|
||||||
|
Team: purchaseTeamFromEntity(entity),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map vendor if present
|
// Map vendor if present
|
||||||
|
|||||||
@@ -82,7 +82,11 @@ func (m *AuthMiddleware) RequireRole(allowedRoles ...string) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc {
|
func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc {
|
||||||
return m.RequireRole("superadmin", "admin", "manager")
|
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AuthMiddleware) RequireAdminOrManagerOrPurchasing() gin.HandlerFunc {
|
||||||
|
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
|
func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
|
||||||
|
|||||||
+225
-15
@@ -1,8 +1,12 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
|
"apskel-pos-be/internal/entities"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,6 +23,7 @@ type PaymentMethodAnalyticsRequest struct {
|
|||||||
type PaymentMethodAnalyticsResponse struct {
|
type PaymentMethodAnalyticsResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
GroupBy string `json:"group_by"`
|
GroupBy string `json:"group_by"`
|
||||||
@@ -58,6 +63,7 @@ type SalesAnalyticsRequest struct {
|
|||||||
type SalesAnalyticsResponse struct {
|
type SalesAnalyticsResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
GroupBy string `json:"group_by"`
|
GroupBy string `json:"group_by"`
|
||||||
@@ -91,9 +97,34 @@ type SalesAnalyticsData struct {
|
|||||||
type PurchasingAnalyticsRequest struct {
|
type PurchasingAnalyticsRequest struct {
|
||||||
OrganizationID uuid.UUID `validate:"required"`
|
OrganizationID uuid.UUID `validate:"required"`
|
||||||
OutletID *uuid.UUID `validate:"omitempty"`
|
OutletID *uuid.UUID `validate:"omitempty"`
|
||||||
DateFrom time.Time `validate:"required"`
|
// Team is the raw value the team picker sends: a parent category id,
|
||||||
DateTo time.Time `validate:"required"`
|
// "central" for Pusat, "none" for purchases with no team, or empty for all.
|
||||||
GroupBy string `validate:"omitempty,oneof=day hour week month"`
|
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
|
// PurchasingAnalyticsResponse represents the response for purchasing analytics
|
||||||
@@ -101,6 +132,7 @@ type PurchasingAnalyticsResponse struct {
|
|||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
OutletName *string `json:"outlet_name,omitempty"`
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
|
Team string `json:"team,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
GroupBy string `json:"group_by"`
|
GroupBy string `json:"group_by"`
|
||||||
@@ -108,6 +140,20 @@ type PurchasingAnalyticsResponse struct {
|
|||||||
Data []PurchasingAnalyticsData `json:"data"`
|
Data []PurchasingAnalyticsData `json:"data"`
|
||||||
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
||||||
VendorData []PurchasingVendorData `json:"vendor_data"`
|
VendorData []PurchasingVendorData `json:"vendor_data"`
|
||||||
|
TeamData []PurchasingTeamData `json:"team_data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurchasingTeamData represents purchasing analytics for a single team
|
||||||
|
type PurchasingTeamData struct {
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
CategoryID *uuid.UUID `json:"category_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
TotalPurchases float64 `json:"total_purchases"`
|
||||||
|
RawMaterialPurchases float64 `json:"raw_material_purchases"`
|
||||||
|
ExpensePurchases float64 `json:"expense_purchases"`
|
||||||
|
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Percentage float64 `json:"percentage"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PurchasingSummary represents the summary of purchasing analytics
|
// PurchasingSummary represents the summary of purchasing analytics
|
||||||
@@ -122,6 +168,7 @@ type PurchasingSummary struct {
|
|||||||
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
|
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
|
||||||
TotalIngredients int64 `json:"total_ingredients"`
|
TotalIngredients int64 `json:"total_ingredients"`
|
||||||
TotalVendors int64 `json:"total_vendors"`
|
TotalVendors int64 `json:"total_vendors"`
|
||||||
|
TotalTeams int64 `json:"total_teams"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PurchasingAnalyticsData represents purchasing analytics by time period
|
// PurchasingAnalyticsData represents purchasing analytics by time period
|
||||||
@@ -150,12 +197,12 @@ type PurchasingIngredientData struct {
|
|||||||
|
|
||||||
// PurchasingVendorData represents purchasing analytics for a vendor
|
// PurchasingVendorData represents purchasing analytics for a vendor
|
||||||
type PurchasingVendorData struct {
|
type PurchasingVendorData struct {
|
||||||
VendorID uuid.UUID `json:"vendor_id"`
|
VendorID *uuid.UUID `json:"vendor_id"`
|
||||||
VendorName string `json:"vendor_name"`
|
VendorName string `json:"vendor_name"`
|
||||||
TotalCost float64 `json:"total_cost"`
|
TotalCost float64 `json:"total_cost"`
|
||||||
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||||
IngredientCount int64 `json:"ingredient_count"`
|
IngredientCount int64 `json:"ingredient_count"`
|
||||||
Quantity float64 `json:"quantity"`
|
Quantity float64 `json:"quantity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProductAnalyticsRequest represents the request for product analytics
|
// ProductAnalyticsRequest represents the request for product analytics
|
||||||
@@ -171,6 +218,7 @@ type ProductAnalyticsRequest struct {
|
|||||||
type ProductAnalyticsResponse struct {
|
type ProductAnalyticsResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
Data []ProductAnalyticsData `json:"data"`
|
Data []ProductAnalyticsData `json:"data"`
|
||||||
@@ -208,6 +256,7 @@ type ProductAnalyticsPerCategoryRequest struct {
|
|||||||
type ProductAnalyticsPerCategoryResponse struct {
|
type ProductAnalyticsPerCategoryResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
Data []ProductAnalyticsPerCategoryData `json:"data"`
|
Data []ProductAnalyticsPerCategoryData `json:"data"`
|
||||||
@@ -225,6 +274,135 @@ type ProductAnalyticsPerCategoryData struct {
|
|||||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProductAnalyticsPerParentCategoryRequest represents the request for product analytics per parent category
|
||||||
|
type ProductAnalyticsPerParentCategoryRequest struct {
|
||||||
|
OrganizationID uuid.UUID `validate:"required"`
|
||||||
|
OutletID *uuid.UUID `validate:"omitempty"`
|
||||||
|
DateFrom time.Time `validate:"required"`
|
||||||
|
DateTo time.Time `validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductAnalyticsPerParentCategoryResponse represents the response for product analytics per parent category
|
||||||
|
type ProductAnalyticsPerParentCategoryResponse struct {
|
||||||
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
|
DateFrom time.Time `json:"date_from"`
|
||||||
|
DateTo time.Time `json:"date_to"`
|
||||||
|
Data []ProductAnalyticsPerParentCategoryData `json:"data"`
|
||||||
|
Budget BudgetCutOff `json:"budget"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductAnalyticsPerParentCategoryData struct {
|
||||||
|
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||||
|
ParentCategoryName string `json:"parent_category_name"`
|
||||||
|
TotalRevenue float64 `json:"total_revenue"`
|
||||||
|
TotalQuantity int64 `json:"total_quantity"`
|
||||||
|
CategoryCount int64 `json:"category_count"`
|
||||||
|
ProductCount int64 `json:"product_count"`
|
||||||
|
OrderCount int64 `json:"order_count"`
|
||||||
|
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||||
|
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||||
|
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParentCategoryAnalyticsDetailRequest represents the request for the drill-down of one parent category
|
||||||
|
type ParentCategoryAnalyticsDetailRequest struct {
|
||||||
|
OrganizationID uuid.UUID `validate:"required"`
|
||||||
|
ParentCategoryID uuid.UUID `validate:"required"`
|
||||||
|
OutletID *uuid.UUID `validate:"omitempty"`
|
||||||
|
DateFrom time.Time `validate:"required"`
|
||||||
|
DateTo time.Time `validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParentCategoryAnalyticsDetailResponse represents the drill-down of one parent category
|
||||||
|
type ParentCategoryAnalyticsDetailResponse struct {
|
||||||
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
|
DateFrom time.Time `json:"date_from"`
|
||||||
|
DateTo time.Time `json:"date_to"`
|
||||||
|
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||||
|
ParentCategoryName string `json:"parent_category_name"`
|
||||||
|
Summary ParentCategoryAnalyticsDetailSummary `json:"summary"`
|
||||||
|
Categories []ParentCategoryAnalyticsDetailData `json:"categories"`
|
||||||
|
Budget BudgetCutOff `json:"budget"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParentCategoryAnalyticsDetailSummary struct {
|
||||||
|
TotalRevenue float64 `json:"total_revenue"`
|
||||||
|
TotalQuantity int64 `json:"total_quantity"`
|
||||||
|
CategoryCount int64 `json:"category_count"`
|
||||||
|
ProductCount int64 `json:"product_count"`
|
||||||
|
OrderCount int64 `json:"order_count"`
|
||||||
|
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||||
|
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||||
|
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParentCategoryAnalyticsDetailData struct {
|
||||||
|
CategoryID uuid.UUID `json:"category_id"`
|
||||||
|
CategoryName string `json:"category_name"`
|
||||||
|
TotalRevenue float64 `json:"total_revenue"`
|
||||||
|
TotalQuantity int64 `json:"total_quantity"`
|
||||||
|
ProductCount int64 `json:"product_count"`
|
||||||
|
OrderCount int64 `json:"order_count"`
|
||||||
|
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||||
|
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||||
|
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||||
|
Products []ParentCategoryAnalyticsProductData `json:"products"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParentCategoryAnalyticsProductData struct {
|
||||||
|
ProductID uuid.UUID `json:"product_id"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
ProductSku string `json:"product_sku"`
|
||||||
|
ProductPrice float64 `json:"product_price"`
|
||||||
|
QuantitySold int64 `json:"quantity_sold"`
|
||||||
|
Revenue float64 `json:"revenue"`
|
||||||
|
AveragePrice float64 `json:"average_price"`
|
||||||
|
OrderCount int64 `json:"order_count"`
|
||||||
|
StandardHppPerUnit float64 `json:"standard_hpp_per_unit"`
|
||||||
|
StandardHppTotal float64 `json:"standard_hpp_total"`
|
||||||
|
FifoHppPerUnit float64 `json:"fifo_hpp_per_unit"`
|
||||||
|
FifoHppTotal float64 `json:"fifo_hpp_total"`
|
||||||
|
MovingAverageHppPerUnit float64 `json:"moving_average_hpp_per_unit"`
|
||||||
|
MovingAverageHppTotal float64 `json:"moving_average_hpp_total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BudgetCutOff is the Monday-to-Sunday spending limit breakdown attached to the
|
||||||
|
// parent category reports.
|
||||||
|
type BudgetCutOff struct {
|
||||||
|
Percentages BudgetPercentages `json:"percentages"`
|
||||||
|
CutOffFrom time.Time `json:"cut_off_from"`
|
||||||
|
CutOffTo time.Time `json:"cut_off_to"`
|
||||||
|
Total BudgetPeriod `json:"total"`
|
||||||
|
Weekly []BudgetPeriod `json:"weekly"`
|
||||||
|
Monthly []BudgetMonthPeriod `json:"monthly"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BudgetPercentages struct {
|
||||||
|
Purchase float64 `json:"purchase"`
|
||||||
|
Owner float64 `json:"owner"`
|
||||||
|
Team float64 `json:"team"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BudgetPeriod struct {
|
||||||
|
PeriodStart time.Time `json:"period_start"`
|
||||||
|
PeriodEnd time.Time `json:"period_end"`
|
||||||
|
Revenue float64 `json:"revenue"`
|
||||||
|
OrderCount int64 `json:"order_count"`
|
||||||
|
LimitPurchase float64 `json:"limit_purchase"`
|
||||||
|
LimitOwner float64 `json:"limit_owner"`
|
||||||
|
LimitTeam float64 `json:"limit_team"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BudgetMonthPeriod struct {
|
||||||
|
Month string `json:"month"`
|
||||||
|
WeekCount int `json:"week_count"`
|
||||||
|
BudgetPeriod
|
||||||
|
}
|
||||||
|
|
||||||
// DashboardAnalyticsRequest represents the request for dashboard analytics
|
// DashboardAnalyticsRequest represents the request for dashboard analytics
|
||||||
type DashboardAnalyticsRequest struct {
|
type DashboardAnalyticsRequest struct {
|
||||||
OrganizationID uuid.UUID `validate:"required"`
|
OrganizationID uuid.UUID `validate:"required"`
|
||||||
@@ -237,6 +415,7 @@ type DashboardAnalyticsRequest struct {
|
|||||||
type DashboardAnalyticsResponse struct {
|
type DashboardAnalyticsResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
Overview DashboardOverview `json:"overview"`
|
Overview DashboardOverview `json:"overview"`
|
||||||
@@ -247,12 +426,15 @@ type DashboardAnalyticsResponse struct {
|
|||||||
|
|
||||||
// DashboardOverview represents the overview data for dashboard
|
// DashboardOverview represents the overview data for dashboard
|
||||||
type DashboardOverview struct {
|
type DashboardOverview struct {
|
||||||
TotalSales float64 `json:"total_sales"`
|
TotalSales float64 `json:"total_sales"`
|
||||||
TotalOrders int64 `json:"total_orders"`
|
TotalOrders int64 `json:"total_orders"`
|
||||||
AverageOrderValue float64 `json:"average_order_value"`
|
AverageOrderValue float64 `json:"average_order_value"`
|
||||||
TotalCustomers int64 `json:"total_customers"`
|
TotalCustomers int64 `json:"total_customers"`
|
||||||
VoidedOrders int64 `json:"voided_orders"`
|
VoidedOrders int64 `json:"voided_orders"`
|
||||||
RefundedOrders int64 `json:"refunded_orders"`
|
RefundedOrders int64 `json:"refunded_orders"`
|
||||||
|
TotalItemSold int64 `json:"total_item_sold"`
|
||||||
|
TotalLowStock int64 `json:"total_low_stock"`
|
||||||
|
TotalProductActive int64 `json:"total_product_active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProfitLossAnalyticsRequest struct {
|
type ProfitLossAnalyticsRequest struct {
|
||||||
@@ -266,6 +448,7 @@ type ProfitLossAnalyticsRequest struct {
|
|||||||
type ProfitLossAnalyticsResponse struct {
|
type ProfitLossAnalyticsResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
GroupBy string `json:"group_by"`
|
GroupBy string `json:"group_by"`
|
||||||
@@ -273,10 +456,28 @@ type ProfitLossAnalyticsResponse struct {
|
|||||||
Data []ProfitLossData `json:"data"`
|
Data []ProfitLossData `json:"data"`
|
||||||
ProductData []ProductProfitData `json:"product_data"`
|
ProductData []ProductProfitData `json:"product_data"`
|
||||||
MainSummary []ProfitLossSummaryRow `json:"main_summary"`
|
MainSummary []ProfitLossSummaryRow `json:"main_summary"`
|
||||||
|
Purchasing ProfitLossPurchasing `json:"purchasing"`
|
||||||
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
|
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
|
||||||
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProfitLossPurchasing struct {
|
||||||
|
TodayTotal float64 `json:"today_total"`
|
||||||
|
MtdTotal float64 `json:"mtd_total"`
|
||||||
|
TodayRawMaterial float64 `json:"today_raw_material"`
|
||||||
|
MtdRawMaterial float64 `json:"mtd_raw_material"`
|
||||||
|
TodayExpense float64 `json:"today_expense"`
|
||||||
|
MtdExpense float64 `json:"mtd_expense"`
|
||||||
|
Items []ProfitLossPurchasingItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfitLossPurchasingItem struct {
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Item string `json:"item"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Nominal float64 `json:"nominal"`
|
||||||
|
}
|
||||||
|
|
||||||
type ProfitLossSummary struct {
|
type ProfitLossSummary struct {
|
||||||
TotalRevenue float64 `json:"total_revenue"`
|
TotalRevenue float64 `json:"total_revenue"`
|
||||||
TotalCost float64 `json:"total_cost"`
|
TotalCost float64 `json:"total_cost"`
|
||||||
@@ -349,9 +550,17 @@ type ExclusiveSummaryMonthlyRequest struct {
|
|||||||
Month time.Time `validate:"required"`
|
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 {
|
type ExclusiveSummaryPeriodResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
Period ExclusiveSummaryPeriodRange `json:"period"`
|
Period ExclusiveSummaryPeriodRange `json:"period"`
|
||||||
Summary ExclusiveSummaryPeriodSummary `json:"summary"`
|
Summary ExclusiveSummaryPeriodSummary `json:"summary"`
|
||||||
Reimburse ExclusiveSummaryReimburse `json:"reimburse"`
|
Reimburse ExclusiveSummaryReimburse `json:"reimburse"`
|
||||||
@@ -411,6 +620,7 @@ type ExclusiveSummaryDailyTransaction struct {
|
|||||||
type ExclusiveSummaryMonthlyResponse struct {
|
type ExclusiveSummaryMonthlyResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
Month string `json:"month"`
|
Month string `json:"month"`
|
||||||
Summary ExclusiveSummaryMonthlySummary `json:"summary"`
|
Summary ExclusiveSummaryMonthlySummary `json:"summary"`
|
||||||
Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"`
|
Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"`
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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"`
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ type Category struct {
|
|||||||
type CreateCategoryRequest struct {
|
type CreateCategoryRequest struct {
|
||||||
OrganizationID uuid.UUID `validate:"required"`
|
OrganizationID uuid.UUID `validate:"required"`
|
||||||
OutletID *uuid.UUID
|
OutletID *uuid.UUID
|
||||||
|
ParentID *uuid.UUID
|
||||||
Name string `validate:"required,min=1,max=255"`
|
Name string `validate:"required,min=1,max=255"`
|
||||||
Description *string `validate:"omitempty,max=1000"`
|
Description *string `validate:"omitempty,max=1000"`
|
||||||
ImageURL *string `validate:"omitempty,url"`
|
ImageURL *string `validate:"omitempty,url"`
|
||||||
@@ -33,6 +34,7 @@ type UpdateCategoryRequest struct {
|
|||||||
Description *string `validate:"omitempty,max=1000"`
|
Description *string `validate:"omitempty,max=1000"`
|
||||||
ImageURL *string `validate:"omitempty,url"`
|
ImageURL *string `validate:"omitempty,url"`
|
||||||
OutletID *uuid.UUID
|
OutletID *uuid.UUID
|
||||||
|
ParentID *uuid.UUID
|
||||||
Order *int `validate:"omitempty,min=0"`
|
Order *int `validate:"omitempty,min=0"`
|
||||||
IsActive *bool
|
IsActive *bool
|
||||||
}
|
}
|
||||||
@@ -41,6 +43,8 @@ type CategoryResponse struct {
|
|||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
OrganizationID uuid.UUID
|
OrganizationID uuid.UUID
|
||||||
OutletID *uuid.UUID
|
OutletID *uuid.UUID
|
||||||
|
ParentID *uuid.UUID
|
||||||
|
ParentName *string
|
||||||
Name string
|
Name string
|
||||||
Description *string
|
Description *string
|
||||||
ImageURL *string
|
ImageURL *string
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ type ExpenseResponse struct {
|
|||||||
Tax float64 `json:"tax"`
|
Tax float64 `json:"tax"`
|
||||||
Total float64 `json:"total"`
|
Total float64 `json:"total"`
|
||||||
Reserved1 *string `json:"reserved1"`
|
Reserved1 *string `json:"reserved1"`
|
||||||
|
CashAdvanceID *uuid.UUID `json:"cash_advance_id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
Items []ExpenseItemResponse `json:"items,omitempty"`
|
Items []ExpenseItemResponse `json:"items,omitempty"`
|
||||||
@@ -76,6 +77,7 @@ type CreateExpenseRequest struct {
|
|||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Tax float64 `json:"tax"`
|
Tax float64 `json:"tax"`
|
||||||
Total float64 `json:"total"`
|
Total float64 `json:"total"`
|
||||||
|
CashAdvanceID *string `json:"cash_advance_id,omitempty"`
|
||||||
Items []CreateExpenseItemRequest `json:"items"`
|
Items []CreateExpenseItemRequest `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +99,7 @@ type UpdateExpenseRequest struct {
|
|||||||
Tax *float64 `json:"tax,omitempty"`
|
Tax *float64 `json:"tax,omitempty"`
|
||||||
Total *float64 `json:"total,omitempty"`
|
Total *float64 `json:"total,omitempty"`
|
||||||
Reserved1 *string `json:"reserved1,omitempty"`
|
Reserved1 *string `json:"reserved1,omitempty"`
|
||||||
|
CashAdvanceID *string `json:"cash_advance_id,omitempty"`
|
||||||
Items []UpdateExpenseItemRequest `json:"items,omitempty"`
|
Items []UpdateExpenseItemRequest `json:"items,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type Ingredient struct {
|
|||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id"`
|
OutletID *uuid.UUID `json:"outlet_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
UnitID uuid.UUID `json:"unit_id"`
|
UnitID *uuid.UUID `json:"unit_id"`
|
||||||
Cost float64 `json:"cost"`
|
Cost float64 `json:"cost"`
|
||||||
Stock float64 `json:"stock"`
|
Stock float64 `json:"stock"`
|
||||||
IsSemiFinished bool `json:"is_semi_finished"`
|
IsSemiFinished bool `json:"is_semi_finished"`
|
||||||
@@ -29,7 +29,7 @@ type CreateIngredientRequest struct {
|
|||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id"`
|
OutletID *uuid.UUID `json:"outlet_id"`
|
||||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||||
UnitID uuid.UUID `json:"unit_id" validate:"required"`
|
UnitID *uuid.UUID `json:"unit_id" validate:"omitempty"`
|
||||||
Cost float64 `json:"cost" validate:"min=0"`
|
Cost float64 `json:"cost" validate:"min=0"`
|
||||||
Stock float64 `json:"stock" validate:"min=0"`
|
Stock float64 `json:"stock" validate:"min=0"`
|
||||||
IsSemiFinished bool `json:"is_semi_finished"`
|
IsSemiFinished bool `json:"is_semi_finished"`
|
||||||
@@ -48,7 +48,7 @@ type CompositionItemRequest struct {
|
|||||||
type UpdateIngredientRequest struct {
|
type UpdateIngredientRequest struct {
|
||||||
OutletID *uuid.UUID `json:"outlet_id"`
|
OutletID *uuid.UUID `json:"outlet_id"`
|
||||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||||
UnitID uuid.UUID `json:"unit_id" validate:"required"`
|
UnitID *uuid.UUID `json:"unit_id" validate:"omitempty"`
|
||||||
Cost float64 `json:"cost" validate:"min=0"`
|
Cost float64 `json:"cost" validate:"min=0"`
|
||||||
Stock float64 `json:"stock" validate:"min=0"`
|
Stock float64 `json:"stock" validate:"min=0"`
|
||||||
IsSemiFinished bool `json:"is_semi_finished"`
|
IsSemiFinished bool `json:"is_semi_finished"`
|
||||||
@@ -61,7 +61,7 @@ type IngredientResponse struct {
|
|||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id"`
|
OutletID *uuid.UUID `json:"outlet_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
UnitID uuid.UUID `json:"unit_id"`
|
UnitID *uuid.UUID `json:"unit_id"`
|
||||||
Cost float64 `json:"cost"`
|
Cost float64 `json:"cost"`
|
||||||
Stock float64 `json:"stock"`
|
Stock float64 `json:"stock"`
|
||||||
IsSemiFinished bool `json:"is_semi_finished"`
|
IsSemiFinished bool `json:"is_semi_finished"`
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ type ListIngredientUnitConvertersResponse struct {
|
|||||||
type IngredientUnitsResponse struct {
|
type IngredientUnitsResponse struct {
|
||||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||||
IngredientName string `json:"ingredient_name"`
|
IngredientName string `json:"ingredient_name"`
|
||||||
BaseUnitID uuid.UUID `json:"base_unit_id"`
|
BaseUnitID *uuid.UUID `json:"base_unit_id"`
|
||||||
BaseUnitName string `json:"base_unit_name"`
|
BaseUnitName string `json:"base_unit_name"`
|
||||||
Units []*UnitResponse `json:"units"`
|
Units []*UnitResponse `json:"units"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import (
|
|||||||
type PurchaseOrder struct {
|
type PurchaseOrder struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
VendorID uuid.UUID `json:"vendor_id"`
|
OutletID *uuid.UUID `json:"outlet_id"`
|
||||||
|
VendorID *uuid.UUID `json:"vendor_id"`
|
||||||
PONumber string `json:"po_number"`
|
PONumber string `json:"po_number"`
|
||||||
TransactionDate time.Time `json:"transaction_date"`
|
TransactionDate time.Time `json:"transaction_date"`
|
||||||
DueDate *time.Time `json:"due_date"`
|
DueDate *time.Time `json:"due_date"`
|
||||||
@@ -17,21 +18,32 @@ type PurchaseOrder struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Message *string `json:"message"`
|
Message *string `json:"message"`
|
||||||
TotalAmount float64 `json:"total_amount"`
|
TotalAmount float64 `json:"total_amount"`
|
||||||
|
TeamScope *string `json:"team_scope"`
|
||||||
|
TeamCategoryID *uuid.UUID `json:"team_category_id"`
|
||||||
|
CashAdvanceID *uuid.UUID `json:"cash_advance_id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PurchaseTeam is one entry of the team picker: either a parent category or Pusat.
|
||||||
|
// Pusat carries no CategoryID because it has no category of its own.
|
||||||
|
type PurchaseTeam struct {
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
CategoryID *uuid.UUID `json:"category_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
type PurchaseOrderItem struct {
|
type PurchaseOrderItem struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
|
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
|
||||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
IngredientID *uuid.UUID `json:"ingredient_id"`
|
||||||
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
|
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Quantity float64 `json:"quantity"`
|
Quantity *float64 `json:"quantity"`
|
||||||
UnitID uuid.UUID `json:"unit_id"`
|
UnitID *uuid.UUID `json:"unit_id"`
|
||||||
Amount float64 `json:"amount"`
|
Amount float64 `json:"amount"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PurchaseOrderAttachment struct {
|
type PurchaseOrderAttachment struct {
|
||||||
@@ -44,7 +56,8 @@ type PurchaseOrderAttachment struct {
|
|||||||
type PurchaseOrderResponse struct {
|
type PurchaseOrderResponse struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
VendorID uuid.UUID `json:"vendor_id"`
|
OutletID *uuid.UUID `json:"outlet_id"`
|
||||||
|
VendorID *uuid.UUID `json:"vendor_id"`
|
||||||
PONumber string `json:"po_number"`
|
PONumber string `json:"po_number"`
|
||||||
TransactionDate time.Time `json:"transaction_date"`
|
TransactionDate time.Time `json:"transaction_date"`
|
||||||
DueDate *time.Time `json:"due_date"`
|
DueDate *time.Time `json:"due_date"`
|
||||||
@@ -52,8 +65,12 @@ type PurchaseOrderResponse struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Message *string `json:"message"`
|
Message *string `json:"message"`
|
||||||
TotalAmount float64 `json:"total_amount"`
|
TotalAmount float64 `json:"total_amount"`
|
||||||
|
TeamScope *string `json:"team_scope"`
|
||||||
|
TeamCategoryID *uuid.UUID `json:"team_category_id"`
|
||||||
|
CashAdvanceID *uuid.UUID `json:"cash_advance_id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
Team *PurchaseTeam `json:"team,omitempty"`
|
||||||
Vendor *VendorResponse `json:"vendor,omitempty"`
|
Vendor *VendorResponse `json:"vendor,omitempty"`
|
||||||
Items []PurchaseOrderItemResponse `json:"items,omitempty"`
|
Items []PurchaseOrderItemResponse `json:"items,omitempty"`
|
||||||
Attachments []PurchaseOrderAttachmentResponse `json:"attachments,omitempty"`
|
Attachments []PurchaseOrderAttachmentResponse `json:"attachments,omitempty"`
|
||||||
@@ -62,11 +79,11 @@ type PurchaseOrderResponse struct {
|
|||||||
type PurchaseOrderItemResponse struct {
|
type PurchaseOrderItemResponse struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
|
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
|
||||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
IngredientID *uuid.UUID `json:"ingredient_id"`
|
||||||
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
|
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Quantity float64 `json:"quantity"`
|
Quantity *float64 `json:"quantity"`
|
||||||
UnitID uuid.UUID `json:"unit_id"`
|
UnitID *uuid.UUID `json:"unit_id"`
|
||||||
Amount float64 `json:"amount"`
|
Amount float64 `json:"amount"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
@@ -84,24 +101,28 @@ type PurchaseOrderAttachmentResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CreatePurchaseOrderRequest struct {
|
type CreatePurchaseOrderRequest struct {
|
||||||
VendorID uuid.UUID `json:"vendor_id"`
|
VendorID *uuid.UUID `json:"vendor_id,omitempty"`
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
PONumber string `json:"po_number"`
|
PONumber string `json:"po_number"`
|
||||||
TransactionDate time.Time `json:"transaction_date"`
|
TransactionDate time.Time `json:"transaction_date"`
|
||||||
DueDate *time.Time `json:"due_date,omitempty"`
|
DueDate *time.Time `json:"due_date,omitempty"`
|
||||||
Reference *string `json:"reference,omitempty"`
|
Reference *string `json:"reference,omitempty"`
|
||||||
Status *string `json:"status,omitempty"`
|
Status *string `json:"status,omitempty"`
|
||||||
Message *string `json:"message,omitempty"`
|
Message *string `json:"message,omitempty"`
|
||||||
|
TeamScope *string `json:"team_scope,omitempty"`
|
||||||
|
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"`
|
||||||
|
CashAdvanceID *uuid.UUID `json:"cash_advance_id,omitempty"`
|
||||||
Items []CreatePurchaseOrderItemRequest `json:"items"`
|
Items []CreatePurchaseOrderItemRequest `json:"items"`
|
||||||
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreatePurchaseOrderItemRequest struct {
|
type CreatePurchaseOrderItemRequest struct {
|
||||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
IngredientID *uuid.UUID `json:"ingredient_id,omitempty"`
|
||||||
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
|
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
Quantity float64 `json:"quantity"`
|
Quantity *float64 `json:"quantity,omitempty"`
|
||||||
UnitID uuid.UUID `json:"unit_id"`
|
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||||
Amount float64 `json:"amount"`
|
Amount float64 `json:"amount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdatePurchaseOrderRequest struct {
|
type UpdatePurchaseOrderRequest struct {
|
||||||
@@ -112,12 +133,15 @@ type UpdatePurchaseOrderRequest struct {
|
|||||||
Reference *string `json:"reference,omitempty"`
|
Reference *string `json:"reference,omitempty"`
|
||||||
Status *string `json:"status,omitempty"`
|
Status *string `json:"status,omitempty"`
|
||||||
Message *string `json:"message,omitempty"`
|
Message *string `json:"message,omitempty"`
|
||||||
|
TeamScope *string `json:"team_scope,omitempty"`
|
||||||
|
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"`
|
||||||
|
CashAdvanceID *uuid.UUID `json:"cash_advance_id,omitempty"`
|
||||||
Items []UpdatePurchaseOrderItemRequest `json:"items,omitempty"`
|
Items []UpdatePurchaseOrderItemRequest `json:"items,omitempty"`
|
||||||
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdatePurchaseOrderItemRequest struct {
|
type UpdatePurchaseOrderItemRequest struct {
|
||||||
ID *uuid.UUID `json:"id,omitempty"` // Ignored. Supplying items replaces all existing PO items.
|
ID *uuid.UUID `json:"id,omitempty"` // For existing items
|
||||||
IngredientID *uuid.UUID `json:"ingredient_id,omitempty"`
|
IngredientID *uuid.UUID `json:"ingredient_id,omitempty"`
|
||||||
PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty"`
|
PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
@@ -127,13 +151,20 @@ type UpdatePurchaseOrderItemRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ListPurchaseOrdersRequest struct {
|
type ListPurchaseOrdersRequest struct {
|
||||||
Page int `json:"page" validate:"min=1"`
|
Page int `json:"page" validate:"min=1"`
|
||||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||||
Search string `json:"search,omitempty"`
|
Search string `json:"search,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
VendorID *uuid.UUID `json:"vendor_id,omitempty"`
|
VendorID *uuid.UUID `json:"vendor_id,omitempty"`
|
||||||
StartDate *time.Time `json:"start_date,omitempty"`
|
Team string `json:"team,omitempty"`
|
||||||
EndDate *time.Time `json:"end_date,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 {
|
type ListPurchaseOrdersResponse struct {
|
||||||
|
|||||||
@@ -63,10 +63,12 @@ type UserResponse struct {
|
|||||||
|
|
||||||
func (u *User) HasPermission(requiredRole constants.UserRole) bool {
|
func (u *User) HasPermission(requiredRole constants.UserRole) bool {
|
||||||
roleHierarchy := map[constants.UserRole]int{
|
roleHierarchy := map[constants.UserRole]int{
|
||||||
constants.RoleWaiter: 1,
|
constants.RoleWaiter: 1,
|
||||||
constants.RoleCashier: 2,
|
constants.RoleCashier: 2,
|
||||||
constants.RoleManager: 3,
|
constants.RolePurchasing: 3,
|
||||||
constants.RoleAdmin: 4,
|
constants.RoleManager: 4,
|
||||||
|
constants.RoleAdmin: 5,
|
||||||
|
constants.RoleOwner: 6,
|
||||||
}
|
}
|
||||||
|
|
||||||
userLevel := roleHierarchy[u.Role]
|
userLevel := roleHierarchy[u.Role]
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
"apskel-pos-be/internal/repository"
|
"apskel-pos-be/internal/repository"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AnalyticsProcessor interface {
|
type AnalyticsProcessor interface {
|
||||||
@@ -17,10 +20,13 @@ type AnalyticsProcessor interface {
|
|||||||
GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error)
|
GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error)
|
||||||
GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error)
|
GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error)
|
||||||
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error)
|
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error)
|
||||||
|
GetProductAnalyticsPerParentCategory(ctx context.Context, req *models.ProductAnalyticsPerParentCategoryRequest) (*models.ProductAnalyticsPerParentCategoryResponse, error)
|
||||||
|
GetParentCategoryAnalyticsDetail(ctx context.Context, req *models.ParentCategoryAnalyticsDetailRequest) (*models.ParentCategoryAnalyticsDetailResponse, error)
|
||||||
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
|
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
|
||||||
GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error)
|
GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error)
|
||||||
GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error)
|
GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error)
|
||||||
GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error)
|
GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error)
|
||||||
|
GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnalyticsProcessorImpl struct {
|
type AnalyticsProcessorImpl struct {
|
||||||
@@ -35,6 +41,18 @@ func NewAnalyticsProcessorImpl(analyticsRepo repository.AnalyticsRepository, exp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveOutletName fetches the outlet name from the database if outletID is provided
|
||||||
|
func (p *AnalyticsProcessorImpl) resolveOutletName(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) *string {
|
||||||
|
if outletID == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name, err := p.analyticsRepo.GetOutletName(ctx, organizationID, *outletID)
|
||||||
|
if err != nil || name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &name
|
||||||
|
}
|
||||||
|
|
||||||
func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) {
|
func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) {
|
||||||
if req.DateFrom.After(req.DateTo) {
|
if req.DateFrom.After(req.DateTo) {
|
||||||
return nil, fmt.Errorf("date_from cannot be after date_to")
|
return nil, fmt.Errorf("date_from cannot be after date_to")
|
||||||
@@ -89,6 +107,7 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context,
|
|||||||
return &models.PaymentMethodAnalyticsResponse{
|
return &models.PaymentMethodAnalyticsResponse{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
|
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||||
DateFrom: req.DateFrom,
|
DateFrom: req.DateFrom,
|
||||||
DateTo: req.DateTo,
|
DateTo: req.DateTo,
|
||||||
GroupBy: req.GroupBy,
|
GroupBy: req.GroupBy,
|
||||||
@@ -163,6 +182,7 @@ func (p *AnalyticsProcessorImpl) GetSalesAnalytics(ctx context.Context, req *mod
|
|||||||
return &models.SalesAnalyticsResponse{
|
return &models.SalesAnalyticsResponse{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
|
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||||
DateFrom: req.DateFrom,
|
DateFrom: req.DateFrom,
|
||||||
DateTo: req.DateTo,
|
DateTo: req.DateTo,
|
||||||
GroupBy: req.GroupBy,
|
GroupBy: req.GroupBy,
|
||||||
@@ -180,7 +200,12 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
|
|||||||
req.GroupBy = "day"
|
req.GroupBy = "day"
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := p.analyticsRepo.GetPurchasingAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy)
|
teamFilter, err := models.ParsePurchaseTeamFilter(req.Team)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := p.analyticsRepo.GetPurchasingAnalytics(ctx, req.OrganizationID, req.OutletID, teamFilter, req.DateFrom, req.DateTo, req.GroupBy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get purchasing analytics: %w", err)
|
return nil, fmt.Errorf("failed to get purchasing analytics: %w", err)
|
||||||
}
|
}
|
||||||
@@ -225,10 +250,26 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
teamData := make([]models.PurchasingTeamData, len(result.TeamData))
|
||||||
|
for i, item := range result.TeamData {
|
||||||
|
teamData[i] = models.PurchasingTeamData{
|
||||||
|
Scope: item.Scope,
|
||||||
|
CategoryID: item.CategoryID,
|
||||||
|
Name: item.Name,
|
||||||
|
TotalPurchases: item.TotalPurchases,
|
||||||
|
RawMaterialPurchases: item.RawMaterialPurchases,
|
||||||
|
ExpensePurchases: item.ExpensePurchases,
|
||||||
|
PurchaseOrderCount: item.PurchaseOrderCount,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
Percentage: item.Percentage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &models.PurchasingAnalyticsResponse{
|
return &models.PurchasingAnalyticsResponse{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
OutletName: result.OutletName,
|
OutletName: result.OutletName,
|
||||||
|
Team: req.Team,
|
||||||
DateFrom: req.DateFrom,
|
DateFrom: req.DateFrom,
|
||||||
DateTo: req.DateTo,
|
DateTo: req.DateTo,
|
||||||
GroupBy: req.GroupBy,
|
GroupBy: req.GroupBy,
|
||||||
@@ -243,10 +284,12 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
|
|||||||
AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue,
|
AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue,
|
||||||
TotalIngredients: result.Summary.TotalIngredients,
|
TotalIngredients: result.Summary.TotalIngredients,
|
||||||
TotalVendors: result.Summary.TotalVendors,
|
TotalVendors: result.Summary.TotalVendors,
|
||||||
|
TotalTeams: result.Summary.TotalTeams,
|
||||||
},
|
},
|
||||||
Data: data,
|
Data: data,
|
||||||
IngredientData: ingredientData,
|
IngredientData: ingredientData,
|
||||||
VendorData: vendorData,
|
VendorData: vendorData,
|
||||||
|
TeamData: teamData,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,6 +337,7 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m
|
|||||||
return &models.ProductAnalyticsResponse{
|
return &models.ProductAnalyticsResponse{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
|
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||||
DateFrom: req.DateFrom,
|
DateFrom: req.DateFrom,
|
||||||
DateTo: req.DateTo,
|
DateTo: req.DateTo,
|
||||||
Data: resultData,
|
Data: resultData,
|
||||||
@@ -331,12 +375,249 @@ func (p *AnalyticsProcessorImpl) GetProductAnalyticsPerCategory(ctx context.Cont
|
|||||||
return &models.ProductAnalyticsPerCategoryResponse{
|
return &models.ProductAnalyticsPerCategoryResponse{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
|
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||||
DateFrom: req.DateFrom,
|
DateFrom: req.DateFrom,
|
||||||
DateTo: req.DateTo,
|
DateTo: req.DateTo,
|
||||||
Data: resultData,
|
Data: resultData,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *AnalyticsProcessorImpl) GetProductAnalyticsPerParentCategory(ctx context.Context, req *models.ProductAnalyticsPerParentCategoryRequest) (*models.ProductAnalyticsPerParentCategoryResponse, error) {
|
||||||
|
// Validate date range
|
||||||
|
if req.DateFrom.After(req.DateTo) {
|
||||||
|
return nil, fmt.Errorf("date_from cannot be after date_to")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get analytics data from repository
|
||||||
|
analyticsData, err := p.analyticsRepo.GetProductAnalyticsPerParentCategory(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get product analytics per parent category: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform data
|
||||||
|
var resultData []models.ProductAnalyticsPerParentCategoryData
|
||||||
|
for _, data := range analyticsData {
|
||||||
|
resultData = append(resultData, models.ProductAnalyticsPerParentCategoryData{
|
||||||
|
ParentCategoryID: data.ParentCategoryID,
|
||||||
|
ParentCategoryName: data.ParentCategoryName,
|
||||||
|
TotalRevenue: data.TotalRevenue,
|
||||||
|
TotalQuantity: data.TotalQuantity,
|
||||||
|
CategoryCount: data.CategoryCount,
|
||||||
|
ProductCount: data.ProductCount,
|
||||||
|
OrderCount: data.OrderCount,
|
||||||
|
TotalStandardHpp: data.TotalStandardHpp,
|
||||||
|
TotalFifoHpp: data.TotalFifoHpp,
|
||||||
|
TotalMovingAverageHpp: data.TotalMovingAverageHpp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
budget, err := p.buildBudgetCutOff(ctx, req.OrganizationID, req.OutletID, nil, req.DateFrom, req.DateTo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.ProductAnalyticsPerParentCategoryResponse{
|
||||||
|
OrganizationID: req.OrganizationID,
|
||||||
|
OutletID: req.OutletID,
|
||||||
|
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||||
|
DateFrom: req.DateFrom,
|
||||||
|
DateTo: req.DateTo,
|
||||||
|
Data: resultData,
|
||||||
|
Budget: budget,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Context, req *models.ParentCategoryAnalyticsDetailRequest) (*models.ParentCategoryAnalyticsDetailResponse, error) {
|
||||||
|
// Validate date range
|
||||||
|
if req.DateFrom.After(req.DateTo) {
|
||||||
|
return nil, fmt.Errorf("date_from cannot be after date_to")
|
||||||
|
}
|
||||||
|
|
||||||
|
detail, err := p.analyticsRepo.GetParentCategoryAnalyticsDetail(ctx, req.OrganizationID, req.OutletID, req.ParentCategoryID, req.DateFrom, req.DateTo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get parent category analytics detail: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bucket the product rows by the category they belong to
|
||||||
|
productsByCategory := make(map[uuid.UUID][]models.ParentCategoryAnalyticsProductData)
|
||||||
|
for _, product := range detail.Products {
|
||||||
|
productsByCategory[product.CategoryID] = append(productsByCategory[product.CategoryID], models.ParentCategoryAnalyticsProductData{
|
||||||
|
ProductID: product.ProductID,
|
||||||
|
ProductName: product.ProductName,
|
||||||
|
ProductSku: product.ProductSku,
|
||||||
|
ProductPrice: product.ProductPrice,
|
||||||
|
QuantitySold: product.QuantitySold,
|
||||||
|
Revenue: product.Revenue,
|
||||||
|
AveragePrice: product.AveragePrice,
|
||||||
|
OrderCount: product.OrderCount,
|
||||||
|
StandardHppPerUnit: product.StandardHppPerUnit,
|
||||||
|
StandardHppTotal: product.StandardHppTotal,
|
||||||
|
FifoHppPerUnit: product.FifoHppPerUnit,
|
||||||
|
FifoHppTotal: product.FifoHppTotal,
|
||||||
|
MovingAverageHppPerUnit: product.MovingAverageHppPerUnit,
|
||||||
|
MovingAverageHppTotal: product.MovingAverageHppTotal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
categories := make([]models.ParentCategoryAnalyticsDetailData, 0, len(detail.Categories))
|
||||||
|
for _, category := range detail.Categories {
|
||||||
|
products := productsByCategory[category.CategoryID]
|
||||||
|
if products == nil {
|
||||||
|
products = []models.ParentCategoryAnalyticsProductData{}
|
||||||
|
}
|
||||||
|
|
||||||
|
categories = append(categories, models.ParentCategoryAnalyticsDetailData{
|
||||||
|
CategoryID: category.CategoryID,
|
||||||
|
CategoryName: category.CategoryName,
|
||||||
|
TotalRevenue: category.TotalRevenue,
|
||||||
|
TotalQuantity: category.TotalQuantity,
|
||||||
|
ProductCount: category.ProductCount,
|
||||||
|
OrderCount: category.OrderCount,
|
||||||
|
TotalStandardHpp: category.TotalStandardHpp,
|
||||||
|
TotalFifoHpp: category.TotalFifoHpp,
|
||||||
|
TotalMovingAverageHpp: category.TotalMovingAverageHpp,
|
||||||
|
Products: products,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := models.ParentCategoryAnalyticsDetailSummary{}
|
||||||
|
if detail.Summary != nil {
|
||||||
|
summary = models.ParentCategoryAnalyticsDetailSummary{
|
||||||
|
TotalRevenue: detail.Summary.TotalRevenue,
|
||||||
|
TotalQuantity: detail.Summary.TotalQuantity,
|
||||||
|
CategoryCount: detail.Summary.CategoryCount,
|
||||||
|
ProductCount: detail.Summary.ProductCount,
|
||||||
|
OrderCount: detail.Summary.OrderCount,
|
||||||
|
TotalStandardHpp: detail.Summary.TotalStandardHpp,
|
||||||
|
TotalFifoHpp: detail.Summary.TotalFifoHpp,
|
||||||
|
TotalMovingAverageHpp: detail.Summary.TotalMovingAverageHpp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
budget, err := p.buildBudgetCutOff(ctx, req.OrganizationID, req.OutletID, &req.ParentCategoryID, req.DateFrom, req.DateTo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.ParentCategoryAnalyticsDetailResponse{
|
||||||
|
OrganizationID: req.OrganizationID,
|
||||||
|
OutletID: req.OutletID,
|
||||||
|
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||||
|
DateFrom: req.DateFrom,
|
||||||
|
DateTo: req.DateTo,
|
||||||
|
ParentCategoryID: detail.ParentCategoryID,
|
||||||
|
ParentCategoryName: detail.ParentCategoryName,
|
||||||
|
Summary: summary,
|
||||||
|
Categories: categories,
|
||||||
|
Budget: budget,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startOfWeek returns the Monday 00:00 of the week containing t, in t's own location.
|
||||||
|
func startOfWeek(t time.Time) time.Time {
|
||||||
|
daysSinceMonday := (int(t.Weekday()) + 6) % 7
|
||||||
|
year, month, day := t.Date()
|
||||||
|
return time.Date(year, month, day-daysSinceMonday, 0, 0, 0, 0, t.Location())
|
||||||
|
}
|
||||||
|
|
||||||
|
// endOfWeek returns the Sunday 23:59:59.999999999 of the week containing t.
|
||||||
|
func endOfWeek(t time.Time) time.Time {
|
||||||
|
return startOfWeek(t).AddDate(0, 0, 7).Add(-time.Nanosecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newBudgetPeriod splits a period's revenue into the spending limits.
|
||||||
|
func newBudgetPeriod(start, end time.Time, revenue float64, orderCount int64) models.BudgetPeriod {
|
||||||
|
return models.BudgetPeriod{
|
||||||
|
PeriodStart: start,
|
||||||
|
PeriodEnd: end,
|
||||||
|
Revenue: revenue,
|
||||||
|
OrderCount: orderCount,
|
||||||
|
LimitPurchase: revenue * constants.BudgetLimitPurchasePercent / 100,
|
||||||
|
LimitOwner: revenue * constants.BudgetLimitOwnerPercent / 100,
|
||||||
|
LimitTeam: revenue * constants.BudgetLimitTeamPercent / 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildBudgetCutOff produces the weekly cut-off breakdown for the given scope. Weeks
|
||||||
|
// are always whole Monday-to-Sunday blocks, so the covered range is widened to the
|
||||||
|
// week boundaries around the requested dates. A nil parentCategoryID covers every
|
||||||
|
// category.
|
||||||
|
func (p *AnalyticsProcessorImpl) buildBudgetCutOff(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, parentCategoryID *uuid.UUID, dateFrom, dateTo time.Time) (models.BudgetCutOff, error) {
|
||||||
|
cutOffFrom := startOfWeek(dateFrom)
|
||||||
|
cutOffTo := endOfWeek(dateTo)
|
||||||
|
|
||||||
|
budget := models.BudgetCutOff{
|
||||||
|
Percentages: models.BudgetPercentages{
|
||||||
|
Purchase: constants.BudgetLimitPurchasePercent,
|
||||||
|
Owner: constants.BudgetLimitOwnerPercent,
|
||||||
|
Team: constants.BudgetLimitTeamPercent,
|
||||||
|
},
|
||||||
|
CutOffFrom: cutOffFrom,
|
||||||
|
CutOffTo: cutOffTo,
|
||||||
|
Weekly: []models.BudgetPeriod{},
|
||||||
|
Monthly: []models.BudgetMonthPeriod{},
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := p.analyticsRepo.GetBudgetCutOffWeekly(ctx, organizationID, outletID, parentCategoryID, cutOffFrom, cutOffTo)
|
||||||
|
if err != nil {
|
||||||
|
return budget, fmt.Errorf("failed to get budget cut off: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key the rows by their Monday so weeks without any sales can still be emitted
|
||||||
|
rowsByWeek := make(map[string]*entities.BudgetCutOffWeek, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
rowsByWeek[row.WeekStart.In(cutOffFrom.Location()).Format("2006-01-02")] = row
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
totalRevenue float64
|
||||||
|
totalOrders int64
|
||||||
|
monthOrder []string
|
||||||
|
monthAccumulator = map[string]*models.BudgetMonthPeriod{}
|
||||||
|
)
|
||||||
|
|
||||||
|
for week := cutOffFrom; !week.After(cutOffTo); week = week.AddDate(0, 0, 7) {
|
||||||
|
var revenue float64
|
||||||
|
var orderCount int64
|
||||||
|
if row, ok := rowsByWeek[week.Format("2006-01-02")]; ok {
|
||||||
|
revenue, orderCount = row.Revenue, row.OrderCount
|
||||||
|
}
|
||||||
|
|
||||||
|
period := newBudgetPeriod(week, endOfWeek(week), revenue, orderCount)
|
||||||
|
budget.Weekly = append(budget.Weekly, period)
|
||||||
|
|
||||||
|
totalRevenue += revenue
|
||||||
|
totalOrders += orderCount
|
||||||
|
|
||||||
|
// A week belongs to the month of its Monday, so every week is counted once
|
||||||
|
monthKey := week.Format("2006-01")
|
||||||
|
month, ok := monthAccumulator[monthKey]
|
||||||
|
if !ok {
|
||||||
|
month = &models.BudgetMonthPeriod{Month: monthKey}
|
||||||
|
month.PeriodStart = period.PeriodStart
|
||||||
|
monthAccumulator[monthKey] = month
|
||||||
|
monthOrder = append(monthOrder, monthKey)
|
||||||
|
}
|
||||||
|
month.WeekCount++
|
||||||
|
month.PeriodEnd = period.PeriodEnd
|
||||||
|
month.Revenue += revenue
|
||||||
|
month.OrderCount += orderCount
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, monthKey := range monthOrder {
|
||||||
|
month := monthAccumulator[monthKey]
|
||||||
|
budget.Monthly = append(budget.Monthly, models.BudgetMonthPeriod{
|
||||||
|
Month: month.Month,
|
||||||
|
WeekCount: month.WeekCount,
|
||||||
|
BudgetPeriod: newBudgetPeriod(month.PeriodStart, month.PeriodEnd, month.Revenue, month.OrderCount),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
budget.Total = newBudgetPeriod(cutOffFrom, cutOffTo, totalRevenue, totalOrders)
|
||||||
|
|
||||||
|
return budget, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) {
|
func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) {
|
||||||
// Validate date range
|
// Validate date range
|
||||||
if req.DateFrom.After(req.DateTo) {
|
if req.DateFrom.After(req.DateTo) {
|
||||||
@@ -392,15 +673,19 @@ func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req
|
|||||||
return &models.DashboardAnalyticsResponse{
|
return &models.DashboardAnalyticsResponse{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
|
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||||
DateFrom: req.DateFrom,
|
DateFrom: req.DateFrom,
|
||||||
DateTo: req.DateTo,
|
DateTo: req.DateTo,
|
||||||
Overview: models.DashboardOverview{
|
Overview: models.DashboardOverview{
|
||||||
TotalSales: overview.TotalSales,
|
TotalSales: overview.TotalSales,
|
||||||
TotalOrders: overview.TotalOrders,
|
TotalOrders: overview.TotalOrders,
|
||||||
AverageOrderValue: overview.AverageOrderValue,
|
AverageOrderValue: overview.AverageOrderValue,
|
||||||
TotalCustomers: overview.TotalCustomers,
|
TotalCustomers: overview.TotalCustomers,
|
||||||
VoidedOrders: overview.VoidedOrders,
|
VoidedOrders: overview.VoidedOrders,
|
||||||
RefundedOrders: overview.RefundedOrders,
|
RefundedOrders: overview.RefundedOrders,
|
||||||
|
TotalItemSold: overview.TotalItemSold,
|
||||||
|
TotalLowStock: overview.TotalLowStock,
|
||||||
|
TotalProductActive: overview.TotalProductActive,
|
||||||
},
|
},
|
||||||
TopProducts: topProducts.Data,
|
TopProducts: topProducts.Data,
|
||||||
PaymentMethods: paymentMethods.Data,
|
PaymentMethods: paymentMethods.Data,
|
||||||
@@ -603,9 +888,20 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
|
|||||||
opsTotal += 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{
|
return &models.ProfitLossAnalyticsResponse{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
|
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||||
DateFrom: req.DateFrom,
|
DateFrom: req.DateFrom,
|
||||||
DateTo: req.DateTo,
|
DateTo: req.DateTo,
|
||||||
GroupBy: req.GroupBy,
|
GroupBy: req.GroupBy,
|
||||||
@@ -622,9 +918,18 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
|
|||||||
AverageProfit: result.Summary.AverageProfit,
|
AverageProfit: result.Summary.AverageProfit,
|
||||||
ProfitabilityRatio: result.Summary.ProfitabilityRatio,
|
ProfitabilityRatio: result.Summary.ProfitabilityRatio,
|
||||||
},
|
},
|
||||||
Data: data,
|
Data: data,
|
||||||
ProductData: productData,
|
ProductData: productData,
|
||||||
MainSummary: mainSummary,
|
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,
|
OperationalExpenses: opsItems,
|
||||||
OperationalExpensesTotal: opsTotal,
|
OperationalExpensesTotal: opsTotal,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -656,14 +961,6 @@ func slugify(s string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
|
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, 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) {
|
if req.DateFrom.After(req.DateTo) {
|
||||||
return nil, fmt.Errorf("date_from cannot be after date_to")
|
return nil, fmt.Errorf("date_from cannot be after date_to")
|
||||||
}
|
}
|
||||||
@@ -672,10 +969,6 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryPeriod(ctx context.Context,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) {
|
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) {
|
||||||
if req.Month.IsZero() {
|
|
||||||
return nil, fmt.Errorf("month is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
monthStart := time.Date(req.Month.Year(), req.Month.Month(), 1, 0, 0, 0, 0, req.Month.Location())
|
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)
|
monthEnd := monthStart.AddDate(0, 1, 0).Add(-time.Nanosecond)
|
||||||
|
|
||||||
@@ -689,9 +982,8 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
buckets := buildExclusiveSummaryMonthlyBuckets(monthStart)
|
periods := make([]models.ExclusiveSummaryMonthlyPeriod, 0)
|
||||||
periods := make([]models.ExclusiveSummaryMonthlyPeriod, 0, len(buckets))
|
for _, bucket := range buildExclusiveSummaryMonthlyBuckets(monthStart) {
|
||||||
for _, bucket := range buckets {
|
|
||||||
period, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{
|
period, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
@@ -702,7 +994,6 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
grossMargin := percentage(period.Summary.GrossProfit, period.Summary.Sales)
|
|
||||||
periods = append(periods, models.ExclusiveSummaryMonthlyPeriod{
|
periods = append(periods, models.ExclusiveSummaryMonthlyPeriod{
|
||||||
Label: bucket.Label,
|
Label: bucket.Label,
|
||||||
DateFrom: bucket.DateFrom,
|
DateFrom: bucket.DateFrom,
|
||||||
@@ -710,13 +1001,31 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
|
|||||||
Sales: period.Summary.Sales,
|
Sales: period.Summary.Sales,
|
||||||
HPP: period.Summary.HPP,
|
HPP: period.Summary.HPP,
|
||||||
GrossProfit: period.Summary.GrossProfit,
|
GrossProfit: period.Summary.GrossProfit,
|
||||||
GrossMargin: grossMargin,
|
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{
|
return &models.ExclusiveSummaryMonthlyResponse{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
|
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||||
Month: monthStart.Format("2006-01"),
|
Month: monthStart.Format("2006-01"),
|
||||||
Summary: models.ExclusiveSummaryMonthlySummary{
|
Summary: models.ExclusiveSummaryMonthlySummary{
|
||||||
TotalSales: fullPeriod.Summary.Sales,
|
TotalSales: fullPeriod.Summary.Sales,
|
||||||
@@ -727,14 +1036,23 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
|
|||||||
NetProfit: fullPeriod.Summary.NetProfit,
|
NetProfit: fullPeriod.Summary.NetProfit,
|
||||||
NetProfitMargin: percentage(fullPeriod.Summary.NetProfit, fullPeriod.Summary.Sales),
|
NetProfitMargin: percentage(fullPeriod.Summary.NetProfit, fullPeriod.Summary.Sales),
|
||||||
},
|
},
|
||||||
Periods: periods,
|
Periods: periods,
|
||||||
BankBalance: []models.ExclusiveSummaryBankBalance{
|
BankBalance: bankBalance,
|
||||||
{Bank: "BCA"},
|
|
||||||
{Bank: "BRI"},
|
|
||||||
},
|
|
||||||
}, nil
|
}, 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) {
|
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)
|
result, err := p.analyticsRepo.GetExclusiveSummaryAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -782,6 +1100,7 @@ func (p *AnalyticsProcessorImpl) buildExclusiveSummaryPeriod(ctx context.Context
|
|||||||
return &models.ExclusiveSummaryPeriodResponse{
|
return &models.ExclusiveSummaryPeriodResponse{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
|
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
|
||||||
Period: models.ExclusiveSummaryPeriodRange{
|
Period: models.ExclusiveSummaryPeriodRange{
|
||||||
DateFrom: req.DateFrom,
|
DateFrom: req.DateFrom,
|
||||||
DateTo: req.DateTo,
|
DateTo: req.DateTo,
|
||||||
@@ -836,16 +1155,16 @@ func exclusiveSummarySalaryBreakdown(transactions []entities.ExclusiveSummaryDai
|
|||||||
var salaryOther float64
|
var salaryOther float64
|
||||||
|
|
||||||
for _, transaction := range transactions {
|
for _, transaction := range transactions {
|
||||||
if transaction.Source != "expense" || !isExclusiveSummarySalary(transaction.CategoryCode, transaction.CategoryName, transaction.Description) {
|
if !isExclusiveSummarySalary(transaction.CategoryCode, transaction.CategoryName, transaction.Description) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
classification := strings.ToLower(transaction.CategoryCode + " " + transaction.CategoryName + " " + transaction.Description)
|
classification := strings.ToLower(transaction.CategoryCode + " " + transaction.CategoryName + " " + transaction.Description)
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(classification, "dw"):
|
|
||||||
salaryDW += transaction.Amount
|
|
||||||
case strings.Contains(classification, "staff") || strings.Contains(classification, "kary") || strings.Contains(classification, "karyawan"):
|
case strings.Contains(classification, "staff") || strings.Contains(classification, "kary") || strings.Contains(classification, "karyawan"):
|
||||||
salaryStaff += transaction.Amount
|
salaryStaff += transaction.Amount
|
||||||
|
case strings.Contains(classification, "dw"):
|
||||||
|
salaryDW += transaction.Amount
|
||||||
default:
|
default:
|
||||||
salaryOther += transaction.Amount
|
salaryOther += transaction.Amount
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
|
|
||||||
@@ -13,10 +14,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type analyticsRepositoryStub struct {
|
type analyticsRepositoryStub struct {
|
||||||
purchasingResult *entities.PurchasingAnalytics
|
purchasingResult *entities.PurchasingAnalytics
|
||||||
profitLossResult *entities.ProfitLossAnalytics
|
purchasingTeam *entities.PurchaseTeamFilter
|
||||||
exclusiveResult *entities.ExclusiveSummaryAnalytics
|
budgetCutOffWeeks []*entities.BudgetCutOffWeek
|
||||||
profitLossGroup string
|
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) {
|
func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) {
|
||||||
@@ -27,7 +34,8 @@ func (analyticsRepositoryStub) GetSalesAnalytics(context.Context, uuid.UUID, *uu
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s analyticsRepositoryStub) GetPurchasingAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.PurchasingAnalytics, error) {
|
func (s *analyticsRepositoryStub) GetPurchasingAnalytics(_ context.Context, _ uuid.UUID, _ *uuid.UUID, team *entities.PurchaseTeamFilter, _, _ time.Time, _ string) (*entities.PurchasingAnalytics, error) {
|
||||||
|
s.purchasingTeam = team
|
||||||
return s.purchasingResult, nil
|
return s.purchasingResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +47,18 @@ func (analyticsRepositoryStub) GetProductAnalyticsPerCategory(context.Context, u
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (analyticsRepositoryStub) GetProductAnalyticsPerParentCategory(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.ProductAnalyticsPerParentCategory, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (analyticsRepositoryStub) GetParentCategoryAnalyticsDetail(context.Context, uuid.UUID, *uuid.UUID, uuid.UUID, time.Time, time.Time) (*entities.ParentCategoryAnalyticsDetail, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s analyticsRepositoryStub) GetBudgetCutOffWeekly(context.Context, uuid.UUID, *uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.BudgetCutOffWeek, error) {
|
||||||
|
return s.budgetCutOffWeeks, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (analyticsRepositoryStub) GetDashboardOverview(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) (*entities.DashboardOverview, error) {
|
func (analyticsRepositoryStub) GetDashboardOverview(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) (*entities.DashboardOverview, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -48,8 +68,24 @@ func (s analyticsRepositoryStub) GetProfitLossAnalytics(_ context.Context, _ uui
|
|||||||
return s.profitLossResult, nil
|
return s.profitLossResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s analyticsRepositoryStub) GetExclusiveSummaryAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) (*entities.ExclusiveSummaryAnalytics, error) {
|
func (s *analyticsRepositoryStub) GetExclusiveSummaryAnalytics(_ context.Context, _ uuid.UUID, _ *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error) {
|
||||||
return s.exclusiveResult, nil
|
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{}
|
type expenseRepositoryStub struct{}
|
||||||
@@ -76,7 +112,7 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T)
|
|||||||
outletID := uuid.New()
|
outletID := uuid.New()
|
||||||
outletName := "Main Outlet"
|
outletName := "Main Outlet"
|
||||||
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||||
processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{
|
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
|
||||||
purchasingResult: &entities.PurchasingAnalytics{
|
purchasingResult: &entities.PurchasingAnalytics{
|
||||||
OutletName: &outletName,
|
OutletName: &outletName,
|
||||||
Summary: entities.PurchasingSummary{
|
Summary: entities.PurchasingSummary{
|
||||||
@@ -125,11 +161,115 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T)
|
|||||||
require.Equal(t, float64(175), result.Data[0].ExpensePurchases)
|
require.Equal(t, float64(175), result.Data[0].ExpensePurchases)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAnalyticsProcessorGetPurchasingAnalyticsPassesTeamFilter(t *testing.T) {
|
||||||
|
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) {
|
func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) {
|
||||||
productID := uuid.New()
|
productID := uuid.New()
|
||||||
categoryID := uuid.New()
|
categoryID := uuid.New()
|
||||||
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||||
processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{
|
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
|
||||||
profitLossResult: &entities.ProfitLossAnalytics{
|
profitLossResult: &entities.ProfitLossAnalytics{
|
||||||
Summary: entities.ProfitLossSummary{
|
Summary: entities.ProfitLossSummary{
|
||||||
TotalRevenue: 1000,
|
TotalRevenue: 1000,
|
||||||
@@ -201,7 +341,7 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *
|
|||||||
|
|
||||||
func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *testing.T) {
|
func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *testing.T) {
|
||||||
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||||
processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{
|
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
|
||||||
profitLossResult: &entities.ProfitLossAnalytics{
|
profitLossResult: &entities.ProfitLossAnalytics{
|
||||||
Summary: entities.ProfitLossSummary{
|
Summary: entities.ProfitLossSummary{
|
||||||
TotalRevenue: 10000,
|
TotalRevenue: 10000,
|
||||||
@@ -279,21 +419,28 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *tes
|
|||||||
require.True(t, result.MainSummary[6].IsBold)
|
require.True(t, result.MainSummary[6].IsBold)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesSummaryAndReimburse(t *testing.T) {
|
func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesTotalsAndReimburse(t *testing.T) {
|
||||||
now := time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC)
|
||||||
processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{
|
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
|
||||||
exclusiveResult: &entities.ExclusiveSummaryAnalytics{
|
exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{
|
||||||
SalesTotal: 35619000,
|
{
|
||||||
HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{
|
SalesTotal: 1000,
|
||||||
{CategoryCode: "hpp_nusantara", CategoryName: "Nusantara", Amount: 19010552},
|
HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{
|
||||||
},
|
{CategoryCode: "RAW", CategoryName: "Raw", Amount: 400},
|
||||||
OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{
|
},
|
||||||
{CategoryCode: "biaya_gaji", CategoryName: "Gaji", Amount: 51758333},
|
OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{
|
||||||
{CategoryCode: "biaya_lain", CategoryName: "Biaya Lain-lain", Amount: 1608605},
|
{CategoryCode: "GAJI", CategoryName: "Gaji", Amount: 250},
|
||||||
},
|
{CategoryCode: "OPS", CategoryName: "Operasional", Amount: 100},
|
||||||
DailyTransactions: []entities.ExclusiveSummaryDailyTransaction{
|
},
|
||||||
{Date: now, CategoryCode: "biaya_gaji", CategoryName: "Gaji", Description: "gaji kary", Amount: 48203333, Source: "expense"},
|
DailySummary: []entities.ExclusiveSummaryDailySummary{
|
||||||
{Date: now, CategoryCode: "biaya_gaji_dw", CategoryName: "Gaji DW", Description: "gaji karyawan", Amount: 3555000, Source: "expense"},
|
{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{})
|
}, expenseRepositoryStub{})
|
||||||
@@ -307,34 +454,45 @@ func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesSummaryAndReimburs
|
|||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, result)
|
require.NotNil(t, result)
|
||||||
require.Equal(t, float64(35619000), result.Summary.Sales)
|
require.Equal(t, float64(1000), result.Summary.Sales)
|
||||||
require.Equal(t, float64(19010552), result.Summary.HPP)
|
require.Equal(t, float64(400), result.Summary.HPP)
|
||||||
require.Equal(t, float64(16608448), result.Summary.GrossProfit)
|
require.Equal(t, float64(600), result.Summary.GrossProfit)
|
||||||
require.Equal(t, float64(51758333), result.Summary.SalaryTotal)
|
require.Equal(t, float64(350), result.Summary.OperationalExpensesTotal)
|
||||||
require.Equal(t, float64(3555000), result.Summary.SalaryDW)
|
require.Equal(t, float64(750), result.Summary.TotalCost)
|
||||||
require.Equal(t, float64(48203333), result.Summary.SalaryStaff)
|
require.Equal(t, float64(250), result.Summary.NetProfit)
|
||||||
require.Equal(t, float64(53366938), result.Summary.OperationalExpensesTotal)
|
require.Equal(t, float64(250), result.Summary.SalaryTotal)
|
||||||
require.Equal(t, float64(72377490), result.Summary.TotalCost)
|
require.Equal(t, float64(50), result.Summary.SalaryDW)
|
||||||
require.Equal(t, float64(-36758490), result.Summary.NetProfit)
|
require.Equal(t, float64(200), result.Summary.SalaryStaff)
|
||||||
require.Equal(t, float64(48203333), result.Reimburse.ExcludedSalaryStaff)
|
require.Equal(t, float64(100), result.Summary.OtherOperationalExpenses)
|
||||||
require.Equal(t, float64(24174157), result.Reimburse.TotalReimburse)
|
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 TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsCalendarBucketsAndBankTemplate(t *testing.T) {
|
func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsSummaryAndBuckets(t *testing.T) {
|
||||||
location, err := time.LoadLocation("Asia/Jakarta")
|
location, err := time.LoadLocation("Asia/Jakarta")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
month := time.Date(2026, 5, 1, 0, 0, 0, 0, location)
|
month := time.Date(2026, 5, 1, 0, 0, 0, 0, location)
|
||||||
processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{
|
openingBalance := 5000000.0
|
||||||
exclusiveResult: &entities.ExclusiveSummaryAnalytics{
|
closingBalance := 5000000.0
|
||||||
SalesTotal: 1000,
|
notes := "Main cash account for daily transactions"
|
||||||
HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{
|
stub := &analyticsRepositoryStub{
|
||||||
{CategoryCode: "hpp", CategoryName: "HPP", Amount: 400},
|
exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{
|
||||||
},
|
{SalesTotal: 1000, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 400}}, OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 100}}},
|
||||||
OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{
|
{SalesTotal: 100, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 40}}},
|
||||||
{CategoryCode: "ops", CategoryName: "OPS", Amount: 100},
|
{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}}},
|
||||||
},
|
},
|
||||||
}, expenseRepositoryStub{})
|
bankBalances: []entities.ExclusiveSummaryBankBalance{
|
||||||
|
{Bank: "Cash and Bank", OpeningBalance: &openingBalance, ClosingBalance: &closingBalance, Notes: ¬es},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
processor := NewAnalyticsProcessorImpl(stub, expenseRepositoryStub{})
|
||||||
|
|
||||||
result, err := processor.GetExclusiveSummaryMonthly(context.Background(), &models.ExclusiveSummaryMonthlyRequest{
|
result, err := processor.GetExclusiveSummaryMonthly(context.Background(), &models.ExclusiveSummaryMonthlyRequest{
|
||||||
OrganizationID: uuid.New(),
|
OrganizationID: uuid.New(),
|
||||||
@@ -345,11 +503,70 @@ func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsCalendarBucketsAndBan
|
|||||||
require.NotNil(t, result)
|
require.NotNil(t, result)
|
||||||
require.Equal(t, "2026-05", result.Month)
|
require.Equal(t, "2026-05", result.Month)
|
||||||
require.Equal(t, float64(1000), result.Summary.TotalSales)
|
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.Equal(t, float64(500), result.Summary.NetProfit)
|
||||||
|
require.InDelta(t, float64(50), result.Summary.NetProfitMargin, 0.0001)
|
||||||
require.Len(t, result.Periods, 5)
|
require.Len(t, result.Periods, 5)
|
||||||
require.Equal(t, "1 - 3 Mei", result.Periods[0].Label)
|
require.Equal(t, "1 - 3 Mei", result.Periods[0].Label)
|
||||||
require.Equal(t, "25 - 31 Mei", result.Periods[4].Label)
|
require.Equal(t, "25 - 31 Mei", result.Periods[4].Label)
|
||||||
require.Len(t, result.BankBalance, 2)
|
require.Len(t, result.BankBalance, 1)
|
||||||
require.Equal(t, "BCA", result.BankBalance[0].Bank)
|
require.Equal(t, "Cash and Bank", result.BankBalance[0].Bank)
|
||||||
require.Equal(t, "BRI", result.BankBalance[1].Bank)
|
require.NotNil(t, result.BankBalance[0].OpeningBalance)
|
||||||
|
require.Equal(t, openingBalance, *result.BankBalance[0].OpeningBalance)
|
||||||
|
require.NotNil(t, result.BankBalance[0].ClosingBalance)
|
||||||
|
require.Equal(t, closingBalance, *result.BankBalance[0].ClosingBalance)
|
||||||
|
require.Nil(t, result.BankBalance[0].IncomingMutation)
|
||||||
|
require.Nil(t, result.BankBalance[0].OutgoingMutation)
|
||||||
|
require.NotNil(t, result.BankBalance[0].Notes)
|
||||||
|
require.Equal(t, notes, *result.BankBalance[0].Notes)
|
||||||
|
require.Equal(t, 6, stub.exclusiveSummaryCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyticsProcessorGetExclusiveSummaryMTDBuildsMonthToDateBreakdown(t *testing.T) {
|
||||||
|
location, err := time.LoadLocation("Asia/Jakarta")
|
||||||
|
require.NoError(t, err)
|
||||||
|
dateTo := time.Date(2026, 6, 18, 23, 59, 59, int(time.Second-time.Nanosecond), location)
|
||||||
|
stub := &analyticsRepositoryStub{
|
||||||
|
exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{
|
||||||
|
{
|
||||||
|
SalesTotal: 1000,
|
||||||
|
HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{
|
||||||
|
{CategoryCode: "RAW", CategoryName: "Raw Material", Amount: 400},
|
||||||
|
},
|
||||||
|
OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{
|
||||||
|
{CategoryCode: "OPS", CategoryName: "Operational", Amount: 100},
|
||||||
|
},
|
||||||
|
DailySummary: []entities.ExclusiveSummaryDailySummary{
|
||||||
|
{Date: dateTo, TransactionCount: 2, TotalCost: 500},
|
||||||
|
},
|
||||||
|
DailyTransactions: []entities.ExclusiveSummaryDailyTransaction{
|
||||||
|
{Date: dateTo, CategoryCode: "RAW", CategoryName: "Raw Material", Description: "beras", Amount: 400, Source: "purchase_order"},
|
||||||
|
{Date: dateTo, CategoryCode: "OPS", CategoryName: "Operational", Description: "atk", Amount: 100, Source: "expense"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
processor := NewAnalyticsProcessorImpl(stub, expenseRepositoryStub{})
|
||||||
|
|
||||||
|
result, err := processor.GetExclusiveSummaryMTD(context.Background(), &models.ExclusiveSummaryMTDRequest{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
DateTo: dateTo,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Len(t, stub.exclusiveSummaryFrom, 1)
|
||||||
|
require.Equal(t, time.Date(2026, 6, 1, 0, 0, 0, 0, location), stub.exclusiveSummaryFrom[0])
|
||||||
|
require.Equal(t, dateTo, stub.exclusiveSummaryTo[0])
|
||||||
|
require.Equal(t, stub.exclusiveSummaryFrom[0], result.Period.DateFrom)
|
||||||
|
require.Equal(t, dateTo, result.Period.DateTo)
|
||||||
|
require.Equal(t, float64(1000), result.Summary.Sales)
|
||||||
|
require.Equal(t, float64(400), result.Summary.HPP)
|
||||||
|
require.Equal(t, float64(500), result.Summary.TotalCost)
|
||||||
|
require.Equal(t, float64(500), result.Summary.NetProfit)
|
||||||
|
require.Len(t, result.HPPBreakdown, 1)
|
||||||
|
require.Equal(t, float64(100), result.HPPBreakdown[0].Percentage)
|
||||||
|
require.Len(t, result.OperationalExpenseBreakdown, 1)
|
||||||
|
require.Len(t, result.DailySummary, 1)
|
||||||
|
require.Len(t, result.DailyTransactions, 2)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package processor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/entities"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func jakarta(t *testing.T) *time.Location {
|
||||||
|
t.Helper()
|
||||||
|
loc, err := time.LoadLocation("Asia/Jakarta")
|
||||||
|
require.NoError(t, err)
|
||||||
|
return loc
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartOfWeekLandsOnMonday(t *testing.T) {
|
||||||
|
loc := jakarta(t)
|
||||||
|
|
||||||
|
// 3 Aug 2026 is a Monday, so the whole week must collapse onto it
|
||||||
|
monday := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
|
||||||
|
|
||||||
|
for offset := 0; offset < 7; offset++ {
|
||||||
|
day := monday.AddDate(0, 0, offset).Add(13 * time.Hour)
|
||||||
|
|
||||||
|
got := startOfWeek(day)
|
||||||
|
require.Equal(t, monday, got, "day %s should map to %s", day, monday)
|
||||||
|
require.Equal(t, time.Monday, got.Weekday())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEndOfWeekLandsOnSunday(t *testing.T) {
|
||||||
|
loc := jakarta(t)
|
||||||
|
|
||||||
|
// Sunday 9 Aug 2026 closes the week that starts Monday 3 Aug
|
||||||
|
got := endOfWeek(time.Date(2026, 8, 5, 9, 30, 0, 0, loc))
|
||||||
|
|
||||||
|
require.Equal(t, time.Sunday, got.Weekday())
|
||||||
|
require.Equal(t, 2026, got.Year())
|
||||||
|
require.Equal(t, time.August, got.Month())
|
||||||
|
require.Equal(t, 9, got.Day())
|
||||||
|
require.Equal(t, 23, got.Hour())
|
||||||
|
require.Equal(t, 59, got.Minute())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildBudgetCutOffWidensToWholeWeeks(t *testing.T) {
|
||||||
|
loc := jakarta(t)
|
||||||
|
processor := &AnalyticsProcessorImpl{analyticsRepo: &analyticsRepositoryStub{}}
|
||||||
|
|
||||||
|
// Saturday 1 Aug to Monday 31 Aug 2026: both ends fall mid-week
|
||||||
|
from := time.Date(2026, 8, 1, 0, 0, 0, 0, loc)
|
||||||
|
to := time.Date(2026, 8, 31, 23, 59, 59, 0, loc)
|
||||||
|
|
||||||
|
budget, err := processor.buildBudgetCutOff(context.Background(), uuid.New(), nil, nil, from, to)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Reaches back into July and forward into September to keep weeks whole
|
||||||
|
require.Equal(t, time.Monday, budget.CutOffFrom.Weekday())
|
||||||
|
require.Equal(t, time.July, budget.CutOffFrom.Month())
|
||||||
|
require.Equal(t, 27, budget.CutOffFrom.Day())
|
||||||
|
require.Equal(t, time.Sunday, budget.CutOffTo.Weekday())
|
||||||
|
require.Equal(t, time.September, budget.CutOffTo.Month())
|
||||||
|
require.Equal(t, 6, budget.CutOffTo.Day())
|
||||||
|
|
||||||
|
require.Len(t, budget.Weekly, 6)
|
||||||
|
for _, week := range budget.Weekly {
|
||||||
|
require.Equal(t, time.Monday, week.PeriodStart.Weekday())
|
||||||
|
require.Equal(t, time.Sunday, week.PeriodEnd.Weekday())
|
||||||
|
}
|
||||||
|
|
||||||
|
// A week is filed under the month of its Monday, so the 27 Jul week counts as July
|
||||||
|
require.Len(t, budget.Monthly, 2)
|
||||||
|
require.Equal(t, "2026-07", budget.Monthly[0].Month)
|
||||||
|
require.Equal(t, 1, budget.Monthly[0].WeekCount)
|
||||||
|
require.Equal(t, "2026-08", budget.Monthly[1].Month)
|
||||||
|
require.Equal(t, 5, budget.Monthly[1].WeekCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildBudgetCutOffAppliesLimits(t *testing.T) {
|
||||||
|
loc := jakarta(t)
|
||||||
|
weekStart := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
|
||||||
|
|
||||||
|
stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{
|
||||||
|
{WeekStart: weekStart, Revenue: 10_000_000, OrderCount: 120},
|
||||||
|
}}
|
||||||
|
processor := &AnalyticsProcessorImpl{analyticsRepo: stub}
|
||||||
|
|
||||||
|
budget, err := processor.buildBudgetCutOff(context.Background(), uuid.New(), nil, nil, weekStart, weekStart.AddDate(0, 0, 6))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, budget.Weekly, 1)
|
||||||
|
|
||||||
|
// 60 / 20 / 20 of the week's revenue
|
||||||
|
week := budget.Weekly[0]
|
||||||
|
require.Equal(t, float64(6_000_000), week.LimitPurchase)
|
||||||
|
require.Equal(t, float64(2_000_000), week.LimitOwner)
|
||||||
|
require.Equal(t, float64(2_000_000), week.LimitTeam)
|
||||||
|
require.Equal(t, int64(120), week.OrderCount)
|
||||||
|
|
||||||
|
// Totals mirror the single week
|
||||||
|
require.Equal(t, week.Revenue, budget.Total.Revenue)
|
||||||
|
require.Equal(t, week.LimitPurchase, budget.Total.LimitPurchase)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildBudgetCutOffAccumulatesMonthlyFromWeeks(t *testing.T) {
|
||||||
|
loc := jakarta(t)
|
||||||
|
first := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
|
||||||
|
|
||||||
|
stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{
|
||||||
|
{WeekStart: first, Revenue: 10_000_000, OrderCount: 100},
|
||||||
|
{WeekStart: first.AddDate(0, 0, 7), Revenue: 5_000_000, OrderCount: 60},
|
||||||
|
}}
|
||||||
|
processor := &AnalyticsProcessorImpl{analyticsRepo: stub}
|
||||||
|
|
||||||
|
budget, err := processor.buildBudgetCutOff(context.Background(), uuid.New(), nil, nil, first, first.AddDate(0, 0, 9))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Len(t, budget.Monthly, 1)
|
||||||
|
month := budget.Monthly[0]
|
||||||
|
require.Equal(t, "2026-08", month.Month)
|
||||||
|
require.Equal(t, 2, month.WeekCount)
|
||||||
|
require.Equal(t, float64(15_000_000), month.Revenue)
|
||||||
|
require.Equal(t, int64(160), month.OrderCount)
|
||||||
|
|
||||||
|
// The month limit is the accumulation of its weeks
|
||||||
|
require.Equal(t, float64(9_000_000), month.LimitPurchase)
|
||||||
|
require.Equal(t, budget.Weekly[0].LimitPurchase+budget.Weekly[1].LimitPurchase, month.LimitPurchase)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildBudgetCutOffEmitsWeeksWithoutSales(t *testing.T) {
|
||||||
|
loc := jakarta(t)
|
||||||
|
first := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
|
||||||
|
|
||||||
|
// Only the third week has sales; the two quiet weeks must still be reported
|
||||||
|
stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{
|
||||||
|
{WeekStart: first.AddDate(0, 0, 14), Revenue: 4_000_000, OrderCount: 40},
|
||||||
|
}}
|
||||||
|
processor := &AnalyticsProcessorImpl{analyticsRepo: stub}
|
||||||
|
|
||||||
|
budget, err := processor.buildBudgetCutOff(context.Background(), uuid.New(), nil, nil, first, first.AddDate(0, 0, 16))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Len(t, budget.Weekly, 3)
|
||||||
|
require.Zero(t, budget.Weekly[0].Revenue)
|
||||||
|
require.Zero(t, budget.Weekly[0].LimitPurchase)
|
||||||
|
require.Zero(t, budget.Weekly[1].Revenue)
|
||||||
|
require.Equal(t, float64(4_000_000), budget.Weekly[2].Revenue)
|
||||||
|
require.Equal(t, float64(4_000_000), budget.Total.Revenue)
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ type CategoryRepository interface {
|
|||||||
GetByID(ctx context.Context, id uuid.UUID) (*entities.Category, error)
|
GetByID(ctx context.Context, id uuid.UUID) (*entities.Category, error)
|
||||||
GetWithProducts(ctx context.Context, id uuid.UUID) (*entities.Category, error)
|
GetWithProducts(ctx context.Context, id uuid.UUID) (*entities.Category, error)
|
||||||
GetByOrganization(ctx context.Context, organizationID uuid.UUID) ([]*entities.Category, error)
|
GetByOrganization(ctx context.Context, organizationID uuid.UUID) ([]*entities.Category, error)
|
||||||
|
ListParentCategories(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]*entities.Category, error)
|
||||||
GetByBusinessType(ctx context.Context, businessType string) ([]*entities.Category, error)
|
GetByBusinessType(ctx context.Context, businessType string) ([]*entities.Category, error)
|
||||||
Update(ctx context.Context, category *entities.Category) error
|
Update(ctx context.Context, category *entities.Category) error
|
||||||
Delete(ctx context.Context, id uuid.UUID) error
|
Delete(ctx context.Context, id uuid.UUID) error
|
||||||
@@ -53,6 +54,18 @@ func (p *CategoryProcessorImpl) CreateCategory(ctx context.Context, req *models.
|
|||||||
return nil, fmt.Errorf("category with name '%s' already exists for this organization", req.Name)
|
return nil, fmt.Errorf("category with name '%s' already exists for this organization", req.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var parentName *string
|
||||||
|
if req.ParentID != nil {
|
||||||
|
parentCategory, err := p.categoryRepo.GetByID(ctx, *req.ParentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parent category not found: %w", err)
|
||||||
|
}
|
||||||
|
if parentCategory.OrganizationID != req.OrganizationID {
|
||||||
|
return nil, fmt.Errorf("parent category must belong to the same organization")
|
||||||
|
}
|
||||||
|
parentName = &parentCategory.Name
|
||||||
|
}
|
||||||
|
|
||||||
// Map request to entity
|
// Map request to entity
|
||||||
categoryEntity := mappers.CreateCategoryRequestToEntity(req)
|
categoryEntity := mappers.CreateCategoryRequestToEntity(req)
|
||||||
|
|
||||||
@@ -63,6 +76,7 @@ func (p *CategoryProcessorImpl) CreateCategory(ctx context.Context, req *models.
|
|||||||
|
|
||||||
// Map entity to response model
|
// Map entity to response model
|
||||||
response := mappers.CategoryEntityToResponse(categoryEntity)
|
response := mappers.CategoryEntityToResponse(categoryEntity)
|
||||||
|
response.ParentName = parentName
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +98,23 @@ func (p *CategoryProcessorImpl) UpdateCategory(ctx context.Context, id uuid.UUID
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.ParentID != nil {
|
||||||
|
if *req.ParentID == id {
|
||||||
|
return nil, fmt.Errorf("category cannot be its own parent")
|
||||||
|
}
|
||||||
|
|
||||||
|
parentCategory, err := p.categoryRepo.GetByID(ctx, *req.ParentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parent category not found: %w", err)
|
||||||
|
}
|
||||||
|
if parentCategory.OrganizationID != existingCategory.OrganizationID {
|
||||||
|
return nil, fmt.Errorf("parent category must belong to the same organization")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the preloaded association so the response carries the new parent
|
||||||
|
existingCategory.Parent = parentCategory
|
||||||
|
}
|
||||||
|
|
||||||
// Apply updates to entity
|
// Apply updates to entity
|
||||||
mappers.UpdateCategoryEntityFromRequest(existingCategory, req)
|
mappers.UpdateCategoryEntityFromRequest(existingCategory, req)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package processor
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"apskel-pos-be/internal/constants"
|
"apskel-pos-be/internal/constants"
|
||||||
@@ -25,12 +26,14 @@ type ExpenseProcessor interface {
|
|||||||
type ExpenseProcessorImpl struct {
|
type ExpenseProcessorImpl struct {
|
||||||
expenseRepo ExpenseRepository
|
expenseRepo ExpenseRepository
|
||||||
purchaseCategoryRepo PurchaseCategoryRepository
|
purchaseCategoryRepo PurchaseCategoryRepository
|
||||||
|
cashAdvanceRepo CashAdvanceRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExpenseProcessorImpl(expenseRepo ExpenseRepository, purchaseCategoryRepo PurchaseCategoryRepository) *ExpenseProcessorImpl {
|
func NewExpenseProcessorImpl(expenseRepo ExpenseRepository, purchaseCategoryRepo PurchaseCategoryRepository, cashAdvanceRepo CashAdvanceRepository) *ExpenseProcessorImpl {
|
||||||
return &ExpenseProcessorImpl{
|
return &ExpenseProcessorImpl{
|
||||||
expenseRepo: expenseRepo,
|
expenseRepo: expenseRepo,
|
||||||
purchaseCategoryRepo: purchaseCategoryRepo,
|
purchaseCategoryRepo: purchaseCategoryRepo,
|
||||||
|
cashAdvanceRepo: cashAdvanceRepo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +53,11 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID
|
|||||||
status = *req.Status
|
status = *req.Status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cashAdvanceID, err := p.resolveExpenseCashAdvance(ctx, organizationID, outletID, req.CashAdvanceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
items := make([]entities.ExpenseItem, len(req.Items))
|
items := make([]entities.ExpenseItem, len(req.Items))
|
||||||
for i, itemReq := range req.Items {
|
for i, itemReq := range req.Items {
|
||||||
chartOfAccountID, err := uuid.Parse(itemReq.ChartOfAccountID)
|
chartOfAccountID, err := uuid.Parse(itemReq.ChartOfAccountID)
|
||||||
@@ -84,6 +92,7 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID
|
|||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
Tax: req.Tax,
|
Tax: req.Tax,
|
||||||
Total: req.Total,
|
Total: req.Total,
|
||||||
|
CashAdvanceID: cashAdvanceID,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = p.expenseRepo.Create(ctx, expenseEntity)
|
err = p.expenseRepo.Create(ctx, expenseEntity)
|
||||||
@@ -149,6 +158,14 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati
|
|||||||
if req.Reserved1 != nil {
|
if req.Reserved1 != nil {
|
||||||
expenseEntity.Reserved1 = req.Reserved1
|
expenseEntity.Reserved1 = req.Reserved1
|
||||||
}
|
}
|
||||||
|
// An empty cash_advance_id unlinks the expense; omitting the field leaves it alone.
|
||||||
|
if req.CashAdvanceID != nil {
|
||||||
|
cashAdvanceID, err := p.resolveExpenseCashAdvance(ctx, organizationID, expenseEntity.OutletID, req.CashAdvanceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
expenseEntity.CashAdvanceID = cashAdvanceID
|
||||||
|
}
|
||||||
|
|
||||||
var items []entities.ExpenseItem
|
var items []entities.ExpenseItem
|
||||||
if req.Items != nil {
|
if req.Items != nil {
|
||||||
@@ -334,6 +351,25 @@ func (p *ExpenseProcessorImpl) GetExpenseAnalytics(ctx context.Context, req *mod
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveExpenseCashAdvance checks the expense may be charged to the cash advance it names.
|
||||||
|
// An empty value means no cash advance at all, which is how an update unlinks one.
|
||||||
|
func (p *ExpenseProcessorImpl) resolveExpenseCashAdvance(ctx context.Context, organizationID, outletID uuid.UUID, raw *string) (*uuid.UUID, error) {
|
||||||
|
if raw == nil || strings.TrimSpace(*raw) == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cashAdvanceID, err := uuid.Parse(strings.TrimSpace(*raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid cash_advance_id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := resolveSpendingCashAdvance(ctx, p.cashAdvanceRepo, cashAdvanceID, organizationID, &outletID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cashAdvanceID, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *ExpenseProcessorImpl) validateExpensePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID) error {
|
func (p *ExpenseProcessorImpl) validateExpensePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID) error {
|
||||||
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID)
|
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -99,10 +99,40 @@ func (*expenseRepositoryCaptureStub) DeleteItemsByExpenseID(context.Context, uui
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expenses in these tests are paid straight out of the drawer, so nothing here
|
||||||
|
// reaches the cash advance repository.
|
||||||
|
type expenseCashAdvanceRepositoryStub struct{}
|
||||||
|
|
||||||
|
func (*expenseCashAdvanceRepositoryStub) Create(context.Context, *entities.CashAdvance) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (*expenseCashAdvanceRepositoryStub) GetByID(context.Context, uuid.UUID) (*entities.CashAdvance, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (*expenseCashAdvanceRepositoryStub) GetByIDAndOrganizationID(context.Context, uuid.UUID, uuid.UUID) (*entities.CashAdvance, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (*expenseCashAdvanceRepositoryStub) GetByCodeNumber(context.Context, string, uuid.UUID) (*entities.CashAdvance, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (*expenseCashAdvanceRepositoryStub) Update(context.Context, *entities.CashAdvance) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (*expenseCashAdvanceRepositoryStub) Delete(context.Context, uuid.UUID) error { return nil }
|
||||||
|
func (*expenseCashAdvanceRepositoryStub) List(context.Context, uuid.UUID, map[string]interface{}, int, int) ([]*entities.CashAdvance, int64, error) {
|
||||||
|
return nil, 0, nil
|
||||||
|
}
|
||||||
|
func (*expenseCashAdvanceRepositoryStub) ListSettlements(context.Context, uuid.UUID) ([]*entities.CashAdvanceSettlement, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (*expenseCashAdvanceRepositoryStub) CountSettlements(context.Context, uuid.UUID) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestExpenseProcessorCreatePersistsItemName(t *testing.T) {
|
func TestExpenseProcessorCreatePersistsItemName(t *testing.T) {
|
||||||
repo := &expenseRepositoryCaptureStub{}
|
repo := &expenseRepositoryCaptureStub{}
|
||||||
purchaseCategoryID := uuid.New()
|
purchaseCategoryID := uuid.New()
|
||||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
|
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense), &expenseCashAdvanceRepositoryStub{})
|
||||||
chartOfAccountID := uuid.New()
|
chartOfAccountID := uuid.New()
|
||||||
|
|
||||||
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
|
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
|
||||||
@@ -133,7 +163,7 @@ func TestExpenseProcessorCreatePersistsItemName(t *testing.T) {
|
|||||||
func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) {
|
func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) {
|
||||||
repo := &expenseRepositoryCaptureStub{}
|
repo := &expenseRepositoryCaptureStub{}
|
||||||
purchaseCategoryID := uuid.New()
|
purchaseCategoryID := uuid.New()
|
||||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
|
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense), &expenseCashAdvanceRepositoryStub{})
|
||||||
|
|
||||||
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
|
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
|
||||||
Receiver: "Cashier",
|
Receiver: "Cashier",
|
||||||
@@ -160,7 +190,7 @@ func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) {
|
|||||||
func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) {
|
func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) {
|
||||||
repo := &expenseRepositoryCaptureStub{}
|
repo := &expenseRepositoryCaptureStub{}
|
||||||
purchaseCategoryID := uuid.New()
|
purchaseCategoryID := uuid.New()
|
||||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
|
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense), &expenseCashAdvanceRepositoryStub{})
|
||||||
status := "approved"
|
status := "approved"
|
||||||
|
|
||||||
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
|
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
|
||||||
@@ -189,7 +219,7 @@ func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) {
|
|||||||
func TestExpenseProcessorCreateRejectsRawMaterialPurchaseCategory(t *testing.T) {
|
func TestExpenseProcessorCreateRejectsRawMaterialPurchaseCategory(t *testing.T) {
|
||||||
repo := &expenseRepositoryCaptureStub{}
|
repo := &expenseRepositoryCaptureStub{}
|
||||||
purchaseCategoryID := uuid.New()
|
purchaseCategoryID := uuid.New()
|
||||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeRawMaterial))
|
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeRawMaterial), &expenseCashAdvanceRepositoryStub{})
|
||||||
|
|
||||||
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
|
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
|
||||||
Receiver: "Cashier",
|
Receiver: "Cashier",
|
||||||
@@ -266,7 +296,7 @@ func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *te
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
|
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense), &expenseCashAdvanceRepositoryStub{})
|
||||||
|
|
||||||
resp, err := p.GetExpenseAnalytics(context.Background(), &models.ExpenseAnalyticsRequest{
|
resp, err := p.GetExpenseAnalytics(context.Background(), &models.ExpenseAnalyticsRequest{
|
||||||
OrganizationID: uuid.New(),
|
OrganizationID: uuid.New(),
|
||||||
|
|||||||
@@ -27,8 +27,11 @@ func NewIngredientProcessor(ingredientRepo IngredientRepository, unitRepo UnitRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *IngredientProcessorImpl) CreateIngredient(ctx context.Context, req *models.CreateIngredientRequest) (*models.IngredientResponse, error) {
|
func (p *IngredientProcessorImpl) CreateIngredient(ctx context.Context, req *models.CreateIngredientRequest) (*models.IngredientResponse, error) {
|
||||||
if _, err := p.unitRepo.GetByID(ctx, req.UnitID, req.OrganizationID); err != nil {
|
// The unit is optional, so it is only validated when one is supplied.
|
||||||
return nil, err
|
if req.UnitID != nil {
|
||||||
|
if _, err := p.unitRepo.GetByID(ctx, *req.UnitID, req.OrganizationID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ingredient := &entities.Ingredient{
|
ingredient := &entities.Ingredient{
|
||||||
@@ -107,8 +110,8 @@ func (p *IngredientProcessorImpl) UpdateIngredient(ctx context.Context, id uuid.
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.UnitID != existing.UnitID {
|
if req.UnitID != nil && (existing.UnitID == nil || *req.UnitID != *existing.UnitID) {
|
||||||
if _, err := p.unitRepo.GetByID(ctx, req.UnitID, organizationID); err != nil {
|
if _, err := p.unitRepo.GetByID(ctx, *req.UnitID, organizationID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,15 +266,27 @@ func (p *IngredientUnitConverterProcessorImpl) GetUnitsByIngredientID(ctx contex
|
|||||||
return nil, fmt.Errorf("failed to get ingredient: %w", err)
|
return nil, fmt.Errorf("failed to get ingredient: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the base unit details
|
response := &models.IngredientUnitsResponse{
|
||||||
baseUnit, err := p.unitRepo.GetByID(ctx, ingredient.UnitID, organizationID)
|
IngredientID: ingredientID,
|
||||||
if err != nil {
|
IngredientName: ingredient.Name,
|
||||||
return nil, fmt.Errorf("failed to get base unit: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start with the base unit
|
units := make([]*models.UnitResponse, 0)
|
||||||
units := []*models.UnitResponse{
|
unitMap := make(map[uuid.UUID]bool)
|
||||||
mappers.MapUnitEntityToResponse(baseUnit),
|
|
||||||
|
// An ingredient does not necessarily have a unit assigned yet. When it has
|
||||||
|
// none there is no base unit to start from, so the only units on offer are
|
||||||
|
// the ones its converters mention.
|
||||||
|
if ingredient.UnitID != nil {
|
||||||
|
baseUnit, err := p.unitRepo.GetByID(ctx, *ingredient.UnitID, organizationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get base unit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
units = append(units, mappers.MapUnitEntityToResponse(baseUnit))
|
||||||
|
unitMap[baseUnit.ID] = true
|
||||||
|
response.BaseUnitID = &baseUnit.ID
|
||||||
|
response.BaseUnitName = baseUnit.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all converters for this ingredient
|
// Get all converters for this ingredient
|
||||||
@@ -283,10 +295,6 @@ func (p *IngredientUnitConverterProcessorImpl) GetUnitsByIngredientID(ctx contex
|
|||||||
return nil, fmt.Errorf("failed to get converters: %w", err)
|
return nil, fmt.Errorf("failed to get converters: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add unique units from converters
|
|
||||||
unitMap := make(map[uuid.UUID]bool)
|
|
||||||
unitMap[baseUnit.ID] = true
|
|
||||||
|
|
||||||
for _, converter := range converters {
|
for _, converter := range converters {
|
||||||
if converter.IsActive {
|
if converter.IsActive {
|
||||||
// Add FromUnit if not already added
|
// Add FromUnit if not already added
|
||||||
@@ -309,13 +317,7 @@ func (p *IngredientUnitConverterProcessorImpl) GetUnitsByIngredientID(ctx contex
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
response := &models.IngredientUnitsResponse{
|
response.Units = units
|
||||||
IngredientID: ingredientID,
|
|
||||||
IngredientName: ingredient.Name,
|
|
||||||
BaseUnitID: baseUnit.ID,
|
|
||||||
BaseUnitName: baseUnit.Name,
|
|
||||||
Units: units,
|
|
||||||
}
|
|
||||||
|
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -371,8 +371,8 @@ func (p *OrderIngredientTransactionProcessorImpl) CalculateWasteQuantities(ctx c
|
|||||||
|
|
||||||
// Get unit name
|
// Get unit name
|
||||||
unitName := "unit" // default
|
unitName := "unit" // default
|
||||||
if ingredient.UnitID != uuid.Nil {
|
if ingredient.UnitID != nil {
|
||||||
unit, err := p.unitRepo.GetByID(ctx, ingredient.UnitID, organizationID)
|
unit, err := p.unitRepo.GetByID(ctx, *ingredient.UnitID, organizationID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
unitName = unit.Name
|
unitName = unit.Name
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,23 +11,29 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type PurchaseOrderProcessor interface {
|
type PurchaseOrderProcessor interface {
|
||||||
CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error)
|
CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error)
|
||||||
UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error)
|
UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, outletID *uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error)
|
||||||
DeletePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID) error
|
DeletePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID) error
|
||||||
GetPurchaseOrderByID(ctx context.Context, id, organizationID uuid.UUID) (*models.PurchaseOrderResponse, error)
|
GetPurchaseOrderByID(ctx context.Context, id, organizationID uuid.UUID) (*models.PurchaseOrderResponse, error)
|
||||||
ListPurchaseOrders(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.PurchaseOrderResponse, int, error)
|
ListPurchaseOrders(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.PurchaseOrderResponse, int, error)
|
||||||
GetPurchaseOrdersByStatus(ctx context.Context, organizationID uuid.UUID, status string) ([]*models.PurchaseOrderResponse, error)
|
GetPurchaseOrdersByStatus(ctx context.Context, organizationID uuid.UUID, status string) ([]*models.PurchaseOrderResponse, error)
|
||||||
GetOverduePurchaseOrders(ctx context.Context, organizationID uuid.UUID) ([]*models.PurchaseOrderResponse, error)
|
GetOverduePurchaseOrders(ctx context.Context, organizationID uuid.UUID) ([]*models.PurchaseOrderResponse, error)
|
||||||
UpdatePurchaseOrderStatus(ctx context.Context, id, organizationID, userID, outletID uuid.UUID, status string) (*models.PurchaseOrderResponse, error)
|
UpdatePurchaseOrderStatus(ctx context.Context, id, organizationID, userID, outletID uuid.UUID, status string) (*models.PurchaseOrderResponse, error)
|
||||||
|
ListPurchaseTeams(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type PurchaseOrderProcessorImpl struct {
|
type PurchaseOrderProcessorImpl struct {
|
||||||
purchaseOrderRepo PurchaseOrderRepository
|
purchaseOrderRepo PurchaseOrderRepository
|
||||||
vendorRepo VendorRepository
|
vendorRepo VendorRepository
|
||||||
ingredientRepo IngredientRepository
|
ingredientRepo IngredientRepository
|
||||||
purchaseCategoryRepo PurchaseCategoryRepository
|
purchaseCategoryRepo PurchaseCategoryRepository
|
||||||
unitRepo UnitRepository
|
categoryRepo CategoryRepository
|
||||||
fileRepo FileRepository
|
cashAdvanceRepo CashAdvanceRepository
|
||||||
|
unitRepo UnitRepository
|
||||||
|
fileRepo FileRepository
|
||||||
|
// Kept wired but currently unused: purchase orders are a record of spending
|
||||||
|
// only, so nothing here moves stock or converts units. These stay so that
|
||||||
|
// tying purchases back to inventory is a change in one place.
|
||||||
inventoryMovementService InventoryMovementService
|
inventoryMovementService InventoryMovementService
|
||||||
unitConverterRepo IngredientUnitConverterRepository
|
unitConverterRepo IngredientUnitConverterRepository
|
||||||
}
|
}
|
||||||
@@ -37,6 +43,8 @@ func NewPurchaseOrderProcessorImpl(
|
|||||||
vendorRepo VendorRepository,
|
vendorRepo VendorRepository,
|
||||||
ingredientRepo IngredientRepository,
|
ingredientRepo IngredientRepository,
|
||||||
purchaseCategoryRepo PurchaseCategoryRepository,
|
purchaseCategoryRepo PurchaseCategoryRepository,
|
||||||
|
categoryRepo CategoryRepository,
|
||||||
|
cashAdvanceRepo CashAdvanceRepository,
|
||||||
unitRepo UnitRepository,
|
unitRepo UnitRepository,
|
||||||
fileRepo FileRepository,
|
fileRepo FileRepository,
|
||||||
inventoryMovementService InventoryMovementService,
|
inventoryMovementService InventoryMovementService,
|
||||||
@@ -47,6 +55,8 @@ func NewPurchaseOrderProcessorImpl(
|
|||||||
vendorRepo: vendorRepo,
|
vendorRepo: vendorRepo,
|
||||||
ingredientRepo: ingredientRepo,
|
ingredientRepo: ingredientRepo,
|
||||||
purchaseCategoryRepo: purchaseCategoryRepo,
|
purchaseCategoryRepo: purchaseCategoryRepo,
|
||||||
|
categoryRepo: categoryRepo,
|
||||||
|
cashAdvanceRepo: cashAdvanceRepo,
|
||||||
unitRepo: unitRepo,
|
unitRepo: unitRepo,
|
||||||
fileRepo: fileRepo,
|
fileRepo: fileRepo,
|
||||||
inventoryMovementService: inventoryMovementService,
|
inventoryMovementService: inventoryMovementService,
|
||||||
@@ -54,11 +64,23 @@ func NewPurchaseOrderProcessorImpl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
|
func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
|
||||||
// Check if vendor exists and belongs to organization
|
// Check if vendor exists and belongs to organization when provided.
|
||||||
_, err := p.vendorRepo.GetByIDAndOrganizationID(ctx, req.VendorID, organizationID)
|
if req.VendorID != nil {
|
||||||
|
_, err := p.vendorRepo.GetByIDAndOrganizationID(ctx, *req.VendorID, organizationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("vendor not found: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
teamScope, teamCategoryID, err := p.resolvePurchaseTeam(ctx, organizationID, outletID, req.TeamScope, req.TeamCategoryID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("vendor not found: %w", err)
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
teamScope, teamCategoryID, err = p.applyCashAdvance(ctx, organizationID, outletID, req.CashAdvanceID, teamScope, teamCategoryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if PO number already exists in organization
|
// Check if PO number already exists in organization
|
||||||
@@ -67,32 +89,53 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
|
|||||||
return nil, fmt.Errorf("purchase order with PO number %s already exists in this organization", req.PONumber)
|
return nil, fmt.Errorf("purchase order with PO number %s already exists in this organization", req.PONumber)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Purchase orders are raw-material only because they affect ingredient stock.
|
// Validate categories and inventory fields per item type.
|
||||||
for i, item := range req.Items {
|
for i, item := range req.Items {
|
||||||
if err := p.validateRawMaterialPurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i); err != nil {
|
category, err := p.validatePurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID)
|
switch category.Type {
|
||||||
if err != nil {
|
case entities.PurchaseCategoryTypeRawMaterial:
|
||||||
return nil, fmt.Errorf("ingredient not found for item %d: %w", i, err)
|
if item.IngredientID == nil {
|
||||||
}
|
return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i)
|
||||||
|
}
|
||||||
|
if item.Quantity == nil {
|
||||||
|
return nil, fmt.Errorf("quantity is required for raw_material item %d", i)
|
||||||
|
}
|
||||||
|
if item.UnitID == nil {
|
||||||
|
return nil, fmt.Errorf("unit_id is required for raw_material item %d", i)
|
||||||
|
}
|
||||||
|
|
||||||
_, err = p.unitRepo.GetByID(ctx, item.UnitID, organizationID)
|
_, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("unit not found for item %d: %w", i, err)
|
return nil, fmt.Errorf("ingredient not found for item %d: %w", i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = p.unitRepo.GetByID(ctx, *item.UnitID, organizationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unit not found for item %d: %w", i, err)
|
||||||
|
}
|
||||||
|
case entities.PurchaseCategoryTypeExpense:
|
||||||
|
if item.IngredientID != nil || item.Quantity != nil || item.UnitID != nil {
|
||||||
|
return nil, fmt.Errorf("ingredient_id, quantity, and unit_id must be empty for expense item %d", i)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("purchase category for item %d has unsupported type %s", i, category.Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate total amount
|
// Calculate total amount
|
||||||
totalAmount := 0.0
|
totalAmount := 0.0
|
||||||
for _, item := range req.Items {
|
for _, item := range req.Items {
|
||||||
totalAmount += item.Amount
|
totalAmount += calculatePurchaseOrderItemTotal(item.Quantity, item.Amount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create purchase order entity
|
// Create purchase order entity
|
||||||
poEntity := &entities.PurchaseOrder{
|
poEntity := &entities.PurchaseOrder{
|
||||||
OrganizationID: organizationID,
|
OrganizationID: organizationID,
|
||||||
|
OutletID: outletID,
|
||||||
VendorID: req.VendorID,
|
VendorID: req.VendorID,
|
||||||
PONumber: req.PONumber,
|
PONumber: req.PONumber,
|
||||||
TransactionDate: req.TransactionDate,
|
TransactionDate: req.TransactionDate,
|
||||||
@@ -101,6 +144,9 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
|
|||||||
Status: "draft", // Default status
|
Status: "draft", // Default status
|
||||||
Message: req.Message,
|
Message: req.Message,
|
||||||
TotalAmount: totalAmount,
|
TotalAmount: totalAmount,
|
||||||
|
TeamScope: teamScope,
|
||||||
|
TeamCategoryID: teamCategoryID,
|
||||||
|
CashAdvanceID: req.CashAdvanceID,
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Status != nil {
|
if req.Status != nil {
|
||||||
@@ -153,12 +199,15 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
|
|||||||
return mappers.PurchaseOrderEntityToResponse(createdPO), nil
|
return mappers.PurchaseOrderEntityToResponse(createdPO), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
|
func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, outletID *uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
|
||||||
// Get existing purchase order
|
// Get existing purchase order
|
||||||
poEntity, err := p.purchaseOrderRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
|
poEntity, err := p.purchaseOrderRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("purchase order not found: %w", err)
|
return nil, fmt.Errorf("purchase order not found: %w", err)
|
||||||
}
|
}
|
||||||
|
if poEntity.OutletID == nil && outletID != nil {
|
||||||
|
poEntity.OutletID = outletID
|
||||||
|
}
|
||||||
|
|
||||||
// Check if vendor exists and belongs to organization (if vendor is being updated)
|
// Check if vendor exists and belongs to organization (if vendor is being updated)
|
||||||
if req.VendorID != nil {
|
if req.VendorID != nil {
|
||||||
@@ -166,7 +215,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("vendor not found: %w", err)
|
return nil, fmt.Errorf("vendor not found: %w", err)
|
||||||
}
|
}
|
||||||
poEntity.VendorID = *req.VendorID
|
poEntity.VendorID = req.VendorID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if PO number already exists (if PO number is being updated)
|
// Check if PO number already exists (if PO number is being updated)
|
||||||
@@ -195,6 +244,36 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
|
|||||||
poEntity.Message = req.Message
|
poEntity.Message = req.Message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An omitted team_scope leaves the team as it is; an empty one clears it.
|
||||||
|
if req.TeamScope != nil {
|
||||||
|
teamScope, teamCategoryID, err := p.resolvePurchaseTeam(ctx, organizationID, poEntity.OutletID, req.TeamScope, req.TeamCategoryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
poEntity.TeamScope = teamScope
|
||||||
|
poEntity.TeamCategoryID = teamCategoryID
|
||||||
|
}
|
||||||
|
|
||||||
|
// An all-zero cash advance id unlinks the purchase; omitting the field leaves it alone.
|
||||||
|
if req.CashAdvanceID != nil {
|
||||||
|
if *req.CashAdvanceID == uuid.Nil {
|
||||||
|
poEntity.CashAdvanceID = nil
|
||||||
|
} else {
|
||||||
|
poEntity.CashAdvanceID = req.CashAdvanceID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recheck the pairing whenever either side moved: a purchase can end up on a
|
||||||
|
// cash advance belonging to another team otherwise.
|
||||||
|
if poEntity.CashAdvanceID != nil && (req.CashAdvanceID != nil || req.TeamScope != nil) {
|
||||||
|
teamScope, teamCategoryID, err := p.applyCashAdvance(ctx, organizationID, poEntity.OutletID, poEntity.CashAdvanceID, poEntity.TeamScope, poEntity.TeamCategoryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
poEntity.TeamScope = teamScope
|
||||||
|
poEntity.TeamCategoryID = teamCategoryID
|
||||||
|
}
|
||||||
|
|
||||||
// Update items if provided
|
// Update items if provided
|
||||||
if req.Items != nil {
|
if req.Items != nil {
|
||||||
totalAmount := 0.0
|
totalAmount := 0.0
|
||||||
@@ -204,38 +283,48 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
|
|||||||
return nil, fmt.Errorf("purchase_category_id is required for item %d", i)
|
return nil, fmt.Errorf("purchase_category_id is required for item %d", i)
|
||||||
}
|
}
|
||||||
|
|
||||||
if itemReq.IngredientID == nil {
|
ingredientID := itemReq.IngredientID
|
||||||
return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i)
|
|
||||||
}
|
|
||||||
if itemReq.Quantity == nil {
|
|
||||||
return nil, fmt.Errorf("quantity is required for raw_material item %d", i)
|
|
||||||
}
|
|
||||||
if itemReq.UnitID == nil {
|
|
||||||
return nil, fmt.Errorf("unit_id is required for raw_material item %d", i)
|
|
||||||
}
|
|
||||||
|
|
||||||
ingredientID := *itemReq.IngredientID
|
|
||||||
purchaseCategoryID := *itemReq.PurchaseCategoryID
|
purchaseCategoryID := *itemReq.PurchaseCategoryID
|
||||||
unitID := *itemReq.UnitID
|
unitID := itemReq.UnitID
|
||||||
quantity := *itemReq.Quantity
|
quantity := itemReq.Quantity
|
||||||
amount := 0.0
|
amount := 0.0
|
||||||
if itemReq.Amount != nil {
|
if itemReq.Amount != nil {
|
||||||
amount = *itemReq.Amount
|
amount = *itemReq.Amount
|
||||||
}
|
}
|
||||||
description := itemReq.Description
|
description := itemReq.Description
|
||||||
|
|
||||||
if err := p.validateRawMaterialPurchaseCategory(ctx, purchaseCategoryID, organizationID, i); err != nil {
|
category, err := p.validatePurchaseCategory(ctx, purchaseCategoryID, organizationID, i)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := p.ingredientRepo.GetByID(ctx, ingredientID, organizationID)
|
switch category.Type {
|
||||||
if err != nil {
|
case entities.PurchaseCategoryTypeRawMaterial:
|
||||||
return nil, fmt.Errorf("ingredient not found: %w", err)
|
if ingredientID == nil {
|
||||||
}
|
return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i)
|
||||||
|
}
|
||||||
|
if quantity == nil {
|
||||||
|
return nil, fmt.Errorf("quantity is required for raw_material item %d", i)
|
||||||
|
}
|
||||||
|
if unitID == nil {
|
||||||
|
return nil, fmt.Errorf("unit_id is required for raw_material item %d", i)
|
||||||
|
}
|
||||||
|
|
||||||
_, err = p.unitRepo.GetByID(ctx, unitID, organizationID)
|
_, err := p.ingredientRepo.GetByID(ctx, *ingredientID, organizationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("unit not found: %w", err)
|
return nil, fmt.Errorf("ingredient not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = p.unitRepo.GetByID(ctx, *unitID, organizationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unit not found: %w", err)
|
||||||
|
}
|
||||||
|
case entities.PurchaseCategoryTypeExpense:
|
||||||
|
if ingredientID != nil || quantity != nil || unitID != nil {
|
||||||
|
return nil, fmt.Errorf("ingredient_id, quantity, and unit_id must be empty for expense item %d", i)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("purchase category for item %d has unsupported type %s", i, category.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
items[i] = &entities.PurchaseOrderItem{
|
items[i] = &entities.PurchaseOrderItem{
|
||||||
@@ -247,7 +336,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
|
|||||||
UnitID: unitID,
|
UnitID: unitID,
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
}
|
}
|
||||||
totalAmount += amount
|
totalAmount += calculatePurchaseOrderItemTotal(quantity, amount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete and recreate only after all replacement items are valid.
|
// Delete and recreate only after all replacement items are valid.
|
||||||
@@ -377,66 +466,21 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
|
|||||||
return nil, fmt.Errorf("purchase order not found: %w", err)
|
return nil, fmt.Errorf("purchase order not found: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if status is changing to "received" and current status is not "received"
|
fmt.Println("status:", po.Status)
|
||||||
if status == "received" && po.Status != "received" {
|
|
||||||
// Get purchase order with items for inventory update
|
|
||||||
poWithItems, err := p.purchaseOrderRepo.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get purchase order with items: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update inventory for each item
|
// A purchase order is a record of spending only. Receiving one does not move
|
||||||
for _, item := range poWithItems.Items {
|
// ingredient stock, does not recalculate ingredient cost, and never converts
|
||||||
// Get ingredient to find its base unit
|
// units: the quantity and unit on an item are kept exactly as the user
|
||||||
ingredient, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID)
|
// entered them. Raw material items are therefore treated the same way expense
|
||||||
if err != nil {
|
// items already were, and the ingredient on an item is just a reference.
|
||||||
return nil, fmt.Errorf("failed to get ingredient %s: %w", item.IngredientID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert quantity to ingredient's base unit if needed
|
|
||||||
quantityToAdd := item.Quantity
|
|
||||||
if item.UnitID != ingredient.UnitID {
|
|
||||||
// Convert from purchase unit to ingredient's base unit
|
|
||||||
convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, item.IngredientID, item.UnitID, ingredient.UnitID, organizationID, item.Quantity)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", item.IngredientID, item.UnitID, ingredient.UnitID, err)
|
|
||||||
}
|
|
||||||
quantityToAdd = convertedQuantity
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate unit cost in ingredient's base unit
|
|
||||||
unitCost := 0.0
|
|
||||||
if quantityToAdd > 0 {
|
|
||||||
unitCost = item.Amount / quantityToAdd
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create inventory movement for ingredient purchase
|
|
||||||
reason := fmt.Sprintf("Purchase order %s received", po.PONumber)
|
|
||||||
referenceType := entities.InventoryMovementReferenceTypePurchaseOrder
|
|
||||||
referenceID := &id
|
|
||||||
|
|
||||||
err = p.inventoryMovementService.CreateIngredientMovement(
|
|
||||||
ctx,
|
|
||||||
item.IngredientID,
|
|
||||||
organizationID,
|
|
||||||
outletID,
|
|
||||||
userID,
|
|
||||||
entities.InventoryMovementTypePurchase,
|
|
||||||
quantityToAdd,
|
|
||||||
unitCost,
|
|
||||||
reason,
|
|
||||||
&referenceType,
|
|
||||||
referenceID,
|
|
||||||
&item.ID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create inventory movement for ingredient %s: %w", item.IngredientID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the purchase order status
|
// Update the purchase order status
|
||||||
err = p.purchaseOrderRepo.UpdateStatus(ctx, id, status)
|
statusOutletID := po.OutletID
|
||||||
|
if statusOutletID == nil && outletID != uuid.Nil {
|
||||||
|
statusOutletID = &outletID
|
||||||
|
}
|
||||||
|
|
||||||
|
err = p.purchaseOrderRepo.UpdateStatusAndOutlet(ctx, id, status, statusOutletID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to update purchase order status: %w", err)
|
return nil, fmt.Errorf("failed to update purchase order status: %w", err)
|
||||||
}
|
}
|
||||||
@@ -450,19 +494,72 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
|
|||||||
return mappers.PurchaseOrderEntityToResponse(updatedPO), nil
|
return mappers.PurchaseOrderEntityToResponse(updatedPO), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PurchaseOrderProcessorImpl) validateRawMaterialPurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) error {
|
// ListPurchaseTeams returns the teams a purchase can be charged to. Cash advances are
|
||||||
|
// charged to the same teams, so the list itself is built in one shared place.
|
||||||
|
func (p *PurchaseOrderProcessorImpl) ListPurchaseTeams(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) {
|
||||||
|
return listTeams(ctx, p.categoryRepo, organizationID, outletID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePurchaseTeam turns a requested team into the scope/category pair stored on
|
||||||
|
// the purchase order. A nil or empty scope leaves the purchase without a team, which
|
||||||
|
// is deliberately different from Pusat.
|
||||||
|
func (p *PurchaseOrderProcessorImpl) resolvePurchaseTeam(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, scope *string, categoryID *uuid.UUID) (*string, *uuid.UUID, error) {
|
||||||
|
return resolveTeamSelection(ctx, p.categoryRepo, organizationID, outletID, scope, categoryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyCashAdvance checks a purchase may be charged to the cash advance it names, and returns
|
||||||
|
// the team it should carry. A purchase paid out of a team's cash belongs to that
|
||||||
|
// team, so an unassigned purchase inherits it and an assigned one has to agree.
|
||||||
|
func (p *PurchaseOrderProcessorImpl) applyCashAdvance(ctx context.Context, organizationID uuid.UUID, outletID, cashAdvanceID *uuid.UUID, teamScope *string, teamCategoryID *uuid.UUID) (*string, *uuid.UUID, error) {
|
||||||
|
if cashAdvanceID == nil {
|
||||||
|
return teamScope, teamCategoryID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cashAdvance, err := resolveSpendingCashAdvance(ctx, p.cashAdvanceRepo, *cashAdvanceID, organizationID, outletID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if teamScope == nil {
|
||||||
|
scope := cashAdvance.TeamScope
|
||||||
|
return &scope, cashAdvance.TeamCategoryID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if *teamScope != cashAdvance.TeamScope || !sameUUID(teamCategoryID, cashAdvance.TeamCategoryID) {
|
||||||
|
return nil, nil, fmt.Errorf("purchase order team must match the team cash advance %s was issued to", cashAdvance.CodeNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
return teamScope, teamCategoryID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameUUID(a, b *uuid.UUID) bool {
|
||||||
|
if a == nil || b == nil {
|
||||||
|
return a == nil && b == nil
|
||||||
|
}
|
||||||
|
return *a == *b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PurchaseOrderProcessorImpl) validatePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) (*entities.PurchaseCategory, error) {
|
||||||
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID)
|
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("purchase category not found for item %d: %w", itemIndex, err)
|
return nil, fmt.Errorf("purchase category not found for item %d: %w", itemIndex, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !category.IsActive {
|
if !category.IsActive {
|
||||||
return fmt.Errorf("purchase category for item %d is inactive", itemIndex)
|
return nil, fmt.Errorf("purchase category for item %d is inactive", itemIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
if category.Type != entities.PurchaseCategoryTypeRawMaterial {
|
if category.Type != entities.PurchaseCategoryTypeRawMaterial && category.Type != entities.PurchaseCategoryTypeExpense {
|
||||||
return fmt.Errorf("purchase category for item %d must be raw_material", itemIndex)
|
return nil, fmt.Errorf("purchase category for item %d must be raw_material or expense", itemIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return category, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculatePurchaseOrderItemTotal(quantity *float64, amount float64) float64 {
|
||||||
|
if quantity == nil {
|
||||||
|
return amount
|
||||||
|
}
|
||||||
|
|
||||||
|
return *quantity * amount
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type PurchaseOrderRepository interface {
|
|||||||
GetByStatus(ctx context.Context, organizationID uuid.UUID, status string) ([]*entities.PurchaseOrder, error)
|
GetByStatus(ctx context.Context, organizationID uuid.UUID, status string) ([]*entities.PurchaseOrder, error)
|
||||||
GetOverdue(ctx context.Context, organizationID uuid.UUID) ([]*entities.PurchaseOrder, error)
|
GetOverdue(ctx context.Context, organizationID uuid.UUID) ([]*entities.PurchaseOrder, error)
|
||||||
UpdateStatus(ctx context.Context, id uuid.UUID, status string) error
|
UpdateStatus(ctx context.Context, id uuid.UUID, status string) error
|
||||||
|
UpdateStatusAndOutlet(ctx context.Context, id uuid.UUID, status string, outletID *uuid.UUID) error
|
||||||
UpdateTotalAmount(ctx context.Context, id uuid.UUID, totalAmount float64) error
|
UpdateTotalAmount(ctx context.Context, id uuid.UUID, totalAmount float64) error
|
||||||
CreateItem(ctx context.Context, item *entities.PurchaseOrderItem) error
|
CreateItem(ctx context.Context, item *entities.PurchaseOrderItem) error
|
||||||
UpdateItem(ctx context.Context, item *entities.PurchaseOrderItem) error
|
UpdateItem(ctx context.Context, item *entities.PurchaseOrderItem) error
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package processor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
|
"apskel-pos-be/internal/models"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Teams are the parent product categories, plus Pusat for spending that belongs to
|
||||||
|
// no single team. Both purchase orders and cash advances are charged to one, so the rules
|
||||||
|
// for picking and storing a team live here rather than in either processor.
|
||||||
|
|
||||||
|
// listTeams returns the teams money can be charged to: the parent categories of the
|
||||||
|
// outlet in scope, followed by Pusat. Pusat has no category row, so it is appended
|
||||||
|
// here rather than read from the database.
|
||||||
|
func listTeams(ctx context.Context, categoryRepo CategoryRepository, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) {
|
||||||
|
categories, err := categoryRepo.ListParentCategories(ctx, organizationID, outletID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list parent categories: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
teams := make([]models.PurchaseTeam, 0, len(categories)+1)
|
||||||
|
for _, category := range categories {
|
||||||
|
categoryID := category.ID
|
||||||
|
teams = append(teams, models.PurchaseTeam{
|
||||||
|
Scope: constants.PurchaseTeamScopeCategory,
|
||||||
|
CategoryID: &categoryID,
|
||||||
|
Name: category.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
teams = append(teams, models.PurchaseTeam{
|
||||||
|
Scope: constants.PurchaseTeamScopeCentral,
|
||||||
|
Name: constants.PurchaseTeamCentralName,
|
||||||
|
})
|
||||||
|
|
||||||
|
return &models.ListPurchaseTeamsResponse{Teams: teams}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveTeamSelection turns a requested team into the scope/category pair that gets
|
||||||
|
// stored, mirroring the database check constraint. A nil or empty scope means no
|
||||||
|
// team, which is deliberately different from Pusat — callers that require a team
|
||||||
|
// reject that case before getting here. Which outlet's Pusat it is comes from the
|
||||||
|
// record's own outlet, so 'central' needs nothing stored beyond the scope itself.
|
||||||
|
func resolveTeamSelection(ctx context.Context, categoryRepo CategoryRepository, organizationID uuid.UUID, outletID *uuid.UUID, scope *string, categoryID *uuid.UUID) (*string, *uuid.UUID, error) {
|
||||||
|
if scope == nil {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.TrimSpace(*scope) {
|
||||||
|
case "":
|
||||||
|
return nil, nil, nil
|
||||||
|
|
||||||
|
case constants.PurchaseTeamScopeCentral:
|
||||||
|
resolved := constants.PurchaseTeamScopeCentral
|
||||||
|
return &resolved, nil, nil
|
||||||
|
|
||||||
|
case constants.PurchaseTeamScopeCategory:
|
||||||
|
if categoryID == nil {
|
||||||
|
return nil, nil, fmt.Errorf("team_category_id is required when team_scope is category")
|
||||||
|
}
|
||||||
|
|
||||||
|
category, err := categoryRepo.GetByID(ctx, *categoryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("team category not found: %w", err)
|
||||||
|
}
|
||||||
|
if category.OrganizationID != organizationID {
|
||||||
|
return nil, nil, fmt.Errorf("team category does not belong to this organization")
|
||||||
|
}
|
||||||
|
if category.ParentID != nil {
|
||||||
|
return nil, nil, fmt.Errorf("team must be a parent category")
|
||||||
|
}
|
||||||
|
// Categories without an outlet are shared, so only an outlet-specific
|
||||||
|
// category has to match the outlet the record is booked against.
|
||||||
|
if category.OutletID != nil && outletID != nil && *category.OutletID != *outletID {
|
||||||
|
return nil, nil, fmt.Errorf("team category belongs to a different outlet")
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved := constants.PurchaseTeamScopeCategory
|
||||||
|
return &resolved, &category.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil, fmt.Errorf("team_scope must be one of: category, central")
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,219 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
|
"apskel-pos-be/internal/entities"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cashAdvanceSettledAmountExpr sums the spending charged to an advance straight from
|
||||||
|
// the purchase orders and expenses that point at it. Keeping it as an expression
|
||||||
|
// rather than a column means an advance can never disagree with the purchases behind it,
|
||||||
|
// whichever screen edited them. Cancelled spending never accounted for anything.
|
||||||
|
const cashAdvanceSettledAmountExpr = `(
|
||||||
|
COALESCE((SELECT SUM(po.total_amount) FROM purchase_orders po
|
||||||
|
WHERE po.cash_advance_id = cash_advances.id AND po.status <> 'cancelled'), 0)
|
||||||
|
+ COALESCE((SELECT SUM(e.total) FROM expenses e
|
||||||
|
WHERE e.cash_advance_id = cash_advances.id AND e.status <> 'cancel'), 0)
|
||||||
|
)`
|
||||||
|
|
||||||
|
// Money is stored to two decimals, so half a cent is the smallest gap that means
|
||||||
|
// anything. The filters use it for the same reason the mapper does.
|
||||||
|
const cashAdvanceAmountEpsilon = 0.005
|
||||||
|
|
||||||
|
type CashAdvanceRepositoryImpl struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCashAdvanceRepositoryImpl(db *gorm.DB) *CashAdvanceRepositoryImpl {
|
||||||
|
return &CashAdvanceRepositoryImpl{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CashAdvanceRepositoryImpl) Create(ctx context.Context, cashAdvance *entities.CashAdvance) error {
|
||||||
|
return r.db.WithContext(ctx).Create(cashAdvance).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CashAdvanceRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.CashAdvance, error) {
|
||||||
|
var cashAdvance entities.CashAdvance
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Model(&entities.CashAdvance{}).
|
||||||
|
Select("cash_advances.*, "+cashAdvanceSettledAmountExpr+" AS settled_amount").
|
||||||
|
Preload("Outlet").
|
||||||
|
Preload("TeamCategory").
|
||||||
|
Where("cash_advances.id = ?", id).
|
||||||
|
First(&cashAdvance).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cashAdvance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CashAdvanceRepositoryImpl) GetByIDAndOrganizationID(ctx context.Context, id, organizationID uuid.UUID) (*entities.CashAdvance, error) {
|
||||||
|
var cashAdvance entities.CashAdvance
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Model(&entities.CashAdvance{}).
|
||||||
|
Select("cash_advances.*, "+cashAdvanceSettledAmountExpr+" AS settled_amount").
|
||||||
|
Preload("Outlet").
|
||||||
|
Preload("TeamCategory").
|
||||||
|
Where("cash_advances.id = ? AND cash_advances.organization_id = ?", id, organizationID).
|
||||||
|
First(&cashAdvance).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cashAdvance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CashAdvanceRepositoryImpl) GetByCodeNumber(ctx context.Context, codeNumber string, organizationID uuid.UUID) (*entities.CashAdvance, error) {
|
||||||
|
var cashAdvance entities.CashAdvance
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Where("code_number = ? AND organization_id = ?", codeNumber, organizationID).
|
||||||
|
First(&cashAdvance).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cashAdvance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CashAdvanceRepositoryImpl) Update(ctx context.Context, cashAdvance *entities.CashAdvance) error {
|
||||||
|
// Omit associations so a preloaded TeamCategory or Outlet is not written back
|
||||||
|
// over the row it came from.
|
||||||
|
return r.db.WithContext(ctx).Omit(clause.Associations).Save(cashAdvance).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CashAdvanceRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
|
||||||
|
return r.db.WithContext(ctx).Delete(&entities.CashAdvance{}, "id = ?", id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CashAdvanceRepositoryImpl) List(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, limit, offset int) ([]*entities.CashAdvance, int64, error) {
|
||||||
|
var cashAdvances []*entities.CashAdvance
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
// Count on its own query: the select list carries a correlated subquery, which
|
||||||
|
// GORM would otherwise drag into the COUNT.
|
||||||
|
countQuery := applyCashAdvanceFilters(r.db.WithContext(ctx).Model(&entities.CashAdvance{}).Where("cash_advances.organization_id = ?", organizationID), filters)
|
||||||
|
if err := countQuery.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := applyCashAdvanceFilters(r.db.WithContext(ctx).Model(&entities.CashAdvance{}).Where("cash_advances.organization_id = ?", organizationID), filters)
|
||||||
|
err := query.
|
||||||
|
Select("cash_advances.*, " + cashAdvanceSettledAmountExpr + " AS settled_amount").
|
||||||
|
Preload("Outlet").
|
||||||
|
Preload("TeamCategory").
|
||||||
|
Order("cash_advances.issued_date DESC, cash_advances.created_at DESC").
|
||||||
|
Limit(limit).
|
||||||
|
Offset(offset).
|
||||||
|
Find(&cashAdvances).Error
|
||||||
|
|
||||||
|
return cashAdvances, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyCashAdvanceFilters(query *gorm.DB, filters map[string]interface{}) *gorm.DB {
|
||||||
|
for key, value := range filters {
|
||||||
|
switch key {
|
||||||
|
case "search":
|
||||||
|
if search, ok := value.(string); ok && search != "" {
|
||||||
|
pattern := "%" + strings.ToLower(search) + "%"
|
||||||
|
query = query.Where("LOWER(cash_advances.code_number) LIKE ? OR LOWER(cash_advances.description) LIKE ?", pattern, pattern)
|
||||||
|
}
|
||||||
|
case "status":
|
||||||
|
if status, ok := value.(string); ok && status != "" {
|
||||||
|
query = query.Where("cash_advances.status = ?", status)
|
||||||
|
}
|
||||||
|
case "outlet_id":
|
||||||
|
if outletID, ok := value.(uuid.UUID); ok {
|
||||||
|
query = query.Where("cash_advances.outlet_id = ?", outletID)
|
||||||
|
}
|
||||||
|
case "team_scope":
|
||||||
|
if teamScope, ok := value.(string); ok && teamScope != "" {
|
||||||
|
query = query.Where("cash_advances.team_scope = ?", teamScope)
|
||||||
|
}
|
||||||
|
case "team_category_id":
|
||||||
|
if teamCategoryID, ok := value.(uuid.UUID); ok {
|
||||||
|
query = query.Where("cash_advances.team_category_id = ?", teamCategoryID)
|
||||||
|
}
|
||||||
|
case "settlement_status":
|
||||||
|
query = applyCashAdvanceSettlementFilter(query, value)
|
||||||
|
case "start_date":
|
||||||
|
if startDate, ok := value.(time.Time); ok {
|
||||||
|
query = query.Where("cash_advances.issued_date >= ?", startDate)
|
||||||
|
}
|
||||||
|
case "end_date":
|
||||||
|
if endDate, ok := value.(time.Time); ok {
|
||||||
|
query = query.Where("cash_advances.issued_date <= ?", endDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyCashAdvanceSettlementFilter reproduces in SQL what the mapper computes in Go:
|
||||||
|
// how much of the advance has been accounted for, by spending plus cash returned.
|
||||||
|
func applyCashAdvanceSettlementFilter(query *gorm.DB, value interface{}) *gorm.DB {
|
||||||
|
status, ok := value.(string)
|
||||||
|
if !ok || status == "" {
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
accounted := cashAdvanceSettledAmountExpr + " + cash_advances.returned_amount"
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case constants.CashAdvanceSettlementOpen:
|
||||||
|
return query.Where(accounted+" <= ?", cashAdvanceAmountEpsilon)
|
||||||
|
case constants.CashAdvanceSettlementPartial:
|
||||||
|
return query.
|
||||||
|
Where(accounted+" > ?", cashAdvanceAmountEpsilon).
|
||||||
|
Where("cash_advances.amount - ("+accounted+") > ?", cashAdvanceAmountEpsilon)
|
||||||
|
case constants.CashAdvanceSettlementSettled:
|
||||||
|
return query.
|
||||||
|
Where(accounted+" > ?", cashAdvanceAmountEpsilon).
|
||||||
|
Where("cash_advances.amount - ("+accounted+") <= ?", cashAdvanceAmountEpsilon)
|
||||||
|
}
|
||||||
|
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSettlements returns the spending charged to an advance, newest first. Purchase
|
||||||
|
// orders and expenses are two tables recording the same thing here, so they are
|
||||||
|
// read as one list.
|
||||||
|
func (r *CashAdvanceRepositoryImpl) ListSettlements(ctx context.Context, cashAdvanceID uuid.UUID) ([]*entities.CashAdvanceSettlement, error) {
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT '%s' AS type, po.id AS id, po.po_number AS number,
|
||||||
|
po.transaction_date AS date, po.total_amount AS amount, po.status AS status
|
||||||
|
FROM purchase_orders po
|
||||||
|
WHERE po.cash_advance_id = ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT '%s' AS type, e.id AS id, e.code_number AS number,
|
||||||
|
e.transaction_date AS date, e.total AS amount, e.status AS status
|
||||||
|
FROM expenses e
|
||||||
|
WHERE e.cash_advance_id = ?
|
||||||
|
ORDER BY date DESC`,
|
||||||
|
constants.CashAdvanceSettlementTypePurchaseOrder,
|
||||||
|
constants.CashAdvanceSettlementTypeExpense,
|
||||||
|
)
|
||||||
|
|
||||||
|
var settlements []*entities.CashAdvanceSettlement
|
||||||
|
err := r.db.WithContext(ctx).Raw(query, cashAdvanceID, cashAdvanceID).Scan(&settlements).Error
|
||||||
|
return settlements, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountSettlements is what stops an advance being deleted once spending has been
|
||||||
|
// charged to it; the foreign keys would refuse anyway, but not with a readable error.
|
||||||
|
func (r *CashAdvanceRepositoryImpl) CountSettlements(ctx context.Context, cashAdvanceID uuid.UUID) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.WithContext(ctx).Raw(`
|
||||||
|
SELECT (SELECT COUNT(*) FROM purchase_orders WHERE cash_advance_id = ?)
|
||||||
|
+ (SELECT COUNT(*) FROM expenses WHERE cash_advance_id = ?)`,
|
||||||
|
cashAdvanceID, cashAdvanceID).Scan(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CategoryRepositoryImpl struct {
|
type CategoryRepositoryImpl struct {
|
||||||
@@ -25,7 +26,7 @@ func (r *CategoryRepositoryImpl) Create(ctx context.Context, category *entities.
|
|||||||
|
|
||||||
func (r *CategoryRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.Category, error) {
|
func (r *CategoryRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.Category, error) {
|
||||||
var category entities.Category
|
var category entities.Category
|
||||||
err := r.db.WithContext(ctx).First(&category, "id = ?", id).Error
|
err := r.db.WithContext(ctx).Preload("Parent").First(&category, "id = ?", id).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -47,6 +48,26 @@ func (r *CategoryRepositoryImpl) GetByOrganization(ctx context.Context, organiza
|
|||||||
return categories, err
|
return categories, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListParentCategories returns the top-level categories of an organization. These are
|
||||||
|
// the buckets the parent category reports roll up to via COALESCE(parent_id, id), so
|
||||||
|
// the list is deliberately every top-level category, not only those with children —
|
||||||
|
// otherwise a team could show up in a report but not be selectable on a purchase.
|
||||||
|
// Categories with no outlet of their own are shared, so they are always included.
|
||||||
|
func (r *CategoryRepositoryImpl) ListParentCategories(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]*entities.Category, error) {
|
||||||
|
var categories []*entities.Category
|
||||||
|
|
||||||
|
query := r.db.WithContext(ctx).
|
||||||
|
Where("organization_id = ?", organizationID).
|
||||||
|
Where("parent_id IS NULL")
|
||||||
|
|
||||||
|
if outletID != nil {
|
||||||
|
query = query.Where("outlet_id = ? OR outlet_id IS NULL", *outletID)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := query.Order("\"order\" ASC, name ASC").Find(&categories).Error
|
||||||
|
return categories, err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *CategoryRepositoryImpl) GetByBusinessType(ctx context.Context, businessType string) ([]*entities.Category, error) {
|
func (r *CategoryRepositoryImpl) GetByBusinessType(ctx context.Context, businessType string) ([]*entities.Category, error) {
|
||||||
var categories []*entities.Category
|
var categories []*entities.Category
|
||||||
err := r.db.WithContext(ctx).Where("business_type = ?", businessType).Find(&categories).Error
|
err := r.db.WithContext(ctx).Where("business_type = ?", businessType).Find(&categories).Error
|
||||||
@@ -54,13 +75,29 @@ func (r *CategoryRepositoryImpl) GetByBusinessType(ctx context.Context, business
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *CategoryRepositoryImpl) Update(ctx context.Context, category *entities.Category) error {
|
func (r *CategoryRepositoryImpl) Update(ctx context.Context, category *entities.Category) error {
|
||||||
return r.db.WithContext(ctx).Save(category).Error
|
// Omit associations so a preloaded Parent is not upserted back over parent_id
|
||||||
|
return r.db.WithContext(ctx).Omit(clause.Associations).Save(category).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *CategoryRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
|
func (r *CategoryRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
|
||||||
return r.db.WithContext(ctx).Delete(&entities.Category{}, "id = ?", id).Error
|
return r.db.WithContext(ctx).Delete(&entities.Category{}, "id = ?", id).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyCategoryTypeFilter narrows the query by position in the category tree.
|
||||||
|
// - "parent": top level categories only (no parent of their own)
|
||||||
|
// - "child": leaf categories — sub categories plus top level categories that
|
||||||
|
// have no sub categories, i.e. everything a product can be assigned to
|
||||||
|
func applyCategoryTypeFilter(query *gorm.DB, value interface{}) *gorm.DB {
|
||||||
|
switch value {
|
||||||
|
case "parent":
|
||||||
|
return query.Where("parent_id IS NULL")
|
||||||
|
case "child":
|
||||||
|
return query.Where("NOT EXISTS (SELECT 1 FROM categories AS sub WHERE sub.parent_id = categories.id)")
|
||||||
|
default:
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (r *CategoryRepositoryImpl) List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.Category, int64, error) {
|
func (r *CategoryRepositoryImpl) List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.Category, int64, error) {
|
||||||
var categories []*entities.Category
|
var categories []*entities.Category
|
||||||
var total int64
|
var total int64
|
||||||
@@ -75,6 +112,8 @@ func (r *CategoryRepositoryImpl) List(ctx context.Context, filters map[string]in
|
|||||||
case "outlet_id":
|
case "outlet_id":
|
||||||
// Include outlet-specific categories AND global categories (outlet_id IS NULL)
|
// Include outlet-specific categories AND global categories (outlet_id IS NULL)
|
||||||
query = query.Where("outlet_id = ? OR outlet_id IS NULL", value)
|
query = query.Where("outlet_id = ? OR outlet_id IS NULL", value)
|
||||||
|
case "type":
|
||||||
|
query = applyCategoryTypeFilter(query, value)
|
||||||
default:
|
default:
|
||||||
query = query.Where(key+" = ?", value)
|
query = query.Where(key+" = ?", value)
|
||||||
}
|
}
|
||||||
@@ -84,7 +123,7 @@ func (r *CategoryRepositoryImpl) List(ctx context.Context, filters map[string]in
|
|||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err := query.Order("\"order\" ASC").Limit(limit).Offset(offset).Find(&categories).Error
|
err := query.Preload("Parent").Order("\"order\" ASC").Limit(limit).Offset(offset).Find(&categories).Error
|
||||||
return categories, total, err
|
return categories, total, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +136,10 @@ func (r *CategoryRepositoryImpl) Count(ctx context.Context, filters map[string]i
|
|||||||
case "search":
|
case "search":
|
||||||
searchValue := "%" + value.(string) + "%"
|
searchValue := "%" + value.(string) + "%"
|
||||||
query = query.Where("name ILIKE ? OR description ILIKE ?", searchValue, searchValue)
|
query = query.Where("name ILIKE ? OR description ILIKE ?", searchValue, searchValue)
|
||||||
|
case "outlet_id":
|
||||||
|
query = query.Where("outlet_id = ? OR outlet_id IS NULL", value)
|
||||||
|
case "type":
|
||||||
|
query = applyCategoryTypeFilter(query, value)
|
||||||
default:
|
default:
|
||||||
query = query.Where(key+" = ?", value)
|
query = query.Where(key+" = ?", value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ func (r *ProductRepositoryImpl) List(ctx context.Context, filters map[string]int
|
|||||||
query = query.Where("price >= ?", value)
|
query = query.Where("price >= ?", value)
|
||||||
case "price_max":
|
case "price_max":
|
||||||
query = query.Where("price <= ?", value)
|
query = query.Where("price <= ?", value)
|
||||||
|
case "category_id":
|
||||||
|
query = query.Where("category_id IN (?)", r.categoryAndChildrenIDs(value))
|
||||||
default:
|
default:
|
||||||
query = query.Where(key+" = ?", value)
|
query = query.Where(key+" = ?", value)
|
||||||
}
|
}
|
||||||
@@ -110,10 +112,21 @@ func (r *ProductRepositoryImpl) List(ctx context.Context, filters map[string]int
|
|||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err := query.Limit(limit).Offset(offset).Find(&products).Error
|
// id is a tie-breaker so LIMIT/OFFSET paging stays stable when several products
|
||||||
|
// share the same created_at.
|
||||||
|
err := query.Order("created_at DESC, id DESC").Limit(limit).Offset(offset).Find(&products).Error
|
||||||
return products, total, err
|
return products, total, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// categoryAndChildrenIDs builds a subquery resolving to the category itself plus its
|
||||||
|
// direct children, so filtering by a parent category also returns the children's
|
||||||
|
// products. For a category without children it resolves to just that category.
|
||||||
|
func (r *ProductRepositoryImpl) categoryAndChildrenIDs(categoryID interface{}) *gorm.DB {
|
||||||
|
return r.db.Model(&entities.Category{}).
|
||||||
|
Select("id").
|
||||||
|
Where("id = ? OR parent_id = ?", categoryID, categoryID)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ProductRepositoryImpl) Count(ctx context.Context, filters map[string]interface{}) (int64, error) {
|
func (r *ProductRepositoryImpl) Count(ctx context.Context, filters map[string]interface{}) (int64, error) {
|
||||||
var count int64
|
var count int64
|
||||||
query := r.db.WithContext(ctx).Model(&entities.Product{})
|
query := r.db.WithContext(ctx).Model(&entities.Product{})
|
||||||
@@ -127,6 +140,8 @@ func (r *ProductRepositoryImpl) Count(ctx context.Context, filters map[string]in
|
|||||||
query = query.Where("price >= ?", value)
|
query = query.Where("price >= ?", value)
|
||||||
case "price_max":
|
case "price_max":
|
||||||
query = query.Where("price <= ?", value)
|
query = query.Where("price <= ?", value)
|
||||||
|
case "category_id":
|
||||||
|
query = query.Where("category_id IN (?)", r.categoryAndChildrenIDs(value))
|
||||||
default:
|
default:
|
||||||
query = query.Where(key+" = ?", value)
|
query = query.Where(key+" = ?", value)
|
||||||
}
|
}
|
||||||
@@ -232,6 +247,8 @@ func (r *ProductRepositoryImpl) ListWithOutletPrice(ctx context.Context, filters
|
|||||||
query = query.Where("products.price >= ?", value)
|
query = query.Where("products.price >= ?", value)
|
||||||
case "price_max":
|
case "price_max":
|
||||||
query = query.Where("products.price <= ?", value)
|
query = query.Where("products.price <= ?", value)
|
||||||
|
case "category_id":
|
||||||
|
query = query.Where("products.category_id IN (?)", r.categoryAndChildrenIDs(value))
|
||||||
default:
|
default:
|
||||||
query = query.Where("products."+key+" = ?", value)
|
query = query.Where("products."+key+" = ?", value)
|
||||||
}
|
}
|
||||||
@@ -250,6 +267,8 @@ func (r *ProductRepositoryImpl) ListWithOutletPrice(ctx context.Context, filters
|
|||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err := query.Limit(limit).Offset(offset).Find(&products).Error
|
// Columns are qualified because the outlet join brings a second created_at/id
|
||||||
|
// into scope. id is a tie-breaker for stable LIMIT/OFFSET paging.
|
||||||
|
err := query.Order("products.created_at DESC, products.id DESC").Limit(limit).Offset(offset).Find(&products).Error
|
||||||
return products, total, err
|
return products, total, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PurchaseOrderRepositoryImpl struct {
|
type PurchaseOrderRepositoryImpl struct {
|
||||||
@@ -30,6 +31,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID)
|
|||||||
var po entities.PurchaseOrder
|
var po entities.PurchaseOrder
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Preload("Vendor").
|
Preload("Vendor").
|
||||||
|
Preload("TeamCategory").
|
||||||
Preload("Items.Ingredient").
|
Preload("Items.Ingredient").
|
||||||
Preload("Items.PurchaseCategory").
|
Preload("Items.PurchaseCategory").
|
||||||
Preload("Items.Unit").
|
Preload("Items.Unit").
|
||||||
@@ -45,6 +47,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByIDAndOrganizationID(ctx context.Conte
|
|||||||
var po entities.PurchaseOrder
|
var po entities.PurchaseOrder
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Preload("Vendor").
|
Preload("Vendor").
|
||||||
|
Preload("TeamCategory").
|
||||||
Preload("Items.Ingredient").
|
Preload("Items.Ingredient").
|
||||||
Preload("Items.PurchaseCategory").
|
Preload("Items.PurchaseCategory").
|
||||||
Preload("Items.Unit").
|
Preload("Items.Unit").
|
||||||
@@ -58,7 +61,10 @@ func (r *PurchaseOrderRepositoryImpl) GetByIDAndOrganizationID(ctx context.Conte
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *PurchaseOrderRepositoryImpl) Update(ctx context.Context, po *entities.PurchaseOrder) error {
|
func (r *PurchaseOrderRepositoryImpl) Update(ctx context.Context, po *entities.PurchaseOrder) error {
|
||||||
return r.db.WithContext(ctx).Save(po).Error
|
// Omit associations so preloaded relations are not upserted back. Items and
|
||||||
|
// attachments are rewritten explicitly by the processor, and without this a
|
||||||
|
// preloaded TeamCategory would be written over the category row itself.
|
||||||
|
return r.db.WithContext(ctx).Omit(clause.Associations).Save(po).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *PurchaseOrderRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
|
func (r *PurchaseOrderRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
|
||||||
@@ -87,6 +93,18 @@ func (r *PurchaseOrderRepositoryImpl) List(ctx context.Context, organizationID u
|
|||||||
if vendorID, ok := value.(uuid.UUID); ok {
|
if vendorID, ok := value.(uuid.UUID); ok {
|
||||||
query = query.Where("vendor_id = ?", vendorID)
|
query = query.Where("vendor_id = ?", vendorID)
|
||||||
}
|
}
|
||||||
|
case "team_scope":
|
||||||
|
if teamScope, ok := value.(string); ok && teamScope != "" {
|
||||||
|
query = query.Where("team_scope = ?", teamScope)
|
||||||
|
}
|
||||||
|
case "team_category_id":
|
||||||
|
if teamCategoryID, ok := value.(uuid.UUID); ok {
|
||||||
|
query = query.Where("team_category_id = ?", teamCategoryID)
|
||||||
|
}
|
||||||
|
case "team_unassigned":
|
||||||
|
if unassigned, ok := value.(bool); ok && unassigned {
|
||||||
|
query = query.Where("team_scope IS NULL")
|
||||||
|
}
|
||||||
case "start_date":
|
case "start_date":
|
||||||
if startDate, ok := value.(time.Time); ok {
|
if startDate, ok := value.(time.Time); ok {
|
||||||
query = query.Where("transaction_date >= ?", startDate)
|
query = query.Where("transaction_date >= ?", startDate)
|
||||||
@@ -106,6 +124,7 @@ func (r *PurchaseOrderRepositoryImpl) List(ctx context.Context, organizationID u
|
|||||||
|
|
||||||
err := query.
|
err := query.
|
||||||
Preload("Vendor").
|
Preload("Vendor").
|
||||||
|
Preload("TeamCategory").
|
||||||
Preload("Items.Ingredient").
|
Preload("Items.Ingredient").
|
||||||
Preload("Items.PurchaseCategory").
|
Preload("Items.PurchaseCategory").
|
||||||
Preload("Items.Unit").
|
Preload("Items.Unit").
|
||||||
@@ -137,6 +156,18 @@ func (r *PurchaseOrderRepositoryImpl) Count(ctx context.Context, organizationID
|
|||||||
if vendorID, ok := value.(uuid.UUID); ok {
|
if vendorID, ok := value.(uuid.UUID); ok {
|
||||||
query = query.Where("vendor_id = ?", vendorID)
|
query = query.Where("vendor_id = ?", vendorID)
|
||||||
}
|
}
|
||||||
|
case "team_scope":
|
||||||
|
if teamScope, ok := value.(string); ok && teamScope != "" {
|
||||||
|
query = query.Where("team_scope = ?", teamScope)
|
||||||
|
}
|
||||||
|
case "team_category_id":
|
||||||
|
if teamCategoryID, ok := value.(uuid.UUID); ok {
|
||||||
|
query = query.Where("team_category_id = ?", teamCategoryID)
|
||||||
|
}
|
||||||
|
case "team_unassigned":
|
||||||
|
if unassigned, ok := value.(bool); ok && unassigned {
|
||||||
|
query = query.Where("team_scope IS NULL")
|
||||||
|
}
|
||||||
case "start_date":
|
case "start_date":
|
||||||
if startDate, ok := value.(time.Time); ok {
|
if startDate, ok := value.(time.Time); ok {
|
||||||
query = query.Where("transaction_date >= ?", startDate)
|
query = query.Where("transaction_date >= ?", startDate)
|
||||||
@@ -170,6 +201,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByStatus(ctx context.Context, organizat
|
|||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Where("organization_id = ? AND status = ?", organizationID, status).
|
Where("organization_id = ? AND status = ?", organizationID, status).
|
||||||
Preload("Vendor").
|
Preload("Vendor").
|
||||||
|
Preload("TeamCategory").
|
||||||
Preload("Items.Ingredient").
|
Preload("Items.Ingredient").
|
||||||
Preload("Items.PurchaseCategory").
|
Preload("Items.PurchaseCategory").
|
||||||
Preload("Items.Unit").
|
Preload("Items.Unit").
|
||||||
@@ -182,6 +214,7 @@ func (r *PurchaseOrderRepositoryImpl) GetOverdue(ctx context.Context, organizati
|
|||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Where("organization_id = ? AND due_date < ? AND status IN (?)", organizationID, time.Now(), []string{"draft", "sent", "approved"}).
|
Where("organization_id = ? AND due_date < ? AND status IN (?)", organizationID, time.Now(), []string{"draft", "sent", "approved"}).
|
||||||
Preload("Vendor").
|
Preload("Vendor").
|
||||||
|
Preload("TeamCategory").
|
||||||
Preload("Items.Ingredient").
|
Preload("Items.Ingredient").
|
||||||
Preload("Items.PurchaseCategory").
|
Preload("Items.PurchaseCategory").
|
||||||
Preload("Items.Unit").
|
Preload("Items.Unit").
|
||||||
@@ -196,6 +229,18 @@ func (r *PurchaseOrderRepositoryImpl) UpdateStatus(ctx context.Context, id uuid.
|
|||||||
Update("status", status).Error
|
Update("status", status).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *PurchaseOrderRepositoryImpl) UpdateStatusAndOutlet(ctx context.Context, id uuid.UUID, status string, outletID *uuid.UUID) error {
|
||||||
|
updates := map[string]interface{}{"status": status}
|
||||||
|
if outletID != nil {
|
||||||
|
updates["outlet_id"] = *outletID
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.db.WithContext(ctx).
|
||||||
|
Model(&entities.PurchaseOrder{}).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Updates(updates).Error
|
||||||
|
}
|
||||||
|
|
||||||
func (r *PurchaseOrderRepositoryImpl) UpdateTotalAmount(ctx context.Context, id uuid.UUID, totalAmount float64) error {
|
func (r *PurchaseOrderRepositoryImpl) UpdateTotalAmount(ctx context.Context, id uuid.UUID, totalAmount float64) error {
|
||||||
return r.db.WithContext(ctx).
|
return r.db.WithContext(ctx).
|
||||||
Model(&entities.PurchaseOrder{}).
|
Model(&entities.PurchaseOrder{}).
|
||||||
|
|||||||
@@ -53,12 +53,13 @@ type Router struct {
|
|||||||
selfOrderHandler *handler.SelfOrderHandler
|
selfOrderHandler *handler.SelfOrderHandler
|
||||||
productOutletPriceHandler *handler.ProductOutletPriceHandler
|
productOutletPriceHandler *handler.ProductOutletPriceHandler
|
||||||
expenseHandler *handler.ExpenseHandler
|
expenseHandler *handler.ExpenseHandler
|
||||||
|
cashAdvanceHandler *handler.CashAdvanceHandler
|
||||||
authMiddleware *middleware.AuthMiddleware
|
authMiddleware *middleware.AuthMiddleware
|
||||||
customerAuthMiddleware *middleware.CustomerAuthMiddleware
|
customerAuthMiddleware *middleware.CustomerAuthMiddleware
|
||||||
redisClient *redis.Client
|
redisClient *redis.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, redisClient *redis.Client) *Router {
|
func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, redisClient *redis.Client) *Router {
|
||||||
|
|
||||||
return &Router{
|
return &Router{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
@@ -103,6 +104,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer
|
|||||||
selfOrderHandler: selfOrderHandler,
|
selfOrderHandler: selfOrderHandler,
|
||||||
productOutletPriceHandler: handler.NewProductOutletPriceHandler(productOutletPriceService, productOutletPriceValidator),
|
productOutletPriceHandler: handler.NewProductOutletPriceHandler(productOutletPriceService, productOutletPriceValidator),
|
||||||
expenseHandler: handler.NewExpenseHandler(expenseService, expenseValidator),
|
expenseHandler: handler.NewExpenseHandler(expenseService, expenseValidator),
|
||||||
|
cashAdvanceHandler: handler.NewCashAdvanceHandler(cashAdvanceService, cashAdvanceValidator),
|
||||||
redisClient: redisClient,
|
redisClient: redisClient,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,10 +337,13 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
analytics.GET("/purchasing", r.analyticsHandler.GetPurchasingAnalytics)
|
analytics.GET("/purchasing", r.analyticsHandler.GetPurchasingAnalytics)
|
||||||
analytics.GET("/products", r.analyticsHandler.GetProductAnalytics)
|
analytics.GET("/products", r.analyticsHandler.GetProductAnalytics)
|
||||||
analytics.GET("/categories", r.analyticsHandler.GetProductAnalyticsPerCategory)
|
analytics.GET("/categories", r.analyticsHandler.GetProductAnalyticsPerCategory)
|
||||||
|
analytics.GET("/parent-categories", r.analyticsHandler.GetProductAnalyticsPerParentCategory)
|
||||||
|
analytics.GET("/parent-categories/:parent_category_id", r.analyticsHandler.GetParentCategoryAnalyticsDetail)
|
||||||
analytics.GET("/dashboard", r.analyticsHandler.GetDashboardAnalytics)
|
analytics.GET("/dashboard", r.analyticsHandler.GetDashboardAnalytics)
|
||||||
analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics)
|
analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics)
|
||||||
analytics.GET("/exclusive-summary/period", r.analyticsHandler.GetExclusiveSummaryPeriod)
|
analytics.GET("/exclusive-summary/period", r.analyticsHandler.GetExclusiveSummaryPeriod)
|
||||||
analytics.GET("/exclusive-summary/monthly", r.analyticsHandler.GetExclusiveSummaryMonthly)
|
analytics.GET("/exclusive-summary/monthly", r.analyticsHandler.GetExclusiveSummaryMonthly)
|
||||||
|
analytics.GET("/exclusive-summary/mtd", r.analyticsHandler.GetExclusiveSummaryMTD)
|
||||||
}
|
}
|
||||||
|
|
||||||
tables := protected.Group("/tables")
|
tables := protected.Group("/tables")
|
||||||
@@ -355,7 +360,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ingredients := protected.Group("/ingredients")
|
ingredients := protected.Group("/ingredients")
|
||||||
ingredients.Use(r.authMiddleware.RequireAdminOrManager())
|
ingredients.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
|
||||||
{
|
{
|
||||||
ingredients.POST("", r.ingredientHandler.Create)
|
ingredients.POST("", r.ingredientHandler.Create)
|
||||||
ingredients.GET("", r.ingredientHandler.GetAll)
|
ingredients.GET("", r.ingredientHandler.GetAll)
|
||||||
@@ -368,7 +373,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
vendors := protected.Group("/vendors")
|
vendors := protected.Group("/vendors")
|
||||||
vendors.Use(r.authMiddleware.RequireAdminOrManager())
|
vendors.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
|
||||||
{
|
{
|
||||||
vendors.POST("", r.vendorHandler.CreateVendor)
|
vendors.POST("", r.vendorHandler.CreateVendor)
|
||||||
vendors.GET("", r.vendorHandler.ListVendors)
|
vendors.GET("", r.vendorHandler.ListVendors)
|
||||||
@@ -379,12 +384,13 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
purchaseOrders := protected.Group("/purchase-orders")
|
purchaseOrders := protected.Group("/purchase-orders")
|
||||||
purchaseOrders.Use(r.authMiddleware.RequireAdminOrManager())
|
purchaseOrders.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
|
||||||
{
|
{
|
||||||
purchaseOrders.POST("", r.purchaseOrderHandler.CreatePurchaseOrder)
|
purchaseOrders.POST("", r.purchaseOrderHandler.CreatePurchaseOrder)
|
||||||
purchaseOrders.GET("", r.purchaseOrderHandler.ListPurchaseOrders)
|
purchaseOrders.GET("", r.purchaseOrderHandler.ListPurchaseOrders)
|
||||||
purchaseOrders.GET("/status/:status", r.purchaseOrderHandler.GetPurchaseOrdersByStatus)
|
purchaseOrders.GET("/status/:status", r.purchaseOrderHandler.GetPurchaseOrdersByStatus)
|
||||||
purchaseOrders.GET("/overdue", r.purchaseOrderHandler.GetOverduePurchaseOrders)
|
purchaseOrders.GET("/overdue", r.purchaseOrderHandler.GetOverduePurchaseOrders)
|
||||||
|
purchaseOrders.GET("/teams", r.purchaseOrderHandler.ListPurchaseTeams)
|
||||||
purchaseOrders.GET("/:id", r.purchaseOrderHandler.GetPurchaseOrder)
|
purchaseOrders.GET("/:id", r.purchaseOrderHandler.GetPurchaseOrder)
|
||||||
purchaseOrders.PUT("/:id", r.purchaseOrderHandler.UpdatePurchaseOrder)
|
purchaseOrders.PUT("/:id", r.purchaseOrderHandler.UpdatePurchaseOrder)
|
||||||
purchaseOrders.PUT("/:id/status/:status", r.purchaseOrderHandler.UpdatePurchaseOrderStatus)
|
purchaseOrders.PUT("/:id/status/:status", r.purchaseOrderHandler.UpdatePurchaseOrderStatus)
|
||||||
@@ -392,7 +398,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
purchaseCategories := protected.Group("/purchase-categories")
|
purchaseCategories := protected.Group("/purchase-categories")
|
||||||
purchaseCategories.Use(r.authMiddleware.RequireAdminOrManager())
|
purchaseCategories.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
|
||||||
{
|
{
|
||||||
purchaseCategories.POST("", r.purchaseCategoryHandler.CreatePurchaseCategory)
|
purchaseCategories.POST("", r.purchaseCategoryHandler.CreatePurchaseCategory)
|
||||||
purchaseCategories.GET("", r.purchaseCategoryHandler.ListPurchaseCategories)
|
purchaseCategories.GET("", r.purchaseCategoryHandler.ListPurchaseCategories)
|
||||||
@@ -402,7 +408,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
unitConverters := protected.Group("/unit-converters")
|
unitConverters := protected.Group("/unit-converters")
|
||||||
unitConverters.Use(r.authMiddleware.RequireAdminOrManager())
|
unitConverters.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
|
||||||
{
|
{
|
||||||
unitConverters.POST("", r.unitConverterHandler.CreateIngredientUnitConverter)
|
unitConverters.POST("", r.unitConverterHandler.CreateIngredientUnitConverter)
|
||||||
unitConverters.GET("", r.unitConverterHandler.ListIngredientUnitConverters)
|
unitConverters.GET("", r.unitConverterHandler.ListIngredientUnitConverters)
|
||||||
@@ -464,7 +470,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expenses := protected.Group("/expenses")
|
expenses := protected.Group("/expenses")
|
||||||
expenses.Use(r.authMiddleware.RequireAdminOrManager())
|
expenses.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
|
||||||
{
|
{
|
||||||
expenses.POST("", r.expenseHandler.CreateExpense)
|
expenses.POST("", r.expenseHandler.CreateExpense)
|
||||||
expenses.GET("", r.expenseHandler.ListExpenses)
|
expenses.GET("", r.expenseHandler.ListExpenses)
|
||||||
@@ -474,6 +480,19 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
expenses.DELETE("/:id", r.expenseHandler.DeleteExpense)
|
expenses.DELETE("/:id", r.expenseHandler.DeleteExpense)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cashAdvances := protected.Group("/cash-advances")
|
||||||
|
cashAdvances.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing())
|
||||||
|
{
|
||||||
|
cashAdvances.POST("", r.cashAdvanceHandler.CreateCashAdvance)
|
||||||
|
cashAdvances.GET("", r.cashAdvanceHandler.ListCashAdvances)
|
||||||
|
// Registered ahead of /:id so the picker path is not read as an id.
|
||||||
|
cashAdvances.GET("/teams", r.cashAdvanceHandler.ListCashAdvanceTeams)
|
||||||
|
cashAdvances.GET("/:id", r.cashAdvanceHandler.GetCashAdvance)
|
||||||
|
cashAdvances.PUT("/:id", r.cashAdvanceHandler.UpdateCashAdvance)
|
||||||
|
cashAdvances.PUT("/:id/status/:status", r.cashAdvanceHandler.UpdateCashAdvanceStatus)
|
||||||
|
cashAdvances.DELETE("/:id", r.cashAdvanceHandler.DeleteCashAdvance)
|
||||||
|
}
|
||||||
|
|
||||||
orderIngredientTransactions := protected.Group("/order-ingredient-transactions")
|
orderIngredientTransactions := protected.Group("/order-ingredient-transactions")
|
||||||
orderIngredientTransactions.Use(r.authMiddleware.RequireAdminOrManager())
|
orderIngredientTransactions.Use(r.authMiddleware.RequireAdminOrManager())
|
||||||
{
|
{
|
||||||
@@ -619,6 +638,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
outlets.GET("/:outlet_id/tables/occupied", r.tableHandler.GetOccupiedTables)
|
outlets.GET("/:outlet_id/tables/occupied", r.tableHandler.GetOccupiedTables)
|
||||||
// Reports
|
// Reports
|
||||||
outlets.GET("/:outlet_id/reports/daily-transaction.pdf", r.reportHandler.GetDailyTransactionReportPDF)
|
outlets.GET("/:outlet_id/reports/daily-transaction.pdf", r.reportHandler.GetDailyTransactionReportPDF)
|
||||||
|
outlets.GET("/:outlet_id/reports/profit-loss.pdf", r.reportHandler.GetProfitLossReportPDF)
|
||||||
}
|
}
|
||||||
|
|
||||||
// User device routes - accessible by authenticated users for their own devices
|
// User device routes - accessible by authenticated users for their own devices
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ type AnalyticsService interface {
|
|||||||
GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error)
|
GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error)
|
||||||
GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error)
|
GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error)
|
||||||
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error)
|
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error)
|
||||||
|
GetProductAnalyticsPerParentCategory(ctx context.Context, req *models.ProductAnalyticsPerParentCategoryRequest) (*models.ProductAnalyticsPerParentCategoryResponse, error)
|
||||||
|
GetParentCategoryAnalyticsDetail(ctx context.Context, req *models.ParentCategoryAnalyticsDetailRequest) (*models.ParentCategoryAnalyticsDetailResponse, error)
|
||||||
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
|
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
|
||||||
GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error)
|
GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error)
|
||||||
GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error)
|
GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error)
|
||||||
GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error)
|
GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error)
|
||||||
|
GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnalyticsServiceImpl struct {
|
type AnalyticsServiceImpl struct {
|
||||||
@@ -103,6 +106,36 @@ func (s *AnalyticsServiceImpl) GetProductAnalyticsPerCategory(ctx context.Contex
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AnalyticsServiceImpl) GetProductAnalyticsPerParentCategory(ctx context.Context, req *models.ProductAnalyticsPerParentCategoryRequest) (*models.ProductAnalyticsPerParentCategoryResponse, error) {
|
||||||
|
// Validate request
|
||||||
|
if err := s.validateProductAnalyticsPerParentCategoryRequest(req); err != nil {
|
||||||
|
return nil, fmt.Errorf("validation error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process analytics request
|
||||||
|
response, err := s.analyticsProcessor.GetProductAnalyticsPerParentCategory(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get product analytics per parent category: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AnalyticsServiceImpl) GetParentCategoryAnalyticsDetail(ctx context.Context, req *models.ParentCategoryAnalyticsDetailRequest) (*models.ParentCategoryAnalyticsDetailResponse, error) {
|
||||||
|
// Validate request
|
||||||
|
if err := s.validateParentCategoryAnalyticsDetailRequest(req); err != nil {
|
||||||
|
return nil, fmt.Errorf("validation error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process analytics request
|
||||||
|
response, err := s.analyticsProcessor.GetParentCategoryAnalyticsDetail(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get parent category analytics detail: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AnalyticsServiceImpl) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) {
|
func (s *AnalyticsServiceImpl) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) {
|
||||||
// Validate request
|
// Validate request
|
||||||
if err := s.validateDashboardAnalyticsRequest(req); err != nil {
|
if err := s.validateDashboardAnalyticsRequest(req); err != nil {
|
||||||
@@ -205,6 +238,10 @@ func (s *AnalyticsServiceImpl) validatePurchasingAnalyticsRequest(req *models.Pu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, err := models.ParsePurchaseTeamFilter(req.Team); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,6 +289,50 @@ func (s *AnalyticsServiceImpl) validateProductAnalyticsPerCategoryRequest(req *m
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AnalyticsServiceImpl) validateProductAnalyticsPerParentCategoryRequest(req *models.ProductAnalyticsPerParentCategoryRequest) error {
|
||||||
|
if req.OrganizationID == uuid.Nil {
|
||||||
|
return fmt.Errorf("organization ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DateFrom.IsZero() {
|
||||||
|
return fmt.Errorf("date_from is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DateTo.IsZero() {
|
||||||
|
return fmt.Errorf("date_to is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DateFrom.After(req.DateTo) {
|
||||||
|
return fmt.Errorf("date_from cannot be after date_to")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AnalyticsServiceImpl) validateParentCategoryAnalyticsDetailRequest(req *models.ParentCategoryAnalyticsDetailRequest) error {
|
||||||
|
if req.OrganizationID == uuid.Nil {
|
||||||
|
return fmt.Errorf("organization ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ParentCategoryID == uuid.Nil {
|
||||||
|
return fmt.Errorf("parent category ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DateFrom.IsZero() {
|
||||||
|
return fmt.Errorf("date_from is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DateTo.IsZero() {
|
||||||
|
return fmt.Errorf("date_to is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DateFrom.After(req.DateTo) {
|
||||||
|
return fmt.Errorf("date_from cannot be after date_to")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AnalyticsServiceImpl) validateDashboardAnalyticsRequest(req *models.DashboardAnalyticsRequest) error {
|
func (s *AnalyticsServiceImpl) validateDashboardAnalyticsRequest(req *models.DashboardAnalyticsRequest) error {
|
||||||
if req.OrganizationID == uuid.Nil {
|
if req.OrganizationID == uuid.Nil {
|
||||||
return fmt.Errorf("organization ID is required")
|
return fmt.Errorf("organization ID is required")
|
||||||
@@ -349,6 +430,19 @@ func (s *AnalyticsServiceImpl) GetExclusiveSummaryMonthly(ctx context.Context, r
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AnalyticsServiceImpl) GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
|
||||||
|
if err := s.validateExclusiveSummaryMTDRequest(req); err != nil {
|
||||||
|
return nil, fmt.Errorf("validation error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := s.analyticsProcessor.GetExclusiveSummaryMTD(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get exclusive summary mtd: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AnalyticsServiceImpl) validateExclusiveSummaryPeriodRequest(req *models.ExclusiveSummaryPeriodRequest) error {
|
func (s *AnalyticsServiceImpl) validateExclusiveSummaryPeriodRequest(req *models.ExclusiveSummaryPeriodRequest) error {
|
||||||
if req == nil {
|
if req == nil {
|
||||||
return fmt.Errorf("request cannot be nil")
|
return fmt.Errorf("request cannot be nil")
|
||||||
@@ -373,6 +467,22 @@ func (s *AnalyticsServiceImpl) validateExclusiveSummaryPeriodRequest(req *models
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AnalyticsServiceImpl) validateExclusiveSummaryMTDRequest(req *models.ExclusiveSummaryMTDRequest) error {
|
||||||
|
if req == nil {
|
||||||
|
return fmt.Errorf("request cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.OrganizationID == uuid.Nil {
|
||||||
|
return fmt.Errorf("organization_id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DateTo.IsZero() {
|
||||||
|
return fmt.Errorf("date_to is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AnalyticsServiceImpl) validateExclusiveSummaryMonthlyRequest(req *models.ExclusiveSummaryMonthlyRequest) error {
|
func (s *AnalyticsServiceImpl) validateExclusiveSummaryMonthlyRequest(req *models.ExclusiveSummaryMonthlyRequest) error {
|
||||||
if req == nil {
|
if req == nil {
|
||||||
return fmt.Errorf("request cannot be nil")
|
return fmt.Errorf("request cannot be nil")
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ func (analyticsProcessorStub) GetProductAnalyticsPerCategory(context.Context, *m
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (analyticsProcessorStub) GetProductAnalyticsPerParentCategory(context.Context, *models.ProductAnalyticsPerParentCategoryRequest) (*models.ProductAnalyticsPerParentCategoryResponse, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (analyticsProcessorStub) GetParentCategoryAnalyticsDetail(context.Context, *models.ParentCategoryAnalyticsDetailRequest) (*models.ParentCategoryAnalyticsDetailResponse, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (analyticsProcessorStub) GetDashboardAnalytics(context.Context, *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) {
|
func (analyticsProcessorStub) GetDashboardAnalytics(context.Context, *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -49,6 +57,10 @@ func (analyticsProcessorStub) GetExclusiveSummaryMonthly(context.Context, *model
|
|||||||
return &models.ExclusiveSummaryMonthlyResponse{}, nil
|
return &models.ExclusiveSummaryMonthlyResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (analyticsProcessorStub) GetExclusiveSummaryMTD(context.Context, *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
|
||||||
|
return &models.ExclusiveSummaryPeriodResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestAnalyticsServiceGetPurchasingAnalyticsValidation(t *testing.T) {
|
func TestAnalyticsServiceGetPurchasingAnalyticsValidation(t *testing.T) {
|
||||||
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
|
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
|
||||||
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||||
@@ -101,6 +113,16 @@ func TestAnalyticsServiceGetPurchasingAnalyticsValidation(t *testing.T) {
|
|||||||
},
|
},
|
||||||
wantErr: "invalid group_by value: quarter",
|
wantErr: "invalid group_by value: quarter",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "unknown team",
|
||||||
|
req: &models.PurchasingAnalyticsRequest{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
DateFrom: now,
|
||||||
|
DateTo: now,
|
||||||
|
Team: "marketing",
|
||||||
|
},
|
||||||
|
wantErr: "team must be one of",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -198,3 +220,100 @@ func TestAnalyticsServiceGetProfitLossAnalyticsAllowsEmptyGroupBy(t *testing.T)
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Nil(t, resp)
|
require.Nil(t, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAnalyticsServiceGetExclusiveSummaryPeriodValidation(t *testing.T) {
|
||||||
|
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
|
||||||
|
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
req *models.ExclusiveSummaryPeriodRequest
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil request",
|
||||||
|
req: nil,
|
||||||
|
wantErr: "request cannot be nil",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing organization",
|
||||||
|
req: &models.ExclusiveSummaryPeriodRequest{
|
||||||
|
DateFrom: now,
|
||||||
|
DateTo: now,
|
||||||
|
},
|
||||||
|
wantErr: "organization_id is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reversed dates",
|
||||||
|
req: &models.ExclusiveSummaryPeriodRequest{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
DateFrom: now.AddDate(0, 0, 1),
|
||||||
|
DateTo: now,
|
||||||
|
},
|
||||||
|
wantErr: "date_from cannot be after date_to",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
resp, err := service.GetExclusiveSummaryPeriod(context.Background(), tt.req)
|
||||||
|
|
||||||
|
require.Nil(t, resp)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), tt.wantErr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyticsServiceGetExclusiveSummaryMonthlyValidation(t *testing.T) {
|
||||||
|
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
|
||||||
|
|
||||||
|
resp, err := service.GetExclusiveSummaryMonthly(context.Background(), &models.ExclusiveSummaryMonthlyRequest{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Nil(t, resp)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "month is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyticsServiceGetExclusiveSummaryMTDValidation(t *testing.T) {
|
||||||
|
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
|
||||||
|
now := time.Date(2026, 6, 18, 23, 59, 59, 0, time.UTC)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
req *models.ExclusiveSummaryMTDRequest
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil request",
|
||||||
|
req: nil,
|
||||||
|
wantErr: "request cannot be nil",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing organization",
|
||||||
|
req: &models.ExclusiveSummaryMTDRequest{
|
||||||
|
DateTo: now,
|
||||||
|
},
|
||||||
|
wantErr: "organization_id is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing date_to",
|
||||||
|
req: &models.ExclusiveSummaryMTDRequest{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
},
|
||||||
|
wantErr: "date_to is required",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
resp, err := service.GetExclusiveSummaryMTD(context.Background(), tt.req)
|
||||||
|
|
||||||
|
require.Nil(t, resp)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), tt.wantErr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/appcontext"
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
|
"apskel-pos-be/internal/contract"
|
||||||
|
"apskel-pos-be/internal/processor"
|
||||||
|
"apskel-pos-be/internal/transformer"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CashAdvanceService interface {
|
||||||
|
CreateCashAdvance(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.CreateCashAdvanceRequest) *contract.Response
|
||||||
|
UpdateCashAdvance(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, req *contract.UpdateCashAdvanceRequest) *contract.Response
|
||||||
|
DeleteCashAdvance(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response
|
||||||
|
GetCashAdvanceByID(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response
|
||||||
|
ListCashAdvances(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ListCashAdvancesRequest) *contract.Response
|
||||||
|
UpdateCashAdvanceStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, status string) *contract.Response
|
||||||
|
ListCashAdvanceTeams(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response
|
||||||
|
}
|
||||||
|
|
||||||
|
type CashAdvanceServiceImpl struct {
|
||||||
|
cashAdvanceProcessor processor.CashAdvanceProcessor
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCashAdvanceService(cashAdvanceProcessor processor.CashAdvanceProcessor) *CashAdvanceServiceImpl {
|
||||||
|
return &CashAdvanceServiceImpl{cashAdvanceProcessor: cashAdvanceProcessor}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CashAdvanceServiceImpl) CreateCashAdvance(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.CreateCashAdvanceRequest) *contract.Response {
|
||||||
|
modelReq, err := transformer.CreateCashAdvanceRequestToModel(req)
|
||||||
|
if err != nil {
|
||||||
|
errorResp := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.CashAdvanceServiceEntity, "Invalid date format. Use YYYY-MM-DD format")
|
||||||
|
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
||||||
|
}
|
||||||
|
|
||||||
|
cashAdvance, err := s.cashAdvanceProcessor.CreateCashAdvance(ctx, apctx.OrganizationID, outletFromContext(apctx), modelReq)
|
||||||
|
if err != nil {
|
||||||
|
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.CashAdvanceServiceEntity, err.Error())
|
||||||
|
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
||||||
|
}
|
||||||
|
|
||||||
|
return contract.BuildSuccessResponse(transformer.CashAdvanceModelResponseToResponse(cashAdvance))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CashAdvanceServiceImpl) UpdateCashAdvance(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, req *contract.UpdateCashAdvanceRequest) *contract.Response {
|
||||||
|
modelReq, err := transformer.UpdateCashAdvanceRequestToModel(req)
|
||||||
|
if err != nil {
|
||||||
|
errorResp := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.CashAdvanceServiceEntity, "Invalid date format. Use YYYY-MM-DD format")
|
||||||
|
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
||||||
|
}
|
||||||
|
|
||||||
|
cashAdvance, err := s.cashAdvanceProcessor.UpdateCashAdvance(ctx, id, apctx.OrganizationID, modelReq)
|
||||||
|
if err != nil {
|
||||||
|
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.CashAdvanceServiceEntity, err.Error())
|
||||||
|
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
||||||
|
}
|
||||||
|
|
||||||
|
return contract.BuildSuccessResponse(transformer.CashAdvanceModelResponseToResponse(cashAdvance))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CashAdvanceServiceImpl) DeleteCashAdvance(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response {
|
||||||
|
if err := s.cashAdvanceProcessor.DeleteCashAdvance(ctx, id, apctx.OrganizationID); err != nil {
|
||||||
|
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.CashAdvanceServiceEntity, err.Error())
|
||||||
|
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
||||||
|
}
|
||||||
|
|
||||||
|
return contract.BuildSuccessResponse(map[string]interface{}{
|
||||||
|
"message": "Cash advance deleted successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CashAdvanceServiceImpl) GetCashAdvanceByID(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response {
|
||||||
|
cashAdvance, err := s.cashAdvanceProcessor.GetCashAdvanceByID(ctx, id, apctx.OrganizationID)
|
||||||
|
if err != nil {
|
||||||
|
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.CashAdvanceServiceEntity, err.Error())
|
||||||
|
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
||||||
|
}
|
||||||
|
|
||||||
|
return contract.BuildSuccessResponse(transformer.CashAdvanceModelResponseToResponse(cashAdvance))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CashAdvanceServiceImpl) ListCashAdvances(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ListCashAdvancesRequest) *contract.Response {
|
||||||
|
modelReq := transformer.ListCashAdvancesRequestToModel(req)
|
||||||
|
|
||||||
|
filters := make(map[string]interface{})
|
||||||
|
if modelReq.Search != "" {
|
||||||
|
filters["search"] = modelReq.Search
|
||||||
|
}
|
||||||
|
if modelReq.Status != "" {
|
||||||
|
filters["status"] = modelReq.Status
|
||||||
|
}
|
||||||
|
if modelReq.SettlementStatus != "" {
|
||||||
|
filters["settlement_status"] = modelReq.SettlementStatus
|
||||||
|
}
|
||||||
|
if modelReq.TeamScope != "" {
|
||||||
|
filters["team_scope"] = modelReq.TeamScope
|
||||||
|
}
|
||||||
|
if modelReq.TeamCategoryID != nil {
|
||||||
|
filters["team_category_id"] = *modelReq.TeamCategoryID
|
||||||
|
}
|
||||||
|
// team spells out the same two filters in one value; the validator has already
|
||||||
|
// ruled out sending it together with them.
|
||||||
|
switch modelReq.Team {
|
||||||
|
case "":
|
||||||
|
case constants.PurchaseTeamScopeCentral:
|
||||||
|
filters["team_scope"] = constants.PurchaseTeamScopeCentral
|
||||||
|
default:
|
||||||
|
if teamCategoryID, err := uuid.Parse(modelReq.Team); err == nil {
|
||||||
|
filters["team_scope"] = constants.PurchaseTeamScopeCategory
|
||||||
|
filters["team_category_id"] = teamCategoryID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if modelReq.StartDate != nil {
|
||||||
|
filters["start_date"] = *modelReq.StartDate
|
||||||
|
}
|
||||||
|
if modelReq.EndDate != nil {
|
||||||
|
filters["end_date"] = *modelReq.EndDate
|
||||||
|
}
|
||||||
|
// Cash belongs to the drawer it came out of, so a user signed in to one outlet
|
||||||
|
// only sees that outlet's cash advances.
|
||||||
|
if outletID := outletFromContext(apctx); outletID != nil {
|
||||||
|
filters["outlet_id"] = *outletID
|
||||||
|
}
|
||||||
|
|
||||||
|
cashAdvances, totalPages, err := s.cashAdvanceProcessor.ListCashAdvances(ctx, apctx.OrganizationID, filters, modelReq.Page, modelReq.Limit)
|
||||||
|
if err != nil {
|
||||||
|
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.CashAdvanceServiceEntity, err.Error())
|
||||||
|
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
||||||
|
}
|
||||||
|
|
||||||
|
responses := make([]contract.CashAdvanceResponse, len(cashAdvances))
|
||||||
|
for i, cashAdvance := range cashAdvances {
|
||||||
|
if response := transformer.CashAdvanceModelResponseToResponse(cashAdvance); response != nil {
|
||||||
|
responses[i] = *response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return contract.BuildSuccessResponse(contract.ListCashAdvancesResponse{
|
||||||
|
CashAdvances: responses,
|
||||||
|
TotalCount: len(responses),
|
||||||
|
Page: modelReq.Page,
|
||||||
|
Limit: modelReq.Limit,
|
||||||
|
TotalPages: totalPages,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CashAdvanceServiceImpl) UpdateCashAdvanceStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, status string) *contract.Response {
|
||||||
|
cashAdvance, err := s.cashAdvanceProcessor.UpdateCashAdvanceStatus(ctx, id, apctx.OrganizationID, status)
|
||||||
|
if err != nil {
|
||||||
|
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.CashAdvanceServiceEntity, err.Error())
|
||||||
|
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
||||||
|
}
|
||||||
|
|
||||||
|
return contract.BuildSuccessResponse(transformer.CashAdvanceModelResponseToResponse(cashAdvance))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CashAdvanceServiceImpl) ListCashAdvanceTeams(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response {
|
||||||
|
teams, err := s.cashAdvanceProcessor.ListCashAdvanceTeams(ctx, apctx.OrganizationID, outletFromContext(apctx))
|
||||||
|
if err != nil {
|
||||||
|
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.CashAdvanceServiceEntity, err.Error())
|
||||||
|
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
||||||
|
}
|
||||||
|
|
||||||
|
return contract.BuildSuccessResponse(transformer.ListPurchaseTeamsModelResponseToResponse(teams))
|
||||||
|
}
|
||||||
|
|
||||||
|
// outletFromContext reads the caller's outlet as an optional value: an organization
|
||||||
|
// level user has none, and uuid.Nil is how that arrives on the context.
|
||||||
|
func outletFromContext(apctx *appcontext.ContextInfo) *uuid.UUID {
|
||||||
|
if apctx.OutletID == uuid.Nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
outletID := apctx.OutletID
|
||||||
|
return &outletID
|
||||||
|
}
|
||||||
@@ -91,6 +91,12 @@ func (s *CategoryServiceImpl) ListCategories(ctx context.Context, req *contract.
|
|||||||
if req.BusinessType != "" {
|
if req.BusinessType != "" {
|
||||||
filters["business_type"] = req.BusinessType
|
filters["business_type"] = req.BusinessType
|
||||||
}
|
}
|
||||||
|
if req.ParentID != nil {
|
||||||
|
filters["parent_id"] = *req.ParentID
|
||||||
|
}
|
||||||
|
if req.Type != "" {
|
||||||
|
filters["type"] = req.Type
|
||||||
|
}
|
||||||
if req.Search != "" {
|
if req.Search != "" {
|
||||||
filters["search"] = req.Search
|
filters["search"] = req.Search
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type PurchaseOrderService interface {
|
|||||||
GetPurchaseOrdersByStatus(ctx context.Context, apctx *appcontext.ContextInfo, status string) *contract.Response
|
GetPurchaseOrdersByStatus(ctx context.Context, apctx *appcontext.ContextInfo, status string) *contract.Response
|
||||||
GetOverduePurchaseOrders(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response
|
GetOverduePurchaseOrders(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response
|
||||||
UpdatePurchaseOrderStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, status string) *contract.Response
|
UpdatePurchaseOrderStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, status string) *contract.Response
|
||||||
|
ListPurchaseTeams(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response
|
||||||
}
|
}
|
||||||
|
|
||||||
type PurchaseOrderServiceImpl struct {
|
type PurchaseOrderServiceImpl struct {
|
||||||
@@ -40,7 +41,12 @@ func (s *PurchaseOrderServiceImpl) CreatePurchaseOrder(ctx context.Context, apct
|
|||||||
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
||||||
}
|
}
|
||||||
|
|
||||||
poResponse, err := s.purchaseOrderProcessor.CreatePurchaseOrder(ctx, apctx.OrganizationID, modelReq)
|
var outletID *uuid.UUID
|
||||||
|
if apctx.OutletID != uuid.Nil {
|
||||||
|
outletID = &apctx.OutletID
|
||||||
|
}
|
||||||
|
|
||||||
|
poResponse, err := s.purchaseOrderProcessor.CreatePurchaseOrder(ctx, apctx.OrganizationID, outletID, modelReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseOrderServiceEntity, err.Error())
|
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseOrderServiceEntity, err.Error())
|
||||||
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
||||||
@@ -57,7 +63,12 @@ func (s *PurchaseOrderServiceImpl) UpdatePurchaseOrder(ctx context.Context, apct
|
|||||||
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
||||||
}
|
}
|
||||||
|
|
||||||
poResponse, err := s.purchaseOrderProcessor.UpdatePurchaseOrder(ctx, id, apctx.OrganizationID, modelReq)
|
var outletID *uuid.UUID
|
||||||
|
if apctx.OutletID != uuid.Nil {
|
||||||
|
outletID = &apctx.OutletID
|
||||||
|
}
|
||||||
|
|
||||||
|
poResponse, err := s.purchaseOrderProcessor.UpdatePurchaseOrder(ctx, id, apctx.OrganizationID, outletID, modelReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseOrderServiceEntity, err.Error())
|
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseOrderServiceEntity, err.Error())
|
||||||
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
||||||
@@ -103,6 +114,26 @@ func (s *PurchaseOrderServiceImpl) ListPurchaseOrders(ctx context.Context, apctx
|
|||||||
if modelReq.VendorID != nil {
|
if modelReq.VendorID != nil {
|
||||||
filters["vendor_id"] = *modelReq.VendorID
|
filters["vendor_id"] = *modelReq.VendorID
|
||||||
}
|
}
|
||||||
|
if modelReq.TeamScope != "" {
|
||||||
|
filters["team_scope"] = modelReq.TeamScope
|
||||||
|
}
|
||||||
|
if modelReq.TeamCategoryID != nil {
|
||||||
|
filters["team_category_id"] = *modelReq.TeamCategoryID
|
||||||
|
}
|
||||||
|
// team spells out the same two filters in one value; the validator has already
|
||||||
|
// ruled out sending it together with them.
|
||||||
|
switch modelReq.Team {
|
||||||
|
case "":
|
||||||
|
case constants.PurchaseTeamNone:
|
||||||
|
filters["team_unassigned"] = true
|
||||||
|
case constants.PurchaseTeamScopeCentral:
|
||||||
|
filters["team_scope"] = constants.PurchaseTeamScopeCentral
|
||||||
|
default:
|
||||||
|
if teamCategoryID, err := uuid.Parse(modelReq.Team); err == nil {
|
||||||
|
filters["team_scope"] = constants.PurchaseTeamScopeCategory
|
||||||
|
filters["team_category_id"] = teamCategoryID
|
||||||
|
}
|
||||||
|
}
|
||||||
if modelReq.StartDate != nil {
|
if modelReq.StartDate != nil {
|
||||||
filters["start_date"] = *modelReq.StartDate
|
filters["start_date"] = *modelReq.StartDate
|
||||||
}
|
}
|
||||||
@@ -135,6 +166,21 @@ func (s *PurchaseOrderServiceImpl) ListPurchaseOrders(ctx context.Context, apctx
|
|||||||
return contract.BuildSuccessResponse(response)
|
return contract.BuildSuccessResponse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *PurchaseOrderServiceImpl) ListPurchaseTeams(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response {
|
||||||
|
var outletID *uuid.UUID
|
||||||
|
if apctx.OutletID != uuid.Nil {
|
||||||
|
outletID = &apctx.OutletID
|
||||||
|
}
|
||||||
|
|
||||||
|
teams, err := s.purchaseOrderProcessor.ListPurchaseTeams(ctx, apctx.OrganizationID, outletID)
|
||||||
|
if err != nil {
|
||||||
|
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseOrderServiceEntity, err.Error())
|
||||||
|
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
||||||
|
}
|
||||||
|
|
||||||
|
return contract.BuildSuccessResponse(transformer.ListPurchaseTeamsModelResponseToResponse(teams))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *PurchaseOrderServiceImpl) GetPurchaseOrdersByStatus(ctx context.Context, apctx *appcontext.ContextInfo, status string) *contract.Response {
|
func (s *PurchaseOrderServiceImpl) GetPurchaseOrdersByStatus(ctx context.Context, apctx *appcontext.ContextInfo, status string) *contract.Response {
|
||||||
poResponses, err := s.purchaseOrderProcessor.GetPurchaseOrdersByStatus(ctx, apctx.OrganizationID, status)
|
poResponses, err := s.purchaseOrderProcessor.GetPurchaseOrdersByStatus(ctx, apctx.OrganizationID, status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
type ReportService interface {
|
type ReportService interface {
|
||||||
// Returns (publicURL, fileName, error)
|
// Returns (publicURL, fileName, error)
|
||||||
GenerateDailyTransactionPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error)
|
GenerateDailyTransactionPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error)
|
||||||
|
GenerateProfitLossPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReportServiceImpl struct {
|
type ReportServiceImpl struct {
|
||||||
@@ -218,3 +219,296 @@ func getPLPctByID(rows []models.ProfitLossSummaryRow, id string) float64 {
|
|||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// profitLossReportData holds data for the profit/loss PDF template
|
||||||
|
type profitLossReportData struct {
|
||||||
|
OrganizationName string
|
||||||
|
MonthName string
|
||||||
|
ReportDate string
|
||||||
|
ReportDateUpper string
|
||||||
|
TotalPenjualan string
|
||||||
|
TotalBiaya string
|
||||||
|
LabaRugi string
|
||||||
|
LabaRugiClass string
|
||||||
|
LabaRugiValueClass string
|
||||||
|
LabaRugiMtd string
|
||||||
|
LabaRugiMtdClass string
|
||||||
|
LabaRugiMtdValueClass string
|
||||||
|
MainSummary []profitLossSummaryRowView
|
||||||
|
PurchasingItems []profitLossPurchasingItem
|
||||||
|
PurchasingTotal string
|
||||||
|
GeneratedBy string
|
||||||
|
PrintTime string
|
||||||
|
}
|
||||||
|
|
||||||
|
type profitLossSummaryRowView struct {
|
||||||
|
Number string
|
||||||
|
Label string
|
||||||
|
TodayNominal string
|
||||||
|
TodayPct string
|
||||||
|
MtdNominal string
|
||||||
|
MtdPct string
|
||||||
|
RowClass string
|
||||||
|
SubItems []profitLossSummaryRowView
|
||||||
|
}
|
||||||
|
|
||||||
|
type profitLossPurchasingItem struct {
|
||||||
|
Name string
|
||||||
|
Amount string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ReportServiceImpl) GenerateProfitLossPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error) {
|
||||||
|
orgID, err := uuid.Parse(organizationID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("invalid organization id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var outID *uuid.UUID
|
||||||
|
if outletID != "" {
|
||||||
|
parsed, err := uuid.Parse(outletID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("invalid outlet id: %w", err)
|
||||||
|
}
|
||||||
|
outID = &parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
org, err := s.organizationRepo.GetByID(ctx, orgID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("organization not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tzName string
|
||||||
|
if outID != nil {
|
||||||
|
outlet, err := s.outletRepo.GetByID(ctx, *outID)
|
||||||
|
if err == nil && outlet.Timezone != nil && *outlet.Timezone != "" {
|
||||||
|
tzName = *outlet.Timezone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tzName == "" {
|
||||||
|
tzName = "Asia/Jakarta"
|
||||||
|
}
|
||||||
|
|
||||||
|
loc, locErr := time.LoadLocation(tzName)
|
||||||
|
if locErr != nil || loc == nil {
|
||||||
|
loc = time.Local
|
||||||
|
}
|
||||||
|
|
||||||
|
var day time.Time
|
||||||
|
if reportDate != nil {
|
||||||
|
t := reportDate.UTC()
|
||||||
|
day = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
|
||||||
|
} else {
|
||||||
|
now := time.Now().In(loc)
|
||||||
|
day = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
dayStart := day
|
||||||
|
dayEnd := day.Add(24*time.Hour - time.Nanosecond)
|
||||||
|
|
||||||
|
// MTD: from 1st of month to end of the report day
|
||||||
|
mtdStart := time.Date(day.Year(), day.Month(), 1, 0, 0, 0, 0, loc)
|
||||||
|
mtdEnd := dayEnd
|
||||||
|
|
||||||
|
// Get profit/loss analytics for the day
|
||||||
|
plReq := &models.ProfitLossAnalyticsRequest{
|
||||||
|
OrganizationID: orgID,
|
||||||
|
OutletID: outID,
|
||||||
|
DateFrom: dayStart,
|
||||||
|
DateTo: mtdEnd,
|
||||||
|
GroupBy: "day",
|
||||||
|
}
|
||||||
|
pl, err := s.analyticsService.GetProfitLossAnalytics(ctx, plReq)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("get profit/loss analytics: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get purchasing analytics for the day (Rincian Biaya / Catatan)
|
||||||
|
purchReq := &models.PurchasingAnalyticsRequest{
|
||||||
|
OrganizationID: orgID,
|
||||||
|
OutletID: outID,
|
||||||
|
DateFrom: dayStart,
|
||||||
|
DateTo: dayEnd,
|
||||||
|
GroupBy: "day",
|
||||||
|
}
|
||||||
|
purch, err := s.analyticsService.GetPurchasingAnalytics(ctx, purchReq)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("get purchasing analytics: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build summary values
|
||||||
|
totalOmset := getPLNominalByID(pl.MainSummary, "total_omset")
|
||||||
|
hpp := getPLNominalByID(pl.MainSummary, "hpp")
|
||||||
|
_ = mtdStart // used above
|
||||||
|
|
||||||
|
// Total biaya = HPP + operational expenses for the day
|
||||||
|
totalBiayaToday := hpp + pl.OperationalExpensesTotal
|
||||||
|
|
||||||
|
// Laba/Rugi today
|
||||||
|
labaRugiToday := totalOmset - totalBiayaToday
|
||||||
|
|
||||||
|
// MTD values
|
||||||
|
mtdOmset := getMtdNominalByID(pl.MainSummary, "total_omset")
|
||||||
|
mtdCost := getMtdNominalByID(pl.MainSummary, "hpp")
|
||||||
|
mtdOps := getMtdNominalByID(pl.MainSummary, "biaya_ops")
|
||||||
|
mtdGaji := getMtdNominalByID(pl.MainSummary, "biaya_gaji")
|
||||||
|
labaRugiMtd := mtdOmset - mtdCost - mtdOps - mtdGaji
|
||||||
|
|
||||||
|
// Month name in Indonesian
|
||||||
|
monthNames := []string{"", "Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli", "Agustus", "September", "Oktober", "November", "Desember"}
|
||||||
|
monthName := fmt.Sprintf("%s %d", monthNames[day.Month()], day.Year())
|
||||||
|
|
||||||
|
reportDateStr := fmt.Sprintf("%d %s %d", day.Day(), monthNames[day.Month()], day.Year())
|
||||||
|
reportDateUpper := fmt.Sprintf("%d %s %d", day.Day(), strings.ToUpper(monthNames[day.Month()]), day.Year())
|
||||||
|
|
||||||
|
// Build main summary rows
|
||||||
|
mainSummaryRows := buildProfitLossSummaryRows(pl.MainSummary)
|
||||||
|
|
||||||
|
// Build purchasing items from ingredient data
|
||||||
|
purchItems := make([]profitLossPurchasingItem, 0)
|
||||||
|
var purchTotal float64
|
||||||
|
for _, item := range purch.IngredientData {
|
||||||
|
purchItems = append(purchItems, profitLossPurchasingItem{
|
||||||
|
Name: item.IngredientName,
|
||||||
|
Amount: formatCurrency(item.TotalCost),
|
||||||
|
})
|
||||||
|
purchTotal += item.TotalCost
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine highlight classes
|
||||||
|
labaRugiClass := ""
|
||||||
|
labaRugiValueClass := ""
|
||||||
|
if labaRugiToday < 0 {
|
||||||
|
labaRugiClass = "highlight-red"
|
||||||
|
labaRugiValueClass = "negative"
|
||||||
|
} else {
|
||||||
|
labaRugiClass = "highlight-green"
|
||||||
|
labaRugiValueClass = "positive"
|
||||||
|
}
|
||||||
|
|
||||||
|
labaRugiMtdClass := ""
|
||||||
|
labaRugiMtdValueClass := ""
|
||||||
|
if labaRugiMtd < 0 {
|
||||||
|
labaRugiMtdClass = "highlight-red"
|
||||||
|
labaRugiMtdValueClass = "negative"
|
||||||
|
} else {
|
||||||
|
labaRugiMtdClass = "highlight-green"
|
||||||
|
labaRugiMtdValueClass = "positive"
|
||||||
|
}
|
||||||
|
|
||||||
|
data := profitLossReportData{
|
||||||
|
OrganizationName: org.Name,
|
||||||
|
MonthName: monthName,
|
||||||
|
ReportDate: reportDateStr,
|
||||||
|
ReportDateUpper: reportDateUpper,
|
||||||
|
TotalPenjualan: formatCurrency(totalOmset),
|
||||||
|
TotalBiaya: formatCurrency(totalBiayaToday),
|
||||||
|
LabaRugi: formatCurrencySigned(labaRugiToday),
|
||||||
|
LabaRugiClass: labaRugiClass,
|
||||||
|
LabaRugiValueClass: labaRugiValueClass,
|
||||||
|
LabaRugiMtd: formatCurrencySigned(labaRugiMtd),
|
||||||
|
LabaRugiMtdClass: labaRugiMtdClass,
|
||||||
|
LabaRugiMtdValueClass: labaRugiMtdValueClass,
|
||||||
|
MainSummary: mainSummaryRows,
|
||||||
|
PurchasingItems: purchItems,
|
||||||
|
PurchasingTotal: formatCurrency(purchTotal),
|
||||||
|
GeneratedBy: generatedBy,
|
||||||
|
PrintTime: time.Now().In(loc).Format("02/01/2006 15:04:05"),
|
||||||
|
}
|
||||||
|
|
||||||
|
templatePath := filepath.Join("templates", "profit_loss_report.html")
|
||||||
|
pdfBytes, err := renderTemplateToPDF(templatePath, data)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("render pdf: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
safeOrg := orgID.String()
|
||||||
|
safeOutlet := "all"
|
||||||
|
if outID != nil {
|
||||||
|
safeOutlet = outID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName := fmt.Sprintf("laporan-laba-rugi-%s-%s.pdf", day.Format("2006-01-02"), time.Now().Format("20060102-150405"))
|
||||||
|
objectKey := fmt.Sprintf("/reports/%s/%s/%s", safeOrg, safeOutlet, fileName)
|
||||||
|
publicURL, err := s.fileClient.UploadFile(ctx, objectKey, pdfBytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("upload pdf: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return publicURL, fileName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMtdNominalByID(rows []models.ProfitLossSummaryRow, id string) float64 {
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.ID == id {
|
||||||
|
return row.MtdNominal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildProfitLossSummaryRows(rows []models.ProfitLossSummaryRow) []profitLossSummaryRowView {
|
||||||
|
result := make([]profitLossSummaryRowView, 0, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
rowClass := ""
|
||||||
|
if row.IsBold {
|
||||||
|
rowClass = "highlight-green-row"
|
||||||
|
}
|
||||||
|
// Highlight laba kotor row
|
||||||
|
if row.ID == "laba_kotor" {
|
||||||
|
rowClass = "highlight-row"
|
||||||
|
}
|
||||||
|
|
||||||
|
number := ""
|
||||||
|
if row.ID != "" {
|
||||||
|
number = fmt.Sprintf("%d", i+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
subItems := make([]profitLossSummaryRowView, 0)
|
||||||
|
for _, sub := range row.SubItems {
|
||||||
|
subItems = append(subItems, profitLossSummaryRowView{
|
||||||
|
Label: sub.Label,
|
||||||
|
TodayNominal: formatCurrencyOrDash(sub.TodayNominal),
|
||||||
|
TodayPct: formatPct(sub.TodayPct),
|
||||||
|
MtdNominal: formatCurrencyOrDash(sub.MtdNominal),
|
||||||
|
MtdPct: formatPct(sub.MtdPct),
|
||||||
|
RowClass: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, profitLossSummaryRowView{
|
||||||
|
Number: number,
|
||||||
|
Label: row.Label,
|
||||||
|
TodayNominal: formatCurrencyOrDash(row.TodayNominal),
|
||||||
|
TodayPct: formatPct(row.TodayPct),
|
||||||
|
MtdNominal: formatCurrencyOrDash(row.MtdNominal),
|
||||||
|
MtdPct: formatPct(row.MtdPct),
|
||||||
|
RowClass: rowClass,
|
||||||
|
SubItems: subItems,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatCurrencyOrDash(amount float64) string {
|
||||||
|
if amount == 0 {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
if amount < 0 {
|
||||||
|
return formatCurrencySigned(amount)
|
||||||
|
}
|
||||||
|
return formatCurrency(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatCurrencySigned(amount float64) string {
|
||||||
|
if amount < 0 {
|
||||||
|
return "(Rp " + addThousandsSep(fmt.Sprintf("%.0f", -amount)) + ")"
|
||||||
|
}
|
||||||
|
return "Rp " + addThousandsSep(fmt.Sprintf("%.0f", amount))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatPct(pct float64) string {
|
||||||
|
if pct == 0 {
|
||||||
|
return "0%"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.0f%%", pct)
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ func PaymentMethodAnalyticsModelToContract(resp *models.PaymentMethodAnalyticsRe
|
|||||||
return &contract.PaymentMethodAnalyticsResponse{
|
return &contract.PaymentMethodAnalyticsResponse{
|
||||||
OrganizationID: resp.OrganizationID,
|
OrganizationID: resp.OrganizationID,
|
||||||
OutletID: resp.OutletID,
|
OutletID: resp.OutletID,
|
||||||
|
OutletName: resp.OutletName,
|
||||||
DateFrom: resp.DateFrom,
|
DateFrom: resp.DateFrom,
|
||||||
DateTo: resp.DateTo,
|
DateTo: resp.DateTo,
|
||||||
GroupBy: resp.GroupBy,
|
GroupBy: resp.GroupBy,
|
||||||
@@ -122,6 +123,7 @@ func SalesAnalyticsModelToContract(resp *models.SalesAnalyticsResponse) *contrac
|
|||||||
return &contract.SalesAnalyticsResponse{
|
return &contract.SalesAnalyticsResponse{
|
||||||
OrganizationID: resp.OrganizationID,
|
OrganizationID: resp.OrganizationID,
|
||||||
OutletID: resp.OutletID,
|
OutletID: resp.OutletID,
|
||||||
|
OutletName: resp.OutletName,
|
||||||
DateFrom: resp.DateFrom,
|
DateFrom: resp.DateFrom,
|
||||||
DateTo: resp.DateTo,
|
DateTo: resp.DateTo,
|
||||||
GroupBy: resp.GroupBy,
|
GroupBy: resp.GroupBy,
|
||||||
@@ -154,6 +156,7 @@ func PurchasingAnalyticsContractToModel(req *contract.PurchasingAnalyticsRequest
|
|||||||
return &models.PurchasingAnalyticsRequest{
|
return &models.PurchasingAnalyticsRequest{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
OutletID: parseOutletID(req.OutletID),
|
OutletID: parseOutletID(req.OutletID),
|
||||||
|
Team: req.Team,
|
||||||
DateFrom: dateFrom,
|
DateFrom: dateFrom,
|
||||||
DateTo: dateTo,
|
DateTo: dateTo,
|
||||||
GroupBy: req.GroupBy,
|
GroupBy: req.GroupBy,
|
||||||
@@ -206,10 +209,26 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
teamData := make([]contract.PurchasingTeamData, len(resp.TeamData))
|
||||||
|
for i, item := range resp.TeamData {
|
||||||
|
teamData[i] = contract.PurchasingTeamData{
|
||||||
|
Scope: item.Scope,
|
||||||
|
CategoryID: item.CategoryID,
|
||||||
|
Name: item.Name,
|
||||||
|
TotalPurchases: item.TotalPurchases,
|
||||||
|
RawMaterialPurchases: item.RawMaterialPurchases,
|
||||||
|
ExpensePurchases: item.ExpensePurchases,
|
||||||
|
PurchaseOrderCount: item.PurchaseOrderCount,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
Percentage: item.Percentage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &contract.PurchasingAnalyticsResponse{
|
return &contract.PurchasingAnalyticsResponse{
|
||||||
OrganizationID: resp.OrganizationID,
|
OrganizationID: resp.OrganizationID,
|
||||||
OutletID: resp.OutletID,
|
OutletID: resp.OutletID,
|
||||||
OutletName: resp.OutletName,
|
OutletName: resp.OutletName,
|
||||||
|
Team: resp.Team,
|
||||||
DateFrom: resp.DateFrom,
|
DateFrom: resp.DateFrom,
|
||||||
DateTo: resp.DateTo,
|
DateTo: resp.DateTo,
|
||||||
GroupBy: resp.GroupBy,
|
GroupBy: resp.GroupBy,
|
||||||
@@ -224,10 +243,12 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse
|
|||||||
AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue,
|
AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue,
|
||||||
TotalIngredients: resp.Summary.TotalIngredients,
|
TotalIngredients: resp.Summary.TotalIngredients,
|
||||||
TotalVendors: resp.Summary.TotalVendors,
|
TotalVendors: resp.Summary.TotalVendors,
|
||||||
|
TotalTeams: resp.Summary.TotalTeams,
|
||||||
},
|
},
|
||||||
Data: data,
|
Data: data,
|
||||||
IngredientData: ingredientData,
|
IngredientData: ingredientData,
|
||||||
VendorData: vendorData,
|
VendorData: vendorData,
|
||||||
|
TeamData: teamData,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,6 +306,7 @@ func ProductAnalyticsModelToContract(resp *models.ProductAnalyticsResponse) *con
|
|||||||
return &contract.ProductAnalyticsResponse{
|
return &contract.ProductAnalyticsResponse{
|
||||||
OrganizationID: resp.OrganizationID,
|
OrganizationID: resp.OrganizationID,
|
||||||
OutletID: resp.OutletID,
|
OutletID: resp.OutletID,
|
||||||
|
OutletName: resp.OutletName,
|
||||||
DateFrom: resp.DateFrom,
|
DateFrom: resp.DateFrom,
|
||||||
DateTo: resp.DateTo,
|
DateTo: resp.DateTo,
|
||||||
Data: data,
|
Data: data,
|
||||||
@@ -337,12 +359,202 @@ func ProductAnalyticsPerCategoryModelToContract(resp *models.ProductAnalyticsPer
|
|||||||
return &contract.ProductAnalyticsPerCategoryResponse{
|
return &contract.ProductAnalyticsPerCategoryResponse{
|
||||||
OrganizationID: resp.OrganizationID,
|
OrganizationID: resp.OrganizationID,
|
||||||
OutletID: resp.OutletID,
|
OutletID: resp.OutletID,
|
||||||
|
OutletName: resp.OutletName,
|
||||||
DateFrom: resp.DateFrom,
|
DateFrom: resp.DateFrom,
|
||||||
DateTo: resp.DateTo,
|
DateTo: resp.DateTo,
|
||||||
Data: data,
|
Data: data,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProductAnalyticsPerParentCategoryContractToModel converts contract request to model
|
||||||
|
func ProductAnalyticsPerParentCategoryContractToModel(req *contract.ProductAnalyticsPerParentCategoryRequest) *models.ProductAnalyticsPerParentCategoryRequest {
|
||||||
|
var dateFrom, dateTo time.Time
|
||||||
|
|
||||||
|
// Parse date range using utility function
|
||||||
|
if fromTime, toTime, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo); err == nil {
|
||||||
|
if fromTime != nil {
|
||||||
|
dateFrom = *fromTime
|
||||||
|
}
|
||||||
|
if toTime != nil {
|
||||||
|
dateTo = *toTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.ProductAnalyticsPerParentCategoryRequest{
|
||||||
|
OrganizationID: req.OrganizationID,
|
||||||
|
OutletID: parseOutletID(req.OutletID),
|
||||||
|
DateFrom: dateFrom,
|
||||||
|
DateTo: dateTo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductAnalyticsPerParentCategoryModelToContract converts model response to contract
|
||||||
|
func ProductAnalyticsPerParentCategoryModelToContract(resp *models.ProductAnalyticsPerParentCategoryResponse) *contract.ProductAnalyticsPerParentCategoryResponse {
|
||||||
|
if resp == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var data []contract.ProductAnalyticsPerParentCategoryData
|
||||||
|
for _, item := range resp.Data {
|
||||||
|
data = append(data, contract.ProductAnalyticsPerParentCategoryData{
|
||||||
|
ParentCategoryID: item.ParentCategoryID,
|
||||||
|
ParentCategoryName: item.ParentCategoryName,
|
||||||
|
TotalRevenue: item.TotalRevenue,
|
||||||
|
TotalQuantity: item.TotalQuantity,
|
||||||
|
CategoryCount: item.CategoryCount,
|
||||||
|
ProductCount: item.ProductCount,
|
||||||
|
OrderCount: item.OrderCount,
|
||||||
|
TotalStandardHpp: item.TotalStandardHpp,
|
||||||
|
TotalFifoHpp: item.TotalFifoHpp,
|
||||||
|
TotalMovingAverageHpp: item.TotalMovingAverageHpp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &contract.ProductAnalyticsPerParentCategoryResponse{
|
||||||
|
OrganizationID: resp.OrganizationID,
|
||||||
|
OutletID: resp.OutletID,
|
||||||
|
OutletName: resp.OutletName,
|
||||||
|
DateFrom: resp.DateFrom,
|
||||||
|
DateTo: resp.DateTo,
|
||||||
|
Data: data,
|
||||||
|
Budget: BudgetCutOffModelToContract(resp.Budget),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// budgetPeriodModelToContract converts one budget period to contract
|
||||||
|
func budgetPeriodModelToContract(period models.BudgetPeriod) contract.BudgetPeriod {
|
||||||
|
return contract.BudgetPeriod{
|
||||||
|
PeriodStart: period.PeriodStart,
|
||||||
|
PeriodEnd: period.PeriodEnd,
|
||||||
|
Revenue: period.Revenue,
|
||||||
|
OrderCount: period.OrderCount,
|
||||||
|
LimitPurchase: period.LimitPurchase,
|
||||||
|
LimitOwner: period.LimitOwner,
|
||||||
|
LimitTeam: period.LimitTeam,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BudgetCutOffModelToContract converts the budget cut-off block to contract
|
||||||
|
func BudgetCutOffModelToContract(budget models.BudgetCutOff) contract.BudgetCutOff {
|
||||||
|
weekly := make([]contract.BudgetPeriod, 0, len(budget.Weekly))
|
||||||
|
for _, week := range budget.Weekly {
|
||||||
|
weekly = append(weekly, budgetPeriodModelToContract(week))
|
||||||
|
}
|
||||||
|
|
||||||
|
monthly := make([]contract.BudgetMonthPeriod, 0, len(budget.Monthly))
|
||||||
|
for _, month := range budget.Monthly {
|
||||||
|
monthly = append(monthly, contract.BudgetMonthPeriod{
|
||||||
|
Month: month.Month,
|
||||||
|
WeekCount: month.WeekCount,
|
||||||
|
BudgetPeriod: budgetPeriodModelToContract(month.BudgetPeriod),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return contract.BudgetCutOff{
|
||||||
|
Percentages: contract.BudgetPercentages{
|
||||||
|
Purchase: budget.Percentages.Purchase,
|
||||||
|
Owner: budget.Percentages.Owner,
|
||||||
|
Team: budget.Percentages.Team,
|
||||||
|
},
|
||||||
|
CutOffFrom: budget.CutOffFrom,
|
||||||
|
CutOffTo: budget.CutOffTo,
|
||||||
|
Total: budgetPeriodModelToContract(budget.Total),
|
||||||
|
Weekly: weekly,
|
||||||
|
Monthly: monthly,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParentCategoryAnalyticsDetailContractToModel converts contract request to model
|
||||||
|
func ParentCategoryAnalyticsDetailContractToModel(req *contract.ParentCategoryAnalyticsDetailRequest) *models.ParentCategoryAnalyticsDetailRequest {
|
||||||
|
var dateFrom, dateTo time.Time
|
||||||
|
|
||||||
|
// Parse date range using utility function
|
||||||
|
if fromTime, toTime, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo); err == nil {
|
||||||
|
if fromTime != nil {
|
||||||
|
dateFrom = *fromTime
|
||||||
|
}
|
||||||
|
if toTime != nil {
|
||||||
|
dateTo = *toTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unparseable id stays uuid.Nil and is rejected by the service validator
|
||||||
|
parentCategoryID, _ := uuid.Parse(req.ParentCategoryID)
|
||||||
|
|
||||||
|
return &models.ParentCategoryAnalyticsDetailRequest{
|
||||||
|
OrganizationID: req.OrganizationID,
|
||||||
|
ParentCategoryID: parentCategoryID,
|
||||||
|
OutletID: parseOutletID(req.OutletID),
|
||||||
|
DateFrom: dateFrom,
|
||||||
|
DateTo: dateTo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParentCategoryAnalyticsDetailModelToContract converts model response to contract
|
||||||
|
func ParentCategoryAnalyticsDetailModelToContract(resp *models.ParentCategoryAnalyticsDetailResponse) *contract.ParentCategoryAnalyticsDetailResponse {
|
||||||
|
if resp == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
categories := make([]contract.ParentCategoryAnalyticsDetailData, 0, len(resp.Categories))
|
||||||
|
for _, category := range resp.Categories {
|
||||||
|
products := make([]contract.ParentCategoryAnalyticsProductData, 0, len(category.Products))
|
||||||
|
for _, product := range category.Products {
|
||||||
|
products = append(products, contract.ParentCategoryAnalyticsProductData{
|
||||||
|
ProductID: product.ProductID,
|
||||||
|
ProductName: product.ProductName,
|
||||||
|
ProductSku: product.ProductSku,
|
||||||
|
ProductPrice: product.ProductPrice,
|
||||||
|
QuantitySold: product.QuantitySold,
|
||||||
|
Revenue: product.Revenue,
|
||||||
|
AveragePrice: product.AveragePrice,
|
||||||
|
OrderCount: product.OrderCount,
|
||||||
|
StandardHppPerUnit: product.StandardHppPerUnit,
|
||||||
|
StandardHppTotal: product.StandardHppTotal,
|
||||||
|
FifoHppPerUnit: product.FifoHppPerUnit,
|
||||||
|
FifoHppTotal: product.FifoHppTotal,
|
||||||
|
MovingAverageHppPerUnit: product.MovingAverageHppPerUnit,
|
||||||
|
MovingAverageHppTotal: product.MovingAverageHppTotal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
categories = append(categories, contract.ParentCategoryAnalyticsDetailData{
|
||||||
|
CategoryID: category.CategoryID,
|
||||||
|
CategoryName: category.CategoryName,
|
||||||
|
TotalRevenue: category.TotalRevenue,
|
||||||
|
TotalQuantity: category.TotalQuantity,
|
||||||
|
ProductCount: category.ProductCount,
|
||||||
|
OrderCount: category.OrderCount,
|
||||||
|
TotalStandardHpp: category.TotalStandardHpp,
|
||||||
|
TotalFifoHpp: category.TotalFifoHpp,
|
||||||
|
TotalMovingAverageHpp: category.TotalMovingAverageHpp,
|
||||||
|
Products: products,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &contract.ParentCategoryAnalyticsDetailResponse{
|
||||||
|
OrganizationID: resp.OrganizationID,
|
||||||
|
OutletID: resp.OutletID,
|
||||||
|
OutletName: resp.OutletName,
|
||||||
|
DateFrom: resp.DateFrom,
|
||||||
|
DateTo: resp.DateTo,
|
||||||
|
ParentCategoryID: resp.ParentCategoryID,
|
||||||
|
ParentCategoryName: resp.ParentCategoryName,
|
||||||
|
Summary: contract.ParentCategoryAnalyticsDetailSummary{
|
||||||
|
TotalRevenue: resp.Summary.TotalRevenue,
|
||||||
|
TotalQuantity: resp.Summary.TotalQuantity,
|
||||||
|
CategoryCount: resp.Summary.CategoryCount,
|
||||||
|
ProductCount: resp.Summary.ProductCount,
|
||||||
|
OrderCount: resp.Summary.OrderCount,
|
||||||
|
TotalStandardHpp: resp.Summary.TotalStandardHpp,
|
||||||
|
TotalFifoHpp: resp.Summary.TotalFifoHpp,
|
||||||
|
TotalMovingAverageHpp: resp.Summary.TotalMovingAverageHpp,
|
||||||
|
},
|
||||||
|
Categories: categories,
|
||||||
|
Budget: BudgetCutOffModelToContract(resp.Budget),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// DashboardAnalyticsContractToModel converts contract request to model
|
// DashboardAnalyticsContractToModel converts contract request to model
|
||||||
func DashboardAnalyticsContractToModel(req *contract.DashboardAnalyticsRequest) *models.DashboardAnalyticsRequest {
|
func DashboardAnalyticsContractToModel(req *contract.DashboardAnalyticsRequest) *models.DashboardAnalyticsRequest {
|
||||||
var dateFrom, dateTo time.Time
|
var dateFrom, dateTo time.Time
|
||||||
@@ -421,15 +633,19 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse)
|
|||||||
return &contract.DashboardAnalyticsResponse{
|
return &contract.DashboardAnalyticsResponse{
|
||||||
OrganizationID: resp.OrganizationID,
|
OrganizationID: resp.OrganizationID,
|
||||||
OutletID: resp.OutletID,
|
OutletID: resp.OutletID,
|
||||||
|
OutletName: resp.OutletName,
|
||||||
DateFrom: resp.DateFrom,
|
DateFrom: resp.DateFrom,
|
||||||
DateTo: resp.DateTo,
|
DateTo: resp.DateTo,
|
||||||
Overview: contract.DashboardOverview{
|
Overview: contract.DashboardOverview{
|
||||||
TotalSales: resp.Overview.TotalSales,
|
TotalSales: resp.Overview.TotalSales,
|
||||||
TotalOrders: resp.Overview.TotalOrders,
|
TotalOrders: resp.Overview.TotalOrders,
|
||||||
AverageOrderValue: resp.Overview.AverageOrderValue,
|
AverageOrderValue: resp.Overview.AverageOrderValue,
|
||||||
TotalCustomers: resp.Overview.TotalCustomers,
|
TotalCustomers: resp.Overview.TotalCustomers,
|
||||||
VoidedOrders: resp.Overview.VoidedOrders,
|
VoidedOrders: resp.Overview.VoidedOrders,
|
||||||
RefundedOrders: resp.Overview.RefundedOrders,
|
RefundedOrders: resp.Overview.RefundedOrders,
|
||||||
|
TotalItemSold: resp.Overview.TotalItemSold,
|
||||||
|
TotalLowStock: resp.Overview.TotalLowStock,
|
||||||
|
TotalProductActive: resp.Overview.TotalProductActive,
|
||||||
},
|
},
|
||||||
TopProducts: topProducts,
|
TopProducts: topProducts,
|
||||||
PaymentMethods: paymentMethods,
|
PaymentMethods: paymentMethods,
|
||||||
@@ -516,9 +732,20 @@ func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
purchasingItems := make([]contract.ProfitLossPurchasingItem, len(resp.Purchasing.Items))
|
||||||
|
for i, item := range resp.Purchasing.Items {
|
||||||
|
purchasingItems[i] = contract.ProfitLossPurchasingItem{
|
||||||
|
Date: item.Date,
|
||||||
|
Item: item.Item,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
Nominal: item.Nominal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &contract.ProfitLossAnalyticsResponse{
|
return &contract.ProfitLossAnalyticsResponse{
|
||||||
OrganizationID: resp.OrganizationID,
|
OrganizationID: resp.OrganizationID,
|
||||||
OutletID: resp.OutletID,
|
OutletID: resp.OutletID,
|
||||||
|
OutletName: resp.OutletName,
|
||||||
DateFrom: resp.DateFrom,
|
DateFrom: resp.DateFrom,
|
||||||
DateTo: resp.DateTo,
|
DateTo: resp.DateTo,
|
||||||
GroupBy: resp.GroupBy,
|
GroupBy: resp.GroupBy,
|
||||||
@@ -535,9 +762,18 @@ func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse
|
|||||||
AverageProfit: resp.Summary.AverageProfit,
|
AverageProfit: resp.Summary.AverageProfit,
|
||||||
ProfitabilityRatio: resp.Summary.ProfitabilityRatio,
|
ProfitabilityRatio: resp.Summary.ProfitabilityRatio,
|
||||||
},
|
},
|
||||||
Data: data,
|
Data: data,
|
||||||
ProductData: productData,
|
ProductData: productData,
|
||||||
MainSummary: mainSummary,
|
MainSummary: mainSummary,
|
||||||
|
Purchasing: contract.ProfitLossPurchasing{
|
||||||
|
TodayTotal: resp.Purchasing.TodayTotal,
|
||||||
|
MtdTotal: resp.Purchasing.MtdTotal,
|
||||||
|
TodayRawMaterial: resp.Purchasing.TodayRawMaterial,
|
||||||
|
MtdRawMaterial: resp.Purchasing.MtdRawMaterial,
|
||||||
|
TodayExpense: resp.Purchasing.TodayExpense,
|
||||||
|
MtdExpense: resp.Purchasing.MtdExpense,
|
||||||
|
Items: purchasingItems,
|
||||||
|
},
|
||||||
OperationalExpenses: opsItems,
|
OperationalExpenses: opsItems,
|
||||||
OperationalExpensesTotal: resp.OperationalExpensesTotal,
|
OperationalExpensesTotal: resp.OperationalExpensesTotal,
|
||||||
}
|
}
|
||||||
@@ -604,6 +840,27 @@ func ExclusiveSummaryMonthlyContractToModel(req *contract.ExclusiveSummaryMonthl
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ExclusiveSummaryMTDContractToModel(req *contract.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryMTDRequest, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, fmt.Errorf("request cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
dateTo, err := parseFlexibleDateToJakartaTime(req.DateTo, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid date_to: %w", err)
|
||||||
|
}
|
||||||
|
if dateTo == nil {
|
||||||
|
return nil, fmt.Errorf("date_to is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.ExclusiveSummaryMTDRequest{
|
||||||
|
OrganizationID: req.OrganizationID,
|
||||||
|
OutletID: parseOutletID(req.OutletID),
|
||||||
|
DateTo: *dateTo,
|
||||||
|
ExcludeGajiStaffFromReimburse: req.ExcludeGajiStaffFromReimburse,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func ExclusiveSummaryPeriodModelToContract(resp *models.ExclusiveSummaryPeriodResponse) *contract.ExclusiveSummaryPeriodResponse {
|
func ExclusiveSummaryPeriodModelToContract(resp *models.ExclusiveSummaryPeriodResponse) *contract.ExclusiveSummaryPeriodResponse {
|
||||||
if resp == nil {
|
if resp == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -643,6 +900,7 @@ func ExclusiveSummaryPeriodModelToContract(resp *models.ExclusiveSummaryPeriodRe
|
|||||||
return &contract.ExclusiveSummaryPeriodResponse{
|
return &contract.ExclusiveSummaryPeriodResponse{
|
||||||
OrganizationID: resp.OrganizationID,
|
OrganizationID: resp.OrganizationID,
|
||||||
OutletID: resp.OutletID,
|
OutletID: resp.OutletID,
|
||||||
|
OutletName: resp.OutletName,
|
||||||
Period: contract.ExclusiveSummaryPeriodRange{
|
Period: contract.ExclusiveSummaryPeriodRange{
|
||||||
DateFrom: resp.Period.DateFrom,
|
DateFrom: resp.Period.DateFrom,
|
||||||
DateTo: resp.Period.DateTo,
|
DateTo: resp.Period.DateTo,
|
||||||
@@ -705,6 +963,7 @@ func ExclusiveSummaryMonthlyModelToContract(resp *models.ExclusiveSummaryMonthly
|
|||||||
return &contract.ExclusiveSummaryMonthlyResponse{
|
return &contract.ExclusiveSummaryMonthlyResponse{
|
||||||
OrganizationID: resp.OrganizationID,
|
OrganizationID: resp.OrganizationID,
|
||||||
OutletID: resp.OutletID,
|
OutletID: resp.OutletID,
|
||||||
|
OutletName: resp.OutletName,
|
||||||
Month: resp.Month,
|
Month: resp.Month,
|
||||||
Summary: contract.ExclusiveSummaryMonthlySummary{
|
Summary: contract.ExclusiveSummaryMonthlySummary{
|
||||||
TotalSales: resp.Summary.TotalSales,
|
TotalSales: resp.Summary.TotalSales,
|
||||||
@@ -772,6 +1031,22 @@ func parseISODateToJakartaTime(dateStr string, endOfDay bool) (*time.Time, error
|
|||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseFlexibleDateToJakartaTime(dateStr string, endOfDay bool) (*time.Time, error) {
|
||||||
|
if dateStr == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fromTime, toTime, err := util.ParseDateRangeToJakartaTime(dateStr, dateStr)
|
||||||
|
if err == nil {
|
||||||
|
if endOfDay {
|
||||||
|
return toTime, nil
|
||||||
|
}
|
||||||
|
return fromTime, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseISODateToJakartaTime(dateStr, endOfDay)
|
||||||
|
}
|
||||||
|
|
||||||
func parseMonthToJakartaTime(month string) (time.Time, error) {
|
func parseMonthToJakartaTime(month string) (time.Time, error) {
|
||||||
location, err := time.LoadLocation("Asia/Jakarta")
|
location, err := time.LoadLocation("Asia/Jakarta")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
"apskel-pos-be/internal/contract"
|
"apskel-pos-be/internal/contract"
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
|
|
||||||
@@ -95,6 +96,49 @@ func TestPurchasingAnalyticsModelToContractCopiesOutletName(t *testing.T) {
|
|||||||
require.Equal(t, float64(175), result.Data[0].ExpensePurchases)
|
require.Equal(t, float64(175), result.Data[0].ExpensePurchases)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPurchasingAnalyticsModelToContractCopiesTeamData(t *testing.T) {
|
||||||
|
categoryID := uuid.New()
|
||||||
|
|
||||||
|
result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
Team: categoryID.String(),
|
||||||
|
Summary: models.PurchasingSummary{TotalPurchases: 300, TotalTeams: 2},
|
||||||
|
TeamData: []models.PurchasingTeamData{
|
||||||
|
{
|
||||||
|
Scope: constants.PurchaseTeamScopeCategory,
|
||||||
|
CategoryID: &categoryID,
|
||||||
|
Name: "Kitchen",
|
||||||
|
TotalPurchases: 200,
|
||||||
|
RawMaterialPurchases: 150,
|
||||||
|
ExpensePurchases: 50,
|
||||||
|
PurchaseOrderCount: 2,
|
||||||
|
Quantity: 12,
|
||||||
|
Percentage: 66.67,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Scope: constants.PurchaseTeamNone,
|
||||||
|
Name: constants.PurchaseTeamNoneName,
|
||||||
|
TotalPurchases: 100,
|
||||||
|
PurchaseOrderCount: 1,
|
||||||
|
Percentage: 33.33,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Equal(t, categoryID.String(), result.Team)
|
||||||
|
require.Equal(t, int64(2), result.Summary.TotalTeams)
|
||||||
|
require.Len(t, result.TeamData, 2)
|
||||||
|
require.Equal(t, constants.PurchaseTeamScopeCategory, result.TeamData[0].Scope)
|
||||||
|
require.Equal(t, &categoryID, result.TeamData[0].CategoryID)
|
||||||
|
require.Equal(t, "Kitchen", result.TeamData[0].Name)
|
||||||
|
require.Equal(t, float64(200), result.TeamData[0].TotalPurchases)
|
||||||
|
require.Equal(t, 66.67, result.TeamData[0].Percentage)
|
||||||
|
require.Equal(t, constants.PurchaseTeamNone, result.TeamData[1].Scope)
|
||||||
|
require.Nil(t, result.TeamData[1].CategoryID)
|
||||||
|
require.Equal(t, constants.PurchaseTeamNoneName, result.TeamData[1].Name)
|
||||||
|
}
|
||||||
|
|
||||||
func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) {
|
func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) {
|
||||||
result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{
|
result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{
|
||||||
OrganizationID: uuid.New(),
|
OrganizationID: uuid.New(),
|
||||||
@@ -183,7 +227,7 @@ func TestProfitLossAnalyticsModelToContractCopiesDateRange(t *testing.T) {
|
|||||||
require.Equal(t, "total_omset", result.MainSummary[0].ID)
|
require.Equal(t, "total_omset", result.MainSummary[0].ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExclusiveSummaryPeriodContractToModelParsesISODateRange(t *testing.T) {
|
func TestExclusiveSummaryPeriodContractToModelParsesFlexibleDates(t *testing.T) {
|
||||||
orgID := uuid.New()
|
orgID := uuid.New()
|
||||||
outletID := uuid.New().String()
|
outletID := uuid.New().String()
|
||||||
|
|
||||||
@@ -217,6 +261,7 @@ func TestExclusiveSummaryMonthlyContractToModelParsesMonth(t *testing.T) {
|
|||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, orgID, result.OrganizationID)
|
require.Equal(t, orgID, result.OrganizationID)
|
||||||
|
|
||||||
location, err := time.LoadLocation("Asia/Jakarta")
|
location, err := time.LoadLocation("Asia/Jakarta")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, time.Date(2026, 5, 1, 0, 0, 0, 0, location), result.Month)
|
require.Equal(t, time.Date(2026, 5, 1, 0, 0, 0, 0, location), result.Month)
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package transformer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/contract"
|
||||||
|
"apskel-pos-be/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CreateCashAdvanceRequestToModel(req *contract.CreateCashAdvanceRequest) (*models.CreateCashAdvanceRequest, error) {
|
||||||
|
issuedDate, err := time.Parse("2006-01-02", req.IssuedDate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var dueDate *time.Time
|
||||||
|
if req.DueDate != nil && *req.DueDate != "" {
|
||||||
|
parsed, err := time.Parse("2006-01-02", *req.DueDate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dueDate = &parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.CreateCashAdvanceRequest{
|
||||||
|
OutletID: req.OutletID,
|
||||||
|
CodeNumber: req.CodeNumber,
|
||||||
|
TeamScope: req.TeamScope,
|
||||||
|
TeamCategoryID: req.TeamCategoryID,
|
||||||
|
Amount: req.Amount,
|
||||||
|
IssuedDate: issuedDate,
|
||||||
|
DueDate: dueDate,
|
||||||
|
Status: req.Status,
|
||||||
|
Description: req.Description,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateCashAdvanceRequestToModel(req *contract.UpdateCashAdvanceRequest) (*models.UpdateCashAdvanceRequest, error) {
|
||||||
|
var issuedDate *time.Time
|
||||||
|
if req.IssuedDate != nil && *req.IssuedDate != "" {
|
||||||
|
parsed, err := time.Parse("2006-01-02", *req.IssuedDate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
issuedDate = &parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
var dueDate *time.Time
|
||||||
|
if req.DueDate != nil && *req.DueDate != "" {
|
||||||
|
parsed, err := time.Parse("2006-01-02", *req.DueDate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dueDate = &parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.UpdateCashAdvanceRequest{
|
||||||
|
CodeNumber: req.CodeNumber,
|
||||||
|
TeamScope: req.TeamScope,
|
||||||
|
TeamCategoryID: req.TeamCategoryID,
|
||||||
|
Amount: req.Amount,
|
||||||
|
ReturnedAmount: req.ReturnedAmount,
|
||||||
|
IssuedDate: issuedDate,
|
||||||
|
DueDate: dueDate,
|
||||||
|
Status: req.Status,
|
||||||
|
Description: req.Description,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListCashAdvancesRequestToModel(req *contract.ListCashAdvancesRequest) *models.ListCashAdvancesRequest {
|
||||||
|
return &models.ListCashAdvancesRequest{
|
||||||
|
Page: req.Page,
|
||||||
|
Limit: req.Limit,
|
||||||
|
Search: req.Search,
|
||||||
|
Status: req.Status,
|
||||||
|
SettlementStatus: req.SettlementStatus,
|
||||||
|
Team: req.Team,
|
||||||
|
TeamScope: req.TeamScope,
|
||||||
|
TeamCategoryID: req.TeamCategoryID,
|
||||||
|
StartDate: req.StartDate,
|
||||||
|
EndDate: req.EndDate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CashAdvanceModelResponseToResponse(cashAdvance *models.CashAdvanceResponse) *contract.CashAdvanceResponse {
|
||||||
|
if cashAdvance == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
response := &contract.CashAdvanceResponse{
|
||||||
|
ID: cashAdvance.ID,
|
||||||
|
OrganizationID: cashAdvance.OrganizationID,
|
||||||
|
OutletID: cashAdvance.OutletID,
|
||||||
|
CodeNumber: cashAdvance.CodeNumber,
|
||||||
|
TeamScope: cashAdvance.TeamScope,
|
||||||
|
TeamCategoryID: cashAdvance.TeamCategoryID,
|
||||||
|
Amount: cashAdvance.Amount,
|
||||||
|
SettledAmount: cashAdvance.SettledAmount,
|
||||||
|
ReturnedAmount: cashAdvance.ReturnedAmount,
|
||||||
|
RemainingAmount: cashAdvance.RemainingAmount,
|
||||||
|
SettlementStatus: cashAdvance.SettlementStatus,
|
||||||
|
IssuedDate: cashAdvance.IssuedDate,
|
||||||
|
DueDate: cashAdvance.DueDate,
|
||||||
|
Status: cashAdvance.Status,
|
||||||
|
Description: cashAdvance.Description,
|
||||||
|
CreatedAt: cashAdvance.CreatedAt,
|
||||||
|
UpdatedAt: cashAdvance.UpdatedAt,
|
||||||
|
Team: PurchaseTeamModelToResponse(cashAdvance.Team),
|
||||||
|
}
|
||||||
|
|
||||||
|
if cashAdvance.Settlements != nil {
|
||||||
|
response.Settlements = make([]contract.CashAdvanceSettlementResponse, len(cashAdvance.Settlements))
|
||||||
|
for i, settlement := range cashAdvance.Settlements {
|
||||||
|
response.Settlements[i] = contract.CashAdvanceSettlementResponse{
|
||||||
|
Type: settlement.Type,
|
||||||
|
ID: settlement.ID,
|
||||||
|
Number: settlement.Number,
|
||||||
|
Date: settlement.Date,
|
||||||
|
Amount: settlement.Amount,
|
||||||
|
Status: settlement.Status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ func CreateCategoryRequestToModel(apctx *appcontext.ContextInfo, req *contract.C
|
|||||||
return &models.CreateCategoryRequest{
|
return &models.CreateCategoryRequest{
|
||||||
OrganizationID: apctx.OrganizationID,
|
OrganizationID: apctx.OrganizationID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
|
ParentID: req.ParentID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
ImageURL: nil,
|
ImageURL: nil,
|
||||||
@@ -27,6 +28,7 @@ func UpdateCategoryRequestToModel(req *contract.UpdateCategoryRequest) *models.U
|
|||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
ImageURL: nil,
|
ImageURL: nil,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
|
ParentID: req.ParentID,
|
||||||
Order: req.Order,
|
Order: req.Order,
|
||||||
IsActive: nil,
|
IsActive: nil,
|
||||||
}
|
}
|
||||||
@@ -41,6 +43,8 @@ func CategoryModelResponseToResponse(cat *models.CategoryResponse) *contract.Cat
|
|||||||
ID: cat.ID,
|
ID: cat.ID,
|
||||||
OrganizationID: cat.OrganizationID,
|
OrganizationID: cat.OrganizationID,
|
||||||
OutletID: cat.OutletID,
|
OutletID: cat.OutletID,
|
||||||
|
ParentID: cat.ParentID,
|
||||||
|
ParentName: cat.ParentName,
|
||||||
Name: cat.Name,
|
Name: cat.Name,
|
||||||
Description: cat.Description,
|
Description: cat.Description,
|
||||||
BusinessType: "restaurant",
|
BusinessType: "restaurant",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ func CreateExpenseRequestToModel(req *contract.CreateExpenseRequest) *models.Cre
|
|||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
Tax: req.Tax,
|
Tax: req.Tax,
|
||||||
Total: req.Total,
|
Total: req.Total,
|
||||||
|
CashAdvanceID: req.CashAdvanceID,
|
||||||
Items: items,
|
Items: items,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,6 +47,7 @@ func UpdateExpenseRequestToModel(req *contract.UpdateExpenseRequest) *models.Upd
|
|||||||
Tax: req.Tax,
|
Tax: req.Tax,
|
||||||
Total: req.Total,
|
Total: req.Total,
|
||||||
Reserved1: req.Reserved1,
|
Reserved1: req.Reserved1,
|
||||||
|
CashAdvanceID: req.CashAdvanceID,
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Items != nil {
|
if req.Items != nil {
|
||||||
@@ -103,6 +105,7 @@ func ExpenseModelResponseToResponse(expense *models.ExpenseResponse) *contract.E
|
|||||||
Tax: expense.Tax,
|
Tax: expense.Tax,
|
||||||
Total: expense.Total,
|
Total: expense.Total,
|
||||||
Reserved1: expense.Reserved1,
|
Reserved1: expense.Reserved1,
|
||||||
|
CashAdvanceID: expense.CashAdvanceID,
|
||||||
CreatedAt: expense.CreatedAt,
|
CreatedAt: expense.CreatedAt,
|
||||||
UpdatedAt: expense.UpdatedAt,
|
UpdatedAt: expense.UpdatedAt,
|
||||||
Items: items,
|
Items: items,
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ func CreatePurchaseOrderRequestToModel(req *contract.CreatePurchaseOrderRequest)
|
|||||||
Reference: req.Reference,
|
Reference: req.Reference,
|
||||||
Status: req.Status,
|
Status: req.Status,
|
||||||
Message: req.Message,
|
Message: req.Message,
|
||||||
|
TeamScope: req.TeamScope,
|
||||||
|
TeamCategoryID: req.TeamCategoryID,
|
||||||
|
CashAdvanceID: req.CashAdvanceID,
|
||||||
Items: items,
|
Items: items,
|
||||||
AttachmentFileIDs: req.AttachmentFileIDs,
|
AttachmentFileIDs: req.AttachmentFileIDs,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -94,6 +97,9 @@ func UpdatePurchaseOrderRequestToModel(req *contract.UpdatePurchaseOrderRequest)
|
|||||||
Reference: req.Reference,
|
Reference: req.Reference,
|
||||||
Status: req.Status,
|
Status: req.Status,
|
||||||
Message: req.Message,
|
Message: req.Message,
|
||||||
|
TeamScope: req.TeamScope,
|
||||||
|
TeamCategoryID: req.TeamCategoryID,
|
||||||
|
CashAdvanceID: req.CashAdvanceID,
|
||||||
Items: items,
|
Items: items,
|
||||||
AttachmentFileIDs: req.AttachmentFileIDs,
|
AttachmentFileIDs: req.AttachmentFileIDs,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -101,16 +107,40 @@ func UpdatePurchaseOrderRequestToModel(req *contract.UpdatePurchaseOrderRequest)
|
|||||||
|
|
||||||
func ListPurchaseOrdersRequestToModel(req *contract.ListPurchaseOrdersRequest) *models.ListPurchaseOrdersRequest {
|
func ListPurchaseOrdersRequestToModel(req *contract.ListPurchaseOrdersRequest) *models.ListPurchaseOrdersRequest {
|
||||||
return &models.ListPurchaseOrdersRequest{
|
return &models.ListPurchaseOrdersRequest{
|
||||||
Page: req.Page,
|
Page: req.Page,
|
||||||
Limit: req.Limit,
|
Limit: req.Limit,
|
||||||
Search: req.Search,
|
Search: req.Search,
|
||||||
Status: req.Status,
|
Status: req.Status,
|
||||||
VendorID: req.VendorID,
|
VendorID: req.VendorID,
|
||||||
StartDate: req.StartDate,
|
Team: req.Team,
|
||||||
EndDate: req.EndDate,
|
TeamScope: req.TeamScope,
|
||||||
|
TeamCategoryID: req.TeamCategoryID,
|
||||||
|
StartDate: req.StartDate,
|
||||||
|
EndDate: req.EndDate,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func PurchaseTeamModelToResponse(team *models.PurchaseTeam) *contract.PurchaseTeamResponse {
|
||||||
|
if team == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &contract.PurchaseTeamResponse{
|
||||||
|
Scope: team.Scope,
|
||||||
|
CategoryID: team.CategoryID,
|
||||||
|
Name: team.Name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListPurchaseTeamsModelResponseToResponse(resp *models.ListPurchaseTeamsResponse) *contract.ListPurchaseTeamsResponse {
|
||||||
|
teams := make([]contract.PurchaseTeamResponse, len(resp.Teams))
|
||||||
|
for i, team := range resp.Teams {
|
||||||
|
teams[i] = *PurchaseTeamModelToResponse(&team)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &contract.ListPurchaseTeamsResponse{Teams: teams}
|
||||||
|
}
|
||||||
|
|
||||||
// Model to Contract conversions
|
// Model to Contract conversions
|
||||||
func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *contract.PurchaseOrderResponse {
|
func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *contract.PurchaseOrderResponse {
|
||||||
if po == nil {
|
if po == nil {
|
||||||
@@ -120,6 +150,7 @@ func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *con
|
|||||||
response := &contract.PurchaseOrderResponse{
|
response := &contract.PurchaseOrderResponse{
|
||||||
ID: po.ID,
|
ID: po.ID,
|
||||||
OrganizationID: po.OrganizationID,
|
OrganizationID: po.OrganizationID,
|
||||||
|
OutletID: po.OutletID,
|
||||||
VendorID: po.VendorID,
|
VendorID: po.VendorID,
|
||||||
PONumber: po.PONumber,
|
PONumber: po.PONumber,
|
||||||
TransactionDate: po.TransactionDate,
|
TransactionDate: po.TransactionDate,
|
||||||
@@ -128,8 +159,12 @@ func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *con
|
|||||||
Status: po.Status,
|
Status: po.Status,
|
||||||
Message: po.Message,
|
Message: po.Message,
|
||||||
TotalAmount: po.TotalAmount,
|
TotalAmount: po.TotalAmount,
|
||||||
|
TeamScope: po.TeamScope,
|
||||||
|
TeamCategoryID: po.TeamCategoryID,
|
||||||
|
CashAdvanceID: po.CashAdvanceID,
|
||||||
CreatedAt: po.CreatedAt,
|
CreatedAt: po.CreatedAt,
|
||||||
UpdatedAt: po.UpdatedAt,
|
UpdatedAt: po.UpdatedAt,
|
||||||
|
Team: PurchaseTeamModelToResponse(po.Team),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map vendor if present
|
// Map vendor if present
|
||||||
|
|||||||
@@ -12,15 +12,20 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) {
|
func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) {
|
||||||
|
vendorID := uuid.New()
|
||||||
|
ingredientID := uuid.New()
|
||||||
|
quantity := 1.0
|
||||||
|
unitID := uuid.New()
|
||||||
|
|
||||||
result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{
|
result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{
|
||||||
VendorID: uuid.New(),
|
VendorID: &vendorID,
|
||||||
PONumber: "PO-001",
|
PONumber: "PO-001",
|
||||||
TransactionDate: "2026-05-29",
|
TransactionDate: "2026-05-29",
|
||||||
Items: []contract.CreatePurchaseOrderItemRequest{
|
Items: []contract.CreatePurchaseOrderItemRequest{
|
||||||
{
|
{
|
||||||
IngredientID: uuid.New(),
|
IngredientID: &ingredientID,
|
||||||
Quantity: 1,
|
Quantity: &quantity,
|
||||||
UnitID: uuid.New(),
|
UnitID: &unitID,
|
||||||
Amount: 1000,
|
Amount: 1000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -31,9 +36,10 @@ func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPurchaseOrderModelResponseToResponseIncludesNullDueDate(t *testing.T) {
|
func TestPurchaseOrderModelResponseToResponseIncludesNullDueDate(t *testing.T) {
|
||||||
|
vendorID := uuid.New()
|
||||||
result := PurchaseOrderModelResponseToResponse(&models.PurchaseOrderResponse{
|
result := PurchaseOrderModelResponseToResponse(&models.PurchaseOrderResponse{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
VendorID: uuid.New(),
|
VendorID: &vendorID,
|
||||||
PONumber: "PO-001",
|
PONumber: "PO-001",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -41,3 +47,19 @@ func TestPurchaseOrderModelResponseToResponseIncludesNullDueDate(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Contains(t, string(payload), `"due_date":null`)
|
require.Contains(t, string(payload), `"due_date":null`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreatePurchaseOrderRequestToModelAllowsMissingVendor(t *testing.T) {
|
||||||
|
result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{
|
||||||
|
PONumber: "PO-001",
|
||||||
|
TransactionDate: "2026-05-29",
|
||||||
|
Items: []contract.CreatePurchaseOrderItemRequest{
|
||||||
|
{
|
||||||
|
PurchaseCategoryID: uuid.New(),
|
||||||
|
Amount: 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, result.VendorID)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package validator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
|
"apskel-pos-be/internal/contract"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CashAdvanceValidator interface {
|
||||||
|
ValidateCreateCashAdvanceRequest(req *contract.CreateCashAdvanceRequest) (error, string)
|
||||||
|
ValidateUpdateCashAdvanceRequest(req *contract.UpdateCashAdvanceRequest) (error, string)
|
||||||
|
ValidateListCashAdvancesRequest(req *contract.ListCashAdvancesRequest) (error, string)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CashAdvanceValidatorImpl struct{}
|
||||||
|
|
||||||
|
func NewCashAdvanceValidator() *CashAdvanceValidatorImpl {
|
||||||
|
return &CashAdvanceValidatorImpl{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *CashAdvanceValidatorImpl) ValidateCreateCashAdvanceRequest(req *contract.CreateCashAdvanceRequest) (error, string) {
|
||||||
|
if req == nil {
|
||||||
|
return errors.New("request body is required"), constants.MissingFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(req.CodeNumber) == "" {
|
||||||
|
return errors.New("code_number is required"), constants.MissingFieldErrorCode
|
||||||
|
}
|
||||||
|
if len(req.CodeNumber) > 50 {
|
||||||
|
return errors.New("code_number must be at most 50 characters"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.OutletID != nil && *req.OutletID == uuid.Nil {
|
||||||
|
return errors.New("outlet_id cannot be empty"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cash advance is cash handed to a team, so the team is not optional here the way
|
||||||
|
// it is on a purchase order: allowClear stays false and an empty scope is rejected.
|
||||||
|
if err, code := validatePurchaseTeamSelection(&req.TeamScope, req.TeamCategoryID, false); err != nil {
|
||||||
|
return err, code
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Amount <= 0 {
|
||||||
|
return errors.New("amount must be greater than 0"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
issuedDate, err := time.Parse("2006-01-02", strings.TrimSpace(req.IssuedDate))
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("issued_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DueDate != nil {
|
||||||
|
if strings.TrimSpace(*req.DueDate) == "" {
|
||||||
|
return errors.New("due_date cannot be empty"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
dueDate, err := time.Parse("2006-01-02", *req.DueDate)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("due_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
if dueDate.Before(issuedDate) {
|
||||||
|
return errors.New("due_date must be after issued_date"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Status != nil && !constants.IsValidCashAdvanceStatus(*req.Status) {
|
||||||
|
return errors.New("status must be one of: " + strings.Join(constants.GetAllCashAdvanceStatuses(), ", ")), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *CashAdvanceValidatorImpl) ValidateUpdateCashAdvanceRequest(req *contract.UpdateCashAdvanceRequest) (error, string) {
|
||||||
|
if req == nil {
|
||||||
|
return errors.New("request body is required"), constants.MissingFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.CodeNumber != nil {
|
||||||
|
if strings.TrimSpace(*req.CodeNumber) == "" {
|
||||||
|
return errors.New("code_number cannot be empty"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
if len(*req.CodeNumber) > 50 {
|
||||||
|
return errors.New("code_number must be at most 50 characters"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The team can be moved but never dropped, so clearing is not allowed here either.
|
||||||
|
if err, code := validatePurchaseTeamSelection(req.TeamScope, req.TeamCategoryID, false); err != nil {
|
||||||
|
return err, code
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Amount != nil && *req.Amount <= 0 {
|
||||||
|
return errors.New("amount must be greater than 0"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ReturnedAmount != nil && *req.ReturnedAmount < 0 {
|
||||||
|
return errors.New("returned_amount must be greater than or equal to 0"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
var issuedDate *time.Time
|
||||||
|
if req.IssuedDate != nil {
|
||||||
|
if strings.TrimSpace(*req.IssuedDate) == "" {
|
||||||
|
return errors.New("issued_date cannot be empty"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
parsed, err := time.Parse("2006-01-02", *req.IssuedDate)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("issued_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
issuedDate = &parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DueDate != nil {
|
||||||
|
if strings.TrimSpace(*req.DueDate) == "" {
|
||||||
|
return errors.New("due_date cannot be empty"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
dueDate, err := time.Parse("2006-01-02", *req.DueDate)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("due_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
if issuedDate != nil && dueDate.Before(*issuedDate) {
|
||||||
|
return errors.New("due_date must be after issued_date"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Status != nil && !constants.IsValidCashAdvanceStatus(*req.Status) {
|
||||||
|
return errors.New("status must be one of: " + strings.Join(constants.GetAllCashAdvanceStatuses(), ", ")), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *CashAdvanceValidatorImpl) ValidateListCashAdvancesRequest(req *contract.ListCashAdvancesRequest) (error, string) {
|
||||||
|
if req == nil {
|
||||||
|
return errors.New("request body is required"), constants.MissingFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Page < 1 {
|
||||||
|
return errors.New("page must be at least 1"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Limit < 1 || req.Limit > 100 {
|
||||||
|
return errors.New("limit must be between 1 and 100"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Status != "" && !constants.IsValidCashAdvanceStatus(req.Status) {
|
||||||
|
return errors.New("status must be one of: " + strings.Join(constants.GetAllCashAdvanceStatuses(), ", ")), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.SettlementStatus != "" && !constants.IsValidCashAdvanceSettlementStatus(req.SettlementStatus) {
|
||||||
|
return errors.New("settlement_status must be one of: " + strings.Join(constants.GetAllCashAdvanceSettlementStatuses(), ", ")), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Team != "" {
|
||||||
|
if req.TeamScope != "" || req.TeamCategoryID != nil {
|
||||||
|
return errors.New("team cannot be combined with team_scope or team_category_id"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every cash advance has a team, so unlike purchases there is nothing to filter
|
||||||
|
// for "no team yet": only Pusat or a category id make sense here.
|
||||||
|
if req.Team != constants.PurchaseTeamScopeCentral {
|
||||||
|
if categoryID, err := uuid.Parse(req.Team); err != nil || categoryID == uuid.Nil {
|
||||||
|
return errors.New("team must be either central or a category id"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.TeamScope != "" {
|
||||||
|
validScopes := []string{constants.PurchaseTeamScopeCategory, constants.PurchaseTeamScopeCentral}
|
||||||
|
if !contains(validScopes, req.TeamScope) {
|
||||||
|
return errors.New("team_scope must be one of: category, central"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.TeamScope == constants.PurchaseTeamScopeCentral && req.TeamCategoryID != nil {
|
||||||
|
return errors.New("team_category_id must be empty when team_scope is central"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.TeamCategoryID != nil && *req.TeamCategoryID == uuid.Nil {
|
||||||
|
return errors.New("team_category_id cannot be empty"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.StartDate != nil && req.EndDate != nil && req.EndDate.Before(*req.StartDate) {
|
||||||
|
return errors.New("end_date must be after start_date"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
@@ -59,7 +59,7 @@ func (v *CategoryValidatorImpl) ValidateUpdateCategoryRequest(req *contract.Upda
|
|||||||
}
|
}
|
||||||
|
|
||||||
// At least one field should be provided for update
|
// At least one field should be provided for update
|
||||||
if req.Name == nil && req.Description == nil && req.BusinessType == nil && req.Metadata == nil {
|
if req.Name == nil && req.Description == nil && req.BusinessType == nil && req.ParentID == nil && req.Metadata == nil {
|
||||||
return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode
|
return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,5 +118,9 @@ func (v *CategoryValidatorImpl) ValidateListCategoriesRequest(req *contract.List
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.Type != "" && req.Type != "parent" && req.Type != "child" {
|
||||||
|
return errors.New("type must be either 'parent' or 'child'"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
return nil, ""
|
return nil, ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ func (v *PurchaseOrderValidatorImpl) ValidateCreatePurchaseOrderRequest(req *con
|
|||||||
return errors.New("request body is required"), constants.MissingFieldErrorCode
|
return errors.New("request body is required"), constants.MissingFieldErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.VendorID == uuid.Nil {
|
if req.VendorID != nil && *req.VendorID == uuid.Nil {
|
||||||
return errors.New("vendor_id is required"), constants.MissingFieldErrorCode
|
return errors.New("vendor_id cannot be empty"), constants.MalformedFieldErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(req.PONumber) == "" {
|
if strings.TrimSpace(req.PONumber) == "" {
|
||||||
@@ -76,6 +76,10 @@ func (v *PurchaseOrderValidatorImpl) ValidateCreatePurchaseOrderRequest(req *con
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err, code := validatePurchaseTeamSelection(req.TeamScope, req.TeamCategoryID, false); err != nil {
|
||||||
|
return err, code
|
||||||
|
}
|
||||||
|
|
||||||
if len(req.Items) == 0 {
|
if len(req.Items) == 0 {
|
||||||
return errors.New("at least one item is required"), constants.MissingFieldErrorCode
|
return errors.New("at least one item is required"), constants.MissingFieldErrorCode
|
||||||
}
|
}
|
||||||
@@ -139,6 +143,10 @@ func (v *PurchaseOrderValidatorImpl) ValidateUpdatePurchaseOrderRequest(req *con
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err, code := validatePurchaseTeamSelection(req.TeamScope, req.TeamCategoryID, true); err != nil {
|
||||||
|
return err, code
|
||||||
|
}
|
||||||
|
|
||||||
// Validate items if provided
|
// Validate items if provided
|
||||||
if req.Items != nil {
|
if req.Items != nil {
|
||||||
for i, item := range req.Items {
|
for i, item := range req.Items {
|
||||||
@@ -151,6 +159,55 @@ func (v *PurchaseOrderValidatorImpl) ValidateUpdatePurchaseOrderRequest(req *con
|
|||||||
return nil, ""
|
return nil, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validatePurchaseTeamSelection keeps team_scope and team_category_id in step with
|
||||||
|
// the database check constraint: a category team needs a category, Pusat must not
|
||||||
|
// carry one. allowClear lets an update send an empty scope to drop the team.
|
||||||
|
func validatePurchaseTeamSelection(scope *string, categoryID *uuid.UUID, allowClear bool) (error, string) {
|
||||||
|
if scope == nil {
|
||||||
|
if categoryID != nil {
|
||||||
|
return errors.New("team_scope is required when team_category_id is provided"), constants.MissingFieldErrorCode
|
||||||
|
}
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.TrimSpace(*scope) {
|
||||||
|
case "":
|
||||||
|
if !allowClear {
|
||||||
|
return errors.New("team_scope must be one of: category, central"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
if categoryID != nil {
|
||||||
|
return errors.New("team_category_id must be empty when clearing the team"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
case constants.PurchaseTeamScopeCategory:
|
||||||
|
if categoryID == nil || *categoryID == uuid.Nil {
|
||||||
|
return errors.New("team_category_id is required when team_scope is category"), constants.MissingFieldErrorCode
|
||||||
|
}
|
||||||
|
case constants.PurchaseTeamScopeCentral:
|
||||||
|
if categoryID != nil {
|
||||||
|
return errors.New("team_category_id must be empty when team_scope is central"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return errors.New("team_scope must be one of: category, central"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// validatePurchaseTeamFilter accepts the values the team picker hands back: Pusat,
|
||||||
|
// no team at all, or the id of the parent category a purchase is charged to.
|
||||||
|
func validatePurchaseTeamFilter(team string) (error, string) {
|
||||||
|
switch team {
|
||||||
|
case constants.PurchaseTeamScopeCentral, constants.PurchaseTeamNone:
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if categoryID, err := uuid.Parse(team); err != nil || categoryID == uuid.Nil {
|
||||||
|
return errors.New("team must be one of: central, none, or a category id"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *contract.ListPurchaseOrdersRequest) (error, string) {
|
func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *contract.ListPurchaseOrdersRequest) (error, string) {
|
||||||
if req == nil {
|
if req == nil {
|
||||||
return errors.New("request body is required"), constants.MissingFieldErrorCode
|
return errors.New("request body is required"), constants.MissingFieldErrorCode
|
||||||
@@ -171,6 +228,31 @@ func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *cont
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.Team != "" {
|
||||||
|
if req.TeamScope != "" || req.TeamCategoryID != nil {
|
||||||
|
return errors.New("team cannot be combined with team_scope or team_category_id"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
if err, code := validatePurchaseTeamFilter(req.Team); err != nil {
|
||||||
|
return err, code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.TeamScope != "" {
|
||||||
|
validScopes := []string{constants.PurchaseTeamScopeCategory, constants.PurchaseTeamScopeCentral}
|
||||||
|
if !contains(validScopes, req.TeamScope) {
|
||||||
|
return errors.New("team_scope must be one of: category, central"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.TeamScope == constants.PurchaseTeamScopeCentral && req.TeamCategoryID != nil {
|
||||||
|
return errors.New("team_category_id must be empty when team_scope is central"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.TeamCategoryID != nil && *req.TeamCategoryID == uuid.Nil {
|
||||||
|
return errors.New("team_category_id cannot be empty"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
if req.StartDate != nil && req.EndDate != nil {
|
if req.StartDate != nil && req.EndDate != nil {
|
||||||
if req.EndDate.Before(*req.StartDate) {
|
if req.EndDate.Before(*req.StartDate) {
|
||||||
return errors.New("end_date must be after start_date"), constants.MalformedFieldErrorCode
|
return errors.New("end_date must be after start_date"), constants.MalformedFieldErrorCode
|
||||||
@@ -181,20 +263,20 @@ func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *cont
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *PurchaseOrderValidatorImpl) validatePurchaseOrderItem(item *contract.CreatePurchaseOrderItemRequest, index int) (error, string) {
|
func (v *PurchaseOrderValidatorImpl) validatePurchaseOrderItem(item *contract.CreatePurchaseOrderItemRequest, index int) (error, string) {
|
||||||
if item.IngredientID == uuid.Nil {
|
|
||||||
return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id is required"), constants.MissingFieldErrorCode
|
|
||||||
}
|
|
||||||
|
|
||||||
if item.PurchaseCategoryID == uuid.Nil {
|
if item.PurchaseCategoryID == uuid.Nil {
|
||||||
return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode
|
return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.Quantity <= 0 {
|
if item.IngredientID != nil && *item.IngredientID == uuid.Nil {
|
||||||
|
return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id cannot be empty"), constants.MalformedFieldErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.Quantity != nil && *item.Quantity <= 0 {
|
||||||
return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode
|
return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.UnitID == uuid.Nil {
|
if item.UnitID != nil && *item.UnitID == uuid.Nil {
|
||||||
return errors.New("items[" + strconv.Itoa(index) + "].unit_id is required"), constants.MissingFieldErrorCode
|
return errors.New("items[" + strconv.Itoa(index) + "].unit_id cannot be empty"), constants.MalformedFieldErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.Amount < 0 {
|
if item.Amount < 0 {
|
||||||
@@ -209,15 +291,15 @@ func (v *PurchaseOrderValidatorImpl) validateUpdatePurchaseOrderItem(item *contr
|
|||||||
return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode
|
return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.IngredientID == nil || *item.IngredientID == uuid.Nil {
|
if item.IngredientID != nil && *item.IngredientID == uuid.Nil {
|
||||||
return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id is required"), constants.MissingFieldErrorCode
|
return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id cannot be empty"), constants.MalformedFieldErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.UnitID == nil || *item.UnitID == uuid.Nil {
|
if item.UnitID != nil && *item.UnitID == uuid.Nil {
|
||||||
return errors.New("items[" + strconv.Itoa(index) + "].unit_id is required"), constants.MissingFieldErrorCode
|
return errors.New("items[" + strconv.Itoa(index) + "].unit_id cannot be empty"), constants.MalformedFieldErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.Quantity == nil || *item.Quantity <= 0 {
|
if item.Quantity != nil && *item.Quantity <= 0 {
|
||||||
return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode
|
return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,32 +11,49 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest {
|
func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest {
|
||||||
|
vendorID := uuid.New()
|
||||||
|
ingredientID := uuid.New()
|
||||||
|
quantity := 1.0
|
||||||
|
unitID := uuid.New()
|
||||||
|
|
||||||
return &contract.CreatePurchaseOrderRequest{
|
return &contract.CreatePurchaseOrderRequest{
|
||||||
VendorID: uuid.New(),
|
VendorID: &vendorID,
|
||||||
PONumber: "PO-001",
|
PONumber: "PO-001",
|
||||||
TransactionDate: "2026-05-29",
|
TransactionDate: "2026-05-29",
|
||||||
Items: []contract.CreatePurchaseOrderItemRequest{
|
Items: []contract.CreatePurchaseOrderItemRequest{
|
||||||
{
|
{
|
||||||
IngredientID: uuid.New(),
|
IngredientID: &ingredientID,
|
||||||
PurchaseCategoryID: uuid.New(),
|
PurchaseCategoryID: uuid.New(),
|
||||||
Quantity: 1,
|
Quantity: &quantity,
|
||||||
UnitID: uuid.New(),
|
UnitID: &unitID,
|
||||||
Amount: 1000,
|
Amount: 1000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPurchaseOrderValidatorCreateRejectsMissingRawMaterialFields(t *testing.T) {
|
func TestPurchaseOrderValidatorCreateAllowsMissingVendor(t *testing.T) {
|
||||||
validator := NewPurchaseOrderValidator()
|
validator := NewPurchaseOrderValidator()
|
||||||
req := validCreatePurchaseOrderRequest()
|
req := validCreatePurchaseOrderRequest()
|
||||||
req.Items[0].IngredientID = uuid.Nil
|
req.VendorID = nil
|
||||||
|
|
||||||
|
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurchaseOrderValidatorCreateRejectsEmptyVendor(t *testing.T) {
|
||||||
|
validator := NewPurchaseOrderValidator()
|
||||||
|
req := validCreatePurchaseOrderRequest()
|
||||||
|
vendorID := uuid.Nil
|
||||||
|
req.VendorID = &vendorID
|
||||||
|
|
||||||
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
||||||
|
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.Equal(t, constants.MissingFieldErrorCode, code)
|
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||||
require.Contains(t, err.Error(), "ingredient_id is required")
|
require.Contains(t, err.Error(), "vendor_id cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPurchaseOrderValidatorCreateAllowsMissingDueDate(t *testing.T) {
|
func TestPurchaseOrderValidatorCreateAllowsMissingDueDate(t *testing.T) {
|
||||||
@@ -74,30 +91,163 @@ func TestPurchaseOrderValidatorCreateRejectsDueDateBeforeTransactionDate(t *test
|
|||||||
require.Contains(t, err.Error(), "due_date must be after transaction_date")
|
require.Contains(t, err.Error(), "due_date must be after transaction_date")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPurchaseOrderValidatorUpdateItemsRequireFullReplacementFields(t *testing.T) {
|
func TestPurchaseOrderValidatorCreateAllowsCentralTeam(t *testing.T) {
|
||||||
validator := NewPurchaseOrderValidator()
|
validator := NewPurchaseOrderValidator()
|
||||||
req := &contract.UpdatePurchaseOrderRequest{
|
req := validCreatePurchaseOrderRequest()
|
||||||
Items: []contract.UpdatePurchaseOrderItemRequest{
|
scope := constants.PurchaseTeamScopeCentral
|
||||||
{
|
req.TeamScope = &scope
|
||||||
PurchaseCategoryID: ptrUUID(uuid.New()),
|
|
||||||
Quantity: ptrFloat64(1),
|
|
||||||
UnitID: ptrUUID(uuid.New()),
|
|
||||||
Amount: ptrFloat64(1000),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
err, code := validator.ValidateUpdatePurchaseOrderRequest(req)
|
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurchaseOrderValidatorCreateRejectsCentralTeamWithCategory(t *testing.T) {
|
||||||
|
validator := NewPurchaseOrderValidator()
|
||||||
|
req := validCreatePurchaseOrderRequest()
|
||||||
|
scope := constants.PurchaseTeamScopeCentral
|
||||||
|
categoryID := uuid.New()
|
||||||
|
req.TeamScope = &scope
|
||||||
|
req.TeamCategoryID = &categoryID
|
||||||
|
|
||||||
|
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||||
|
require.Contains(t, err.Error(), "team_category_id must be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurchaseOrderValidatorCreateRejectsCategoryTeamWithoutCategory(t *testing.T) {
|
||||||
|
validator := NewPurchaseOrderValidator()
|
||||||
|
req := validCreatePurchaseOrderRequest()
|
||||||
|
scope := constants.PurchaseTeamScopeCategory
|
||||||
|
req.TeamScope = &scope
|
||||||
|
|
||||||
|
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
||||||
|
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.Equal(t, constants.MissingFieldErrorCode, code)
|
require.Equal(t, constants.MissingFieldErrorCode, code)
|
||||||
require.Contains(t, err.Error(), "ingredient_id is required")
|
require.Contains(t, err.Error(), "team_category_id is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
func ptrUUID(id uuid.UUID) *uuid.UUID {
|
func TestPurchaseOrderValidatorCreateRejectsCategoryWithoutScope(t *testing.T) {
|
||||||
return &id
|
validator := NewPurchaseOrderValidator()
|
||||||
|
req := validCreatePurchaseOrderRequest()
|
||||||
|
categoryID := uuid.New()
|
||||||
|
req.TeamCategoryID = &categoryID
|
||||||
|
|
||||||
|
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, constants.MissingFieldErrorCode, code)
|
||||||
|
require.Contains(t, err.Error(), "team_scope is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
func ptrFloat64(value float64) *float64 {
|
func TestPurchaseOrderValidatorCreateRejectsUnknownTeamScope(t *testing.T) {
|
||||||
return &value
|
validator := NewPurchaseOrderValidator()
|
||||||
|
req := validCreatePurchaseOrderRequest()
|
||||||
|
scope := "outlet"
|
||||||
|
req.TeamScope = &scope
|
||||||
|
|
||||||
|
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||||
|
require.Contains(t, err.Error(), "team_scope must be one of")
|
||||||
|
}
|
||||||
|
|
||||||
|
// An update may clear the team with an empty scope; a create may not, because
|
||||||
|
// leaving the field out already means "no team".
|
||||||
|
func TestPurchaseOrderValidatorUpdateAllowsClearingTeam(t *testing.T) {
|
||||||
|
validator := NewPurchaseOrderValidator()
|
||||||
|
scope := ""
|
||||||
|
|
||||||
|
err, code := validator.ValidateUpdatePurchaseOrderRequest(&contract.UpdatePurchaseOrderRequest{TeamScope: &scope})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurchaseOrderValidatorCreateRejectsEmptyTeamScope(t *testing.T) {
|
||||||
|
validator := NewPurchaseOrderValidator()
|
||||||
|
req := validCreatePurchaseOrderRequest()
|
||||||
|
scope := ""
|
||||||
|
req.TeamScope = &scope
|
||||||
|
|
||||||
|
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurchaseOrderValidatorUpdateRejectsClearingTeamWithCategory(t *testing.T) {
|
||||||
|
validator := NewPurchaseOrderValidator()
|
||||||
|
scope := ""
|
||||||
|
categoryID := uuid.New()
|
||||||
|
|
||||||
|
err, code := validator.ValidateUpdatePurchaseOrderRequest(&contract.UpdatePurchaseOrderRequest{
|
||||||
|
TeamScope: &scope,
|
||||||
|
TeamCategoryID: &categoryID,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurchaseOrderValidatorListAcceptsTeamFilter(t *testing.T) {
|
||||||
|
validator := NewPurchaseOrderValidator()
|
||||||
|
|
||||||
|
for _, team := range []string{constants.PurchaseTeamScopeCentral, constants.PurchaseTeamNone, uuid.New().String()} {
|
||||||
|
err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{
|
||||||
|
Page: 1,
|
||||||
|
Limit: 10,
|
||||||
|
Team: team,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err, team)
|
||||||
|
require.Empty(t, code, team)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurchaseOrderValidatorListRejectsUnknownTeamFilter(t *testing.T) {
|
||||||
|
validator := NewPurchaseOrderValidator()
|
||||||
|
|
||||||
|
err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{
|
||||||
|
Page: 1,
|
||||||
|
Limit: 10,
|
||||||
|
Team: "marketing",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurchaseOrderValidatorListRejectsTeamWithScope(t *testing.T) {
|
||||||
|
validator := NewPurchaseOrderValidator()
|
||||||
|
|
||||||
|
err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{
|
||||||
|
Page: 1,
|
||||||
|
Limit: 10,
|
||||||
|
Team: constants.PurchaseTeamNone,
|
||||||
|
TeamScope: constants.PurchaseTeamScopeCentral,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurchaseOrderValidatorListRejectsCentralScopeWithCategory(t *testing.T) {
|
||||||
|
validator := NewPurchaseOrderValidator()
|
||||||
|
categoryID := uuid.New()
|
||||||
|
|
||||||
|
err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{
|
||||||
|
Page: 1,
|
||||||
|
Limit: 10,
|
||||||
|
TeamScope: constants.PurchaseTeamScopeCentral,
|
||||||
|
TeamCategoryID: &categoryID,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,10 +140,12 @@ func (v *UserValidatorImpl) ValidateUserID(userID uuid.UUID) (error, string) {
|
|||||||
|
|
||||||
func isValidUserRole(role string) bool {
|
func isValidUserRole(role string) bool {
|
||||||
validRoles := map[string]bool{
|
validRoles := map[string]bool{
|
||||||
string(constants.RoleAdmin): true,
|
string(constants.RoleAdmin): true,
|
||||||
string(constants.RoleManager): true,
|
string(constants.RoleManager): true,
|
||||||
string(constants.RoleCashier): true,
|
string(constants.RoleCashier): true,
|
||||||
string(constants.RoleWaiter): true,
|
string(constants.RoleWaiter): true,
|
||||||
|
string(constants.RoleOwner): true,
|
||||||
|
string(constants.RolePurchasing): true,
|
||||||
}
|
}
|
||||||
return validRoles[role]
|
return validRoles[role]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
DROP TRIGGER IF EXISTS trigger_validate_purchase_order_item_raw_material ON purchase_order_items;
|
|
||||||
DROP FUNCTION IF EXISTS validate_purchase_order_item_raw_material();
|
|
||||||
|
|
||||||
ALTER TABLE purchase_order_items
|
|
||||||
ALTER COLUMN purchase_category_id DROP NOT NULL,
|
|
||||||
ALTER COLUMN ingredient_id DROP NOT NULL,
|
|
||||||
ALTER COLUMN quantity DROP NOT NULL,
|
|
||||||
ALTER COLUMN unit_id DROP NOT NULL;
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
UPDATE purchase_order_items poi
|
|
||||||
SET purchase_category_id = pc.id
|
|
||||||
FROM purchase_orders po
|
|
||||||
JOIN purchase_categories pc ON pc.organization_id = po.organization_id
|
|
||||||
AND pc.code = 'bahan_baku'
|
|
||||||
AND pc.type = 'raw_material'
|
|
||||||
WHERE poi.purchase_order_id = po.id
|
|
||||||
AND poi.purchase_category_id IS NULL;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM purchase_order_items poi
|
|
||||||
LEFT JOIN purchase_categories pc ON pc.id = poi.purchase_category_id
|
|
||||||
WHERE poi.purchase_category_id IS NULL
|
|
||||||
OR pc.id IS NULL
|
|
||||||
OR pc.type <> 'raw_material'
|
|
||||||
OR poi.ingredient_id IS NULL
|
|
||||||
OR poi.quantity IS NULL
|
|
||||||
OR poi.unit_id IS NULL
|
|
||||||
) THEN
|
|
||||||
RAISE EXCEPTION 'purchase_order_items contains non-raw-material or incomplete raw-material rows. Move expense rows to expenses and fill ingredient_id, quantity, and unit_id before running this migration.';
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
ALTER TABLE purchase_order_items
|
|
||||||
ALTER COLUMN purchase_category_id SET NOT NULL,
|
|
||||||
ALTER COLUMN ingredient_id SET NOT NULL,
|
|
||||||
ALTER COLUMN quantity SET NOT NULL,
|
|
||||||
ALTER COLUMN unit_id SET NOT NULL;
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION validate_purchase_order_item_raw_material()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM purchase_categories pc
|
|
||||||
WHERE pc.id = NEW.purchase_category_id
|
|
||||||
AND pc.type = 'raw_material'
|
|
||||||
) THEN
|
|
||||||
RAISE EXCEPTION 'purchase_order_items.purchase_category_id must reference a raw_material purchase category';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
DROP TRIGGER IF EXISTS trigger_validate_purchase_order_item_raw_material ON purchase_order_items;
|
|
||||||
CREATE TRIGGER trigger_validate_purchase_order_item_raw_material
|
|
||||||
BEFORE INSERT OR UPDATE OF purchase_category_id ON purchase_order_items
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION validate_purchase_order_item_raw_material();
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE purchase_orders
|
||||||
|
ALTER COLUMN vendor_id SET NOT NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE purchase_orders
|
||||||
|
ALTER COLUMN vendor_id DROP NOT NULL;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_purchase_orders_outlet_id;
|
||||||
|
|
||||||
|
ALTER TABLE purchase_orders
|
||||||
|
DROP COLUMN IF EXISTS outlet_id;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
ALTER TABLE purchase_orders
|
||||||
|
ADD COLUMN IF NOT EXISTS outlet_id UUID REFERENCES outlets(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_orders_outlet_id
|
||||||
|
ON purchase_orders(outlet_id);
|
||||||
|
|
||||||
|
WITH movement_outlets AS (
|
||||||
|
SELECT
|
||||||
|
poi.purchase_order_id,
|
||||||
|
MIN(im.outlet_id::text)::uuid AS outlet_id
|
||||||
|
FROM inventory_movements im
|
||||||
|
JOIN purchase_order_items poi ON im.purchase_order_item_id = poi.id
|
||||||
|
WHERE im.outlet_id IS NOT NULL
|
||||||
|
AND im.purchase_order_item_id IS NOT NULL
|
||||||
|
GROUP BY poi.purchase_order_id
|
||||||
|
HAVING COUNT(DISTINCT im.outlet_id) = 1
|
||||||
|
)
|
||||||
|
UPDATE purchase_orders po
|
||||||
|
SET outlet_id = movement_outlets.outlet_id
|
||||||
|
FROM movement_outlets
|
||||||
|
WHERE po.id = movement_outlets.purchase_order_id
|
||||||
|
AND po.outlet_id IS NULL;
|
||||||
|
|
||||||
|
WITH candidate_item_outlets AS (
|
||||||
|
SELECT
|
||||||
|
poi.purchase_order_id,
|
||||||
|
i.outlet_id
|
||||||
|
FROM purchase_order_items poi
|
||||||
|
JOIN ingredients i ON poi.ingredient_id = i.id
|
||||||
|
WHERE i.outlet_id IS NOT NULL
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
poi.purchase_order_id,
|
||||||
|
u.outlet_id
|
||||||
|
FROM purchase_order_items poi
|
||||||
|
JOIN units u ON poi.unit_id = u.id
|
||||||
|
WHERE u.outlet_id IS NOT NULL
|
||||||
|
), item_outlets AS (
|
||||||
|
SELECT
|
||||||
|
purchase_order_id,
|
||||||
|
MIN(outlet_id::text)::uuid AS outlet_id
|
||||||
|
FROM candidate_item_outlets
|
||||||
|
GROUP BY purchase_order_id
|
||||||
|
HAVING COUNT(DISTINCT outlet_id) = 1
|
||||||
|
)
|
||||||
|
UPDATE purchase_orders po
|
||||||
|
SET outlet_id = item_outlets.outlet_id
|
||||||
|
FROM item_outlets
|
||||||
|
WHERE po.id = item_outlets.purchase_order_id
|
||||||
|
AND po.outlet_id IS NULL;
|
||||||
|
|
||||||
|
WITH single_outlet_organizations AS (
|
||||||
|
SELECT
|
||||||
|
organization_id,
|
||||||
|
MIN(id::text)::uuid AS outlet_id
|
||||||
|
FROM outlets
|
||||||
|
GROUP BY organization_id
|
||||||
|
HAVING COUNT(*) = 1
|
||||||
|
)
|
||||||
|
UPDATE purchase_orders po
|
||||||
|
SET outlet_id = single_outlet_organizations.outlet_id
|
||||||
|
FROM single_outlet_organizations
|
||||||
|
WHERE po.organization_id = single_outlet_organizations.organization_id
|
||||||
|
AND po.outlet_id IS NULL;
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
DROP TRIGGER IF EXISTS trigger_validate_expense_item_expense_category ON expense_items;
|
|
||||||
DROP FUNCTION IF EXISTS validate_expense_item_expense_category();
|
|
||||||
|
|
||||||
ALTER TABLE expense_items
|
|
||||||
ALTER COLUMN purchase_category_id DROP NOT NULL;
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
UPDATE expense_items ei
|
|
||||||
SET purchase_category_id = pc.id
|
|
||||||
FROM expenses e
|
|
||||||
JOIN purchase_categories pc ON pc.organization_id = e.organization_id
|
|
||||||
AND pc.code = 'biaya_lain_lain'
|
|
||||||
AND pc.type = 'expense'
|
|
||||||
WHERE ei.expense_id = e.id
|
|
||||||
AND (
|
|
||||||
ei.purchase_category_id IS NULL
|
|
||||||
OR NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM purchase_categories current_pc
|
|
||||||
WHERE current_pc.id = ei.purchase_category_id
|
|
||||||
AND current_pc.type = 'expense'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM expense_items ei
|
|
||||||
LEFT JOIN purchase_categories pc ON pc.id = ei.purchase_category_id
|
|
||||||
WHERE ei.purchase_category_id IS NULL
|
|
||||||
OR pc.id IS NULL
|
|
||||||
OR pc.type <> 'expense'
|
|
||||||
) THEN
|
|
||||||
RAISE EXCEPTION 'expense_items contains missing or non-expense purchase categories. Assign valid expense categories before running this migration.';
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
ALTER TABLE expense_items
|
|
||||||
ALTER COLUMN purchase_category_id SET NOT NULL;
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION validate_expense_item_expense_category()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM purchase_categories pc
|
|
||||||
WHERE pc.id = NEW.purchase_category_id
|
|
||||||
AND pc.type = 'expense'
|
|
||||||
) THEN
|
|
||||||
RAISE EXCEPTION 'expense_items.purchase_category_id must reference an expense purchase category';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
DROP TRIGGER IF EXISTS trigger_validate_expense_item_expense_category ON expense_items;
|
|
||||||
CREATE TRIGGER trigger_validate_expense_item_expense_category
|
|
||||||
BEFORE INSERT OR UPDATE OF purchase_category_id ON expense_items
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION validate_expense_item_expense_category();
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Revert to original roles
|
||||||
|
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
|
||||||
|
UPDATE users SET role = 'admin' WHERE role NOT IN ('admin', 'manager', 'cashier', 'waiter');
|
||||||
|
ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('admin', 'manager', 'cashier', 'waiter'));
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Add 'owner' and 'purchasing' roles to users table
|
||||||
|
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
|
||||||
|
UPDATE users SET role = 'admin' WHERE role NOT IN ('admin', 'manager', 'cashier', 'waiter', 'owner', 'purchasing');
|
||||||
|
ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('admin', 'manager', 'cashier', 'waiter', 'owner', 'purchasing'));
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE categories
|
||||||
|
DROP CONSTRAINT IF EXISTS categories_parent_id_fkey;
|
||||||
|
ALTER TABLE categories
|
||||||
|
DROP COLUMN IF EXISTS parent_id;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_categories_parent_id;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE categories
|
||||||
|
ADD COLUMN parent_id UUID REFERENCES categories(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX idx_categories_parent_id ON categories(parent_id);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user