Compare commits
1
Commits
dev
..
b42d141927
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b42d141927 |
@@ -15,19 +15,15 @@ Makefile requires installed dependecies:
|
||||
```shell
|
||||
$ make
|
||||
|
||||
Usage: make [command] [ENV=staging|production]
|
||||
Usage: make [command]
|
||||
|
||||
Commands:
|
||||
run Run server (default: staging)
|
||||
run ENV=production Run server with production config
|
||||
|
||||
rename-project name={name} Rename project
|
||||
|
||||
build-http Build http server
|
||||
|
||||
migration-create name={name} Create migration
|
||||
migration-up Up migrations
|
||||
migration-up ENV=production Up migrations (production DB)
|
||||
migration-down Down last migration
|
||||
|
||||
docker-up Up docker services
|
||||
@@ -40,16 +36,24 @@ Commands:
|
||||
|
||||
## HTTP Server
|
||||
|
||||
The server takes no CLI flags. It reads `ENV_MODE` and loads the matching YAML file from
|
||||
[infra/](infra/) — see [Running the Application](#running-the-application) for details.
|
||||
```shell
|
||||
$ ./bin/http-server --help
|
||||
|
||||
Usage: http-server
|
||||
|
||||
Flags:
|
||||
-h, --help Show mycontext-sensitive help.
|
||||
--env-path=STRING Path to env config file
|
||||
```
|
||||
|
||||
**Configuration** is based on the environment variables. See [.env.template](.env).
|
||||
|
||||
```shell
|
||||
# Build, then start with the staging config (default)
|
||||
$ go build -o ./bin/http-server ./cmd/server/main.go
|
||||
$ ENV_MODE=staging ./bin/http-server
|
||||
# Expose env vars before and start server
|
||||
$ ./bin/http-server
|
||||
|
||||
# Start with the production config
|
||||
$ ENV_MODE=production ./bin/http-server
|
||||
# Expose env vars from the file and start server
|
||||
$ ./bin/http-server --env-path ./config/env/.env
|
||||
```
|
||||
|
||||
## API Docs
|
||||
@@ -120,7 +124,7 @@ Handler → Service → Processor → Repository
|
||||
## API Endpoints
|
||||
|
||||
### Health Check
|
||||
- `GET /health` - Health check endpoint (registered at the root, not under `/api/v1`)
|
||||
- `GET /api/v1/health` - Health check endpoint
|
||||
|
||||
### Organizations
|
||||
- `POST /api/v1/organizations` - Create organization
|
||||
@@ -153,139 +157,73 @@ Handler → Service → Processor → Repository
|
||||
- `PUT /api/v1/order-items/{id}` - Update order item
|
||||
- `DELETE /api/v1/order-items/{id}` - Remove order item
|
||||
|
||||
## Running the Application
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd apskel-pos-backend
|
||||
```
|
||||
|
||||
| Tool | Version | Needed for |
|
||||
|------|---------|------------|
|
||||
| [Go](https://go.dev/doc/install) | 1.24+ | building & running the server |
|
||||
| [golang-migrate](https://github.com/golang-migrate/migrate) | latest | `make migration-*` targets |
|
||||
| [make](https://www.gnu.org/software/make/) | any | shortcut commands (Windows: use Git Bash / WSL, see note below) |
|
||||
| [docker & docker-compose](https://docs.docker.com/compose/) | optional | running Postgres/Redis locally |
|
||||
| [air](https://github.com/air-verse/air) | optional | hot reload during development (`.air.toml` is already configured) |
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
### 1. Clone & install dependencies
|
||||
3. **Set up database**
|
||||
```bash
|
||||
# Set your PostgreSQL database URL
|
||||
export DATABASE_URL="postgres://username:password@localhost:5432/apskel_pos?sslmode=disable"
|
||||
```
|
||||
|
||||
4. **Run migrations**
|
||||
```bash
|
||||
make migration-up
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd apskel-pos-backend
|
||||
go mod download
|
||||
# Start the server
|
||||
go run cmd/server/main.go -port 8080 -db-url "postgres://username:password@localhost:5432/apskel_pos?sslmode=disable"
|
||||
|
||||
# Or using environment variable
|
||||
export DATABASE_URL="postgres://username:password@localhost:5432/apskel_pos?sslmode=disable"
|
||||
go run cmd/server/main.go -port 8080
|
||||
```
|
||||
|
||||
### 2. Configuration
|
||||
|
||||
Configuration is **not** read from `.env` files — it is loaded from YAML files in [infra/](infra/)
|
||||
by [config/configs.go](config/configs.go) using viper.
|
||||
|
||||
The file is selected by the `ENV_MODE` environment variable:
|
||||
|
||||
| `ENV_MODE` | Config file loaded |
|
||||
|------------|--------------------|
|
||||
| `local` | `infra/local.yaml` |
|
||||
| `development` | `infra/development.yaml` |
|
||||
| `staging` *(default)* | `infra/staging.yaml` |
|
||||
| `production` | `infra/production.yaml` |
|
||||
|
||||
Any other/unset value falls back to `staging`. Only `staging.yaml` and `production.yaml` are
|
||||
committed — for `local`/`development` copy one of them first:
|
||||
### Using Make Commands
|
||||
|
||||
```bash
|
||||
cp infra/staging.yaml infra/local.yaml
|
||||
# Run the application
|
||||
make start
|
||||
|
||||
# Format code
|
||||
make fmt
|
||||
|
||||
# Run tests
|
||||
make test
|
||||
|
||||
# Build for production
|
||||
make build-http
|
||||
|
||||
# Docker operations
|
||||
make docker-up
|
||||
make docker-down
|
||||
|
||||
# Database migrations
|
||||
make migration-create name=create_users_table
|
||||
make migration-up
|
||||
make migration-down
|
||||
```
|
||||
|
||||
Two important notes:
|
||||
|
||||
* The config path is **relative to the working directory**, so always run the server from the
|
||||
repository root, otherwise viper panics with `failed to read config file`.
|
||||
* Push notifications need `infra/firebase-service-account.json` (git-ignored). Without it, obtain
|
||||
the file from the team before enabling FCM features.
|
||||
|
||||
### 3. Run migrations
|
||||
|
||||
The migration targets build the DB URL from the credentials at the top of the [Makefile](Makefile):
|
||||
|
||||
```bash
|
||||
make migration-up # staging DB (default)
|
||||
make migration-up ENV=production # production DB
|
||||
|
||||
make migration-create name=create_cash_advances_table
|
||||
make migration-down # roll back the last migration
|
||||
make migration-force version=87 # clear a dirty migration state
|
||||
```
|
||||
|
||||
### 4. Start the server
|
||||
|
||||
```bash
|
||||
make run # ENV_MODE=staging
|
||||
make run ENV=production # ENV_MODE=production
|
||||
```
|
||||
|
||||
`make run` is just a wrapper around:
|
||||
|
||||
```bash
|
||||
ENV_MODE=staging go run cmd/server/main.go
|
||||
```
|
||||
|
||||
The server listens on the `server.port` value from the loaded YAML (**4000** for both staging and
|
||||
production). Verify it is up:
|
||||
|
||||
```bash
|
||||
curl http://localhost:4000/health
|
||||
```
|
||||
|
||||
All application routes live under `/api/v1` (see [internal/router/router.go](internal/router/router.go)).
|
||||
|
||||
#### Windows note
|
||||
|
||||
The `run`/`start` targets use POSIX inline env-var syntax, which `cmd.exe` and PowerShell do not
|
||||
understand. Either run `make` from Git Bash / WSL, or start the server directly:
|
||||
|
||||
```powershell
|
||||
# PowerShell
|
||||
$env:ENV_MODE = "staging"; go run cmd/server/main.go
|
||||
```
|
||||
|
||||
```cmd
|
||||
:: cmd.exe
|
||||
set ENV_MODE=staging && go run cmd/server/main.go
|
||||
```
|
||||
|
||||
#### Hot reload
|
||||
|
||||
```bash
|
||||
ENV_MODE=local air # rebuilds ./tmp/main on every .go change
|
||||
```
|
||||
|
||||
### 5. Other commands
|
||||
|
||||
```bash
|
||||
make # show all available targets
|
||||
make fmt # go fmt ./...
|
||||
make test # go test ./... -v
|
||||
|
||||
# Build a binary (make build-http still points at the old ./cmd/http path)
|
||||
go build -o ./bin/http-server ./cmd/server/main.go
|
||||
```
|
||||
|
||||
### Running with Docker
|
||||
|
||||
`docker-compose.yaml` provides Postgres (`5432`), Redis (`6379`), and the API image. See
|
||||
[DOCKER.md](DOCKER.md) for the full workflow.
|
||||
|
||||
```bash
|
||||
make docker-up # docker-compose up -d
|
||||
make docker-down # docker-compose down
|
||||
```
|
||||
|
||||
If you use the containerised Postgres/Redis, point `infra/local.yaml` at `localhost:5432` /
|
||||
`localhost:6379` instead of the remote hosts baked into `staging.yaml`.
|
||||
|
||||
## Example API Usage
|
||||
|
||||
### Create Organization
|
||||
```bash
|
||||
curl -X POST http://localhost:4000/api/v1/organizations \
|
||||
curl -X POST http://localhost:8080/api/v1/organizations \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "My Restaurant",
|
||||
@@ -295,7 +233,7 @@ curl -X POST http://localhost:4000/api/v1/organizations \
|
||||
|
||||
### Create User
|
||||
```bash
|
||||
curl -X POST http://localhost:4000/api/v1/users \
|
||||
curl -X POST http://localhost:8080/api/v1/users \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"organization_id": "uuid-here",
|
||||
@@ -309,7 +247,7 @@ curl -X POST http://localhost:4000/api/v1/users \
|
||||
|
||||
### Create Order with Items
|
||||
```bash
|
||||
curl -X POST http://localhost:4000/api/v1/orders \
|
||||
curl -X POST http://localhost:8080/api/v1/orders \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"outlet_id": "uuid-here",
|
||||
|
||||
+2
-12
@@ -140,8 +140,6 @@ func (a *App) Initialize(cfg *config.Config) error {
|
||||
selfOrderHandler,
|
||||
services.expenseService,
|
||||
validators.expenseValidator,
|
||||
services.cashAdvanceService,
|
||||
validators.cashAdvanceValidator,
|
||||
a.redisClient,
|
||||
)
|
||||
|
||||
@@ -246,7 +244,6 @@ type repositories struct {
|
||||
notificationDeliveryRepo *repository.NotificationDeliveryRepositoryImpl
|
||||
productOutletPriceRepo *repository.ProductOutletPriceRepositoryImpl
|
||||
expenseRepo *repository.ExpenseRepositoryImpl
|
||||
cashAdvanceRepo *repository.CashAdvanceRepositoryImpl
|
||||
}
|
||||
|
||||
func (a *App) initRepositories() *repositories {
|
||||
@@ -301,7 +298,6 @@ func (a *App) initRepositories() *repositories {
|
||||
notificationDeliveryRepo: repository.NewNotificationDeliveryRepository(a.db),
|
||||
productOutletPriceRepo: repository.NewProductOutletPriceRepositoryImpl(a.db),
|
||||
expenseRepo: repository.NewExpenseRepositoryImpl(a.db),
|
||||
cashAdvanceRepo: repository.NewCashAdvanceRepositoryImpl(a.db),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,7 +345,6 @@ type processors struct {
|
||||
notificationProcessor *processor.NotificationProcessorImpl
|
||||
productOutletPriceProcessor processor.ProductOutletPriceProcessor
|
||||
expenseProcessor *processor.ExpenseProcessorImpl
|
||||
cashAdvanceProcessor *processor.CashAdvanceProcessorImpl
|
||||
}
|
||||
|
||||
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
|
||||
@@ -377,7 +372,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo),
|
||||
productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo),
|
||||
vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo),
|
||||
purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.purchaseCategoryRepo, repos.categoryRepo, repos.cashAdvanceRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo),
|
||||
purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.purchaseCategoryRepo, repos.categoryRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo),
|
||||
purchaseCategoryProcessor: processor.NewPurchaseCategoryProcessorImpl(repos.purchaseCategoryRepo),
|
||||
unitConverterProcessor: processor.NewIngredientUnitConverterProcessorImpl(repos.unitConverterRepo, repos.ingredientRepo, repos.unitRepo),
|
||||
chartOfAccountTypeProcessor: processor.NewChartOfAccountTypeProcessorImpl(repos.chartOfAccountTypeRepo),
|
||||
@@ -401,8 +396,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
userDeviceProcessor: processor.NewUserDeviceProcessorImpl(repos.userDeviceRepo),
|
||||
notificationProcessor: buildNotificationProcessor(cfg, repos),
|
||||
productOutletPriceProcessor: processor.NewProductOutletPriceProcessorImpl(repos.productOutletPriceRepo, repos.productRepo, repos.outletRepo),
|
||||
expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo, repos.cashAdvanceRepo),
|
||||
cashAdvanceProcessor: processor.NewCashAdvanceProcessorImpl(repos.cashAdvanceRepo, repos.categoryRepo),
|
||||
expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,7 +438,6 @@ type services struct {
|
||||
notificationService service.NotificationService
|
||||
productOutletPriceService service.ProductOutletPriceService
|
||||
expenseService *service.ExpenseServiceImpl
|
||||
cashAdvanceService *service.CashAdvanceServiceImpl
|
||||
}
|
||||
|
||||
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
|
||||
@@ -525,7 +518,6 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
||||
notificationService: notificationService,
|
||||
productOutletPriceService: service.NewProductOutletPriceService(processors.productOutletPriceProcessor),
|
||||
expenseService: service.NewExpenseService(processors.expenseProcessor),
|
||||
cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,7 +562,6 @@ type validators struct {
|
||||
notificationValidator *validator.NotificationValidatorImpl
|
||||
productOutletPriceValidator *validator.ProductOutletPriceValidatorImpl
|
||||
expenseValidator *validator.ExpenseValidatorImpl
|
||||
cashAdvanceValidator *validator.CashAdvanceValidatorImpl
|
||||
}
|
||||
|
||||
func (a *App) initValidators() *validators {
|
||||
@@ -603,7 +594,6 @@ func (a *App) initValidators() *validators {
|
||||
notificationValidator: validator.NewNotificationValidator(),
|
||||
productOutletPriceValidator: validator.NewProductOutletPriceValidator(),
|
||||
expenseValidator: validator.NewExpenseValidator(),
|
||||
cashAdvanceValidator: validator.NewCashAdvanceValidator(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
package constants
|
||||
|
||||
// A cash advance is money handed to a team so it can go shopping. Its status is
|
||||
// about the document only — whether the money may leave the drawer. How much of it
|
||||
// has been accounted for is a separate axis, derived from the spending charged to
|
||||
// the advance rather than stored, so the two never have to be kept in step.
|
||||
const (
|
||||
CashAdvanceStatusDraft = "draft"
|
||||
CashAdvanceStatusApproved = "approved"
|
||||
CashAdvanceStatusRejected = "rejected"
|
||||
CashAdvanceStatusCancelled = "cancelled"
|
||||
)
|
||||
|
||||
// Settlement states come out of the amount, the spending charged to the advance, and
|
||||
// the cash handed back. An advance the team overspent still counts as settled: the
|
||||
// shortfall is owed back to the team and shows up as a negative remaining amount.
|
||||
const (
|
||||
CashAdvanceSettlementOpen = "open"
|
||||
CashAdvanceSettlementPartial = "partial"
|
||||
CashAdvanceSettlementSettled = "settled"
|
||||
)
|
||||
|
||||
func GetAllCashAdvanceStatuses() []string {
|
||||
return []string{
|
||||
CashAdvanceStatusDraft,
|
||||
CashAdvanceStatusApproved,
|
||||
CashAdvanceStatusRejected,
|
||||
CashAdvanceStatusCancelled,
|
||||
}
|
||||
}
|
||||
|
||||
func IsValidCashAdvanceStatus(status string) bool {
|
||||
for _, valid := range GetAllCashAdvanceStatuses() {
|
||||
if status == valid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func GetAllCashAdvanceSettlementStatuses() []string {
|
||||
return []string{
|
||||
CashAdvanceSettlementOpen,
|
||||
CashAdvanceSettlementPartial,
|
||||
CashAdvanceSettlementSettled,
|
||||
}
|
||||
}
|
||||
|
||||
func IsValidCashAdvanceSettlementStatus(status string) bool {
|
||||
for _, valid := range GetAllCashAdvanceSettlementStatuses() {
|
||||
if status == valid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Settlement entries name where a piece of spending was recorded.
|
||||
const (
|
||||
CashAdvanceSettlementTypePurchaseOrder = "purchase_order"
|
||||
CashAdvanceSettlementTypeExpense = "expense"
|
||||
)
|
||||
@@ -62,7 +62,6 @@ const (
|
||||
NotificationHandlerEntity = "notification_handler"
|
||||
ProductOutletPriceServiceEntity = "product_outlet_price_service"
|
||||
ExpenseServiceEntity = "expense_service"
|
||||
CashAdvanceServiceEntity = "cash_advance_service"
|
||||
)
|
||||
|
||||
var HttpErrorMap = map[string]int{
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CreateCashAdvanceRequest struct {
|
||||
// OutletID falls back to the caller's outlet when omitted; an advance is cash out
|
||||
// of one drawer, so one of the two has to be known.
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty" validate:"omitempty"`
|
||||
CodeNumber string `json:"code_number" validate:"required,min=1,max=50"`
|
||||
TeamScope string `json:"team_scope" validate:"required,oneof=category central"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"`
|
||||
Amount float64 `json:"amount" validate:"required,gt=0"`
|
||||
IssuedDate string `json:"issued_date" validate:"required"` // Format: YYYY-MM-DD
|
||||
DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft approved rejected cancelled"`
|
||||
Description *string `json:"description,omitempty" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type UpdateCashAdvanceRequest struct {
|
||||
CodeNumber *string `json:"code_number,omitempty" validate:"omitempty,min=1,max=50"`
|
||||
// An advance always belongs to a team, so team_scope can be moved but not cleared.
|
||||
TeamScope *string `json:"team_scope,omitempty" validate:"omitempty,oneof=category central"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"`
|
||||
Amount *float64 `json:"amount,omitempty" validate:"omitempty,gt=0"`
|
||||
ReturnedAmount *float64 `json:"returned_amount,omitempty" validate:"omitempty,gte=0"`
|
||||
IssuedDate *string `json:"issued_date,omitempty" validate:"omitempty"`
|
||||
DueDate *string `json:"due_date,omitempty" validate:"omitempty"`
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft approved rejected cancelled"`
|
||||
Description *string `json:"description,omitempty" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type CashAdvanceResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID uuid.UUID `json:"outlet_id"`
|
||||
CodeNumber string `json:"code_number"`
|
||||
TeamScope string `json:"team_scope"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
SettledAmount float64 `json:"settled_amount"`
|
||||
ReturnedAmount float64 `json:"returned_amount"`
|
||||
RemainingAmount float64 `json:"remaining_amount"`
|
||||
SettlementStatus string `json:"settlement_status"`
|
||||
IssuedDate time.Time `json:"issued_date"`
|
||||
DueDate *time.Time `json:"due_date"`
|
||||
Status string `json:"status"`
|
||||
Description *string `json:"description"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Team *PurchaseTeamResponse `json:"team,omitempty"`
|
||||
Settlements []CashAdvanceSettlementResponse `json:"settlements,omitempty"`
|
||||
}
|
||||
|
||||
// CashAdvanceSettlementResponse is one purchase order or expense paid out of the
|
||||
// advance.
|
||||
type CashAdvanceSettlementResponse struct {
|
||||
Type string `json:"type"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
Number string `json:"number"`
|
||||
Date time.Time `json:"date"`
|
||||
Amount float64 `json:"amount"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type ListCashAdvancesRequest struct {
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
Search string `json:"search,omitempty"`
|
||||
Status string `json:"status,omitempty" validate:"omitempty,oneof=draft approved rejected cancelled"`
|
||||
// SettlementStatus filters on how much of the cash has been accounted for,
|
||||
// which is derived from the spending charged to the advance rather than stored.
|
||||
SettlementStatus string `json:"settlement_status,omitempty" validate:"omitempty,oneof=open partial settled"`
|
||||
// Team is the single-value form of the two filters below, so the team picker can
|
||||
// send back what it was given: a parent category id or "central" for Pusat.
|
||||
Team string `json:"team,omitempty"`
|
||||
TeamScope string `json:"team_scope,omitempty" validate:"omitempty,oneof=category central"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"`
|
||||
StartDate *time.Time `json:"start_date,omitempty"`
|
||||
EndDate *time.Time `json:"end_date,omitempty"`
|
||||
}
|
||||
|
||||
type ListCashAdvancesResponse struct {
|
||||
CashAdvances []CashAdvanceResponse `json:"cash_advances"`
|
||||
TotalCount int `json:"total_count"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
@@ -7,18 +7,15 @@ import (
|
||||
)
|
||||
|
||||
type CreateExpenseRequest struct {
|
||||
Receiver string `json:"receiver" validate:"required"`
|
||||
TransactionDate string `json:"transaction_date" validate:"required"`
|
||||
CodeNumber string `json:"code_number" validate:"required"`
|
||||
OutletID string `json:"outlet_id" validate:"required"`
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Tax float64 `json:"tax"`
|
||||
Total float64 `json:"total" validate:"required"`
|
||||
// CashAdvanceID marks the expense as paid out of cash advanced to a team, which is
|
||||
// what accounts for that advance.
|
||||
CashAdvanceID *string `json:"cash_advance_id,omitempty"`
|
||||
Items []CreateExpenseItemRequest `json:"items" validate:"required"`
|
||||
Receiver string `json:"receiver" validate:"required"`
|
||||
TransactionDate string `json:"transaction_date" validate:"required"`
|
||||
CodeNumber string `json:"code_number" validate:"required"`
|
||||
OutletID string `json:"outlet_id" validate:"required"`
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Tax float64 `json:"tax"`
|
||||
Total float64 `json:"total" validate:"required"`
|
||||
Items []CreateExpenseItemRequest `json:"items" validate:"required"`
|
||||
}
|
||||
|
||||
type CreateExpenseItemRequest struct {
|
||||
@@ -30,18 +27,16 @@ type CreateExpenseItemRequest struct {
|
||||
}
|
||||
|
||||
type UpdateExpenseRequest struct {
|
||||
Receiver *string `json:"receiver,omitempty"`
|
||||
TransactionDate *string `json:"transaction_date,omitempty"`
|
||||
CodeNumber *string `json:"code_number,omitempty"`
|
||||
OutletID *string `json:"outlet_id,omitempty"`
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Tax *float64 `json:"tax,omitempty"`
|
||||
Total *float64 `json:"total,omitempty"`
|
||||
Reserved1 *string `json:"reserved1,omitempty"`
|
||||
// An empty string unlinks the cash advance; omitting the field leaves it untouched.
|
||||
CashAdvanceID *string `json:"cash_advance_id,omitempty"`
|
||||
Items []UpdateExpenseItemRequest `json:"items,omitempty"`
|
||||
Receiver *string `json:"receiver,omitempty"`
|
||||
TransactionDate *string `json:"transaction_date,omitempty"`
|
||||
CodeNumber *string `json:"code_number,omitempty"`
|
||||
OutletID *string `json:"outlet_id,omitempty"`
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Tax *float64 `json:"tax,omitempty"`
|
||||
Total *float64 `json:"total,omitempty"`
|
||||
Reserved1 *string `json:"reserved1,omitempty"`
|
||||
Items []UpdateExpenseItemRequest `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateExpenseItemRequest struct {
|
||||
@@ -64,7 +59,6 @@ type ExpenseResponse struct {
|
||||
Tax float64 `json:"tax"`
|
||||
Total float64 `json:"total"`
|
||||
Reserved1 *string `json:"reserved1,omitempty"`
|
||||
CashAdvanceID *uuid.UUID `json:"cash_advance_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Items []ExpenseItemResponse `json:"items,omitempty"`
|
||||
|
||||
@@ -7,18 +7,15 @@ import (
|
||||
)
|
||||
|
||||
type CreatePurchaseOrderRequest struct {
|
||||
VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"`
|
||||
PONumber string `json:"po_number" validate:"required,min=1,max=50"`
|
||||
TransactionDate string `json:"transaction_date" validate:"required"` // Format: YYYY-MM-DD
|
||||
DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
|
||||
Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"`
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||
Message *string `json:"message,omitempty" validate:"omitempty"`
|
||||
TeamScope *string `json:"team_scope,omitempty" validate:"omitempty,oneof=category central"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"`
|
||||
// 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"`
|
||||
VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"`
|
||||
PONumber string `json:"po_number" validate:"required,min=1,max=50"`
|
||||
TransactionDate string `json:"transaction_date" validate:"required"` // Format: YYYY-MM-DD
|
||||
DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
|
||||
Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"`
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||
Message *string `json:"message,omitempty" validate:"omitempty"`
|
||||
TeamScope *string `json:"team_scope,omitempty" validate:"omitempty,oneof=category central"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"`
|
||||
Items []CreatePurchaseOrderItemRequest `json:"items" validate:"required,min=1,dive"`
|
||||
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
||||
}
|
||||
@@ -41,10 +38,8 @@ type UpdatePurchaseOrderRequest struct {
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||
Message *string `json:"message,omitempty" validate:"omitempty"`
|
||||
// An empty string clears the team; omitting the field leaves it untouched.
|
||||
TeamScope *string `json:"team_scope,omitempty" validate:"omitempty"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"`
|
||||
// An all-zero uuid unlinks the cash advance; omitting the field leaves it untouched.
|
||||
CashAdvanceID *uuid.UUID `json:"cash_advance_id,omitempty" validate:"omitempty"`
|
||||
TeamScope *string `json:"team_scope,omitempty" validate:"omitempty"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"`
|
||||
Items []UpdatePurchaseOrderItemRequest `json:"items,omitempty" validate:"omitempty,dive"`
|
||||
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
||||
}
|
||||
@@ -73,7 +68,6 @@ type PurchaseOrderResponse struct {
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
TeamScope *string `json:"team_scope"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id"`
|
||||
CashAdvanceID *uuid.UUID `json:"cash_advance_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Team *PurchaseTeamResponse `json:"team,omitempty"`
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CashAdvance is money handed to a team up front so it can go shopping — kasbon in
|
||||
// the Indonesian UI. While the cash is out it is still the outlet's, not a cost, so
|
||||
// nothing about what the team bought lives here: that is read back from the purchase
|
||||
// orders and expenses charged to the advance.
|
||||
type CashAdvance struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"`
|
||||
OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id"`
|
||||
CodeNumber string `gorm:"not null;size:50" json:"code_number"`
|
||||
// An advance is always handed to a team, so unlike a purchase order it has no
|
||||
// "not chosen yet" state. TeamCategoryID is set only when the scope is category.
|
||||
TeamScope string `gorm:"not null;size:20;index" json:"team_scope"`
|
||||
TeamCategoryID *uuid.UUID `gorm:"type:uuid;index" json:"team_category_id"`
|
||||
Amount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"amount"`
|
||||
ReturnedAmount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"returned_amount"`
|
||||
IssuedDate time.Time `gorm:"type:date;not null" json:"issued_date"`
|
||||
DueDate *time.Time `gorm:"type:date" json:"due_date"`
|
||||
Status string `gorm:"not null;size:20;default:'draft'" json:"status"`
|
||||
Description *string `gorm:"type:text" json:"description"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
// SettledAmount is filled in by the read queries from the spending charged to
|
||||
// this advance. It has no column of its own, so it can never drift out of step
|
||||
// with the purchases behind it; the arrow tag keeps writes from touching it.
|
||||
SettledAmount float64 `gorm:"->;-:migration" json:"settled_amount"`
|
||||
|
||||
Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
|
||||
TeamCategory *Category `gorm:"foreignKey:TeamCategoryID" json:"team_category,omitempty"`
|
||||
}
|
||||
|
||||
func (k *CashAdvance) BeforeCreate(tx *gorm.DB) error {
|
||||
if k.ID == uuid.Nil {
|
||||
k.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (CashAdvance) TableName() string {
|
||||
return "cash_advances"
|
||||
}
|
||||
|
||||
// CashAdvanceSettlement is one piece of spending charged to an advance. It is read
|
||||
// out of purchase_orders and expenses, so it has no table of its own.
|
||||
type CashAdvanceSettlement struct {
|
||||
Type string `json:"type"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
Number string `json:"number"`
|
||||
Date time.Time `json:"date"`
|
||||
Amount float64 `json:"amount"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
@@ -43,7 +43,6 @@ func GetAllEntities() []interface{} {
|
||||
&NotificationDelivery{},
|
||||
&ProductOutletPrice{},
|
||||
&Expense{},
|
||||
&CashAdvance{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,14 +20,11 @@ type Expense struct {
|
||||
Tax float64 `gorm:"type:decimal(15,2);not null;default:0" json:"tax"`
|
||||
Total float64 `gorm:"type:decimal(15,2);not null;default:0" json:"total"`
|
||||
Reserved1 *string `gorm:"type:text" json:"reserved1"`
|
||||
// CashAdvanceID is set when the expense was paid out of cash advanced to a team.
|
||||
CashAdvanceID *uuid.UUID `gorm:"type:uuid;index" json:"cash_advance_id"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
|
||||
CashAdvance *CashAdvance `gorm:"foreignKey:CashAdvanceID" json:"cash_advance,omitempty"`
|
||||
Items []ExpenseItem `gorm:"foreignKey:ExpenseID" json:"items,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -24,17 +24,13 @@ type PurchaseOrder struct {
|
||||
// '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"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
|
||||
Vendor *Vendor `gorm:"foreignKey:VendorID" json:"vendor,omitempty"`
|
||||
TeamCategory *Category `gorm:"foreignKey:TeamCategoryID" json:"team_category,omitempty"`
|
||||
CashAdvance *CashAdvance `gorm:"foreignKey:CashAdvanceID" json:"cash_advance,omitempty"`
|
||||
Items []PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderID" json:"items,omitempty"`
|
||||
Attachments []PurchaseOrderAttachment `gorm:"foreignKey:PurchaseOrderID" json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
"apskel-pos-be/internal/validator"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CashAdvanceHandler struct {
|
||||
cashAdvanceService service.CashAdvanceService
|
||||
cashAdvanceValidator validator.CashAdvanceValidator
|
||||
}
|
||||
|
||||
func NewCashAdvanceHandler(cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator) *CashAdvanceHandler {
|
||||
return &CashAdvanceHandler{
|
||||
cashAdvanceService: cashAdvanceService,
|
||||
cashAdvanceValidator: cashAdvanceValidator,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *CashAdvanceHandler) CreateCashAdvance(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
var req contract.CreateCashAdvanceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::CreateCashAdvance -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::CreateCashAdvance")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.cashAdvanceValidator.ValidateCreateCashAdvanceRequest(&req)
|
||||
if validationError != nil {
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::CreateCashAdvance")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.cashAdvanceService.CreateCashAdvance(ctx, contextInfo, &req)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::CreateCashAdvance -> Failed to create cash advance from service")
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::CreateCashAdvance")
|
||||
}
|
||||
|
||||
func (h *CashAdvanceHandler) UpdateCashAdvance(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
cashAdvanceID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::UpdateCashAdvance -> Invalid cash advance ID")
|
||||
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid cash advance ID")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::UpdateCashAdvance")
|
||||
return
|
||||
}
|
||||
|
||||
var req contract.UpdateCashAdvanceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::UpdateCashAdvance -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "Invalid request body")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::UpdateCashAdvance")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.cashAdvanceValidator.ValidateUpdateCashAdvanceRequest(&req)
|
||||
if validationError != nil {
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::UpdateCashAdvance")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.cashAdvanceService.UpdateCashAdvance(ctx, contextInfo, cashAdvanceID, &req)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::UpdateCashAdvance -> Failed to update cash advance from service")
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::UpdateCashAdvance")
|
||||
}
|
||||
|
||||
func (h *CashAdvanceHandler) DeleteCashAdvance(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
cashAdvanceID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::DeleteCashAdvance -> Invalid cash advance ID")
|
||||
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid cash advance ID")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::DeleteCashAdvance")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.cashAdvanceService.DeleteCashAdvance(ctx, contextInfo, cashAdvanceID)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::DeleteCashAdvance -> Failed to delete cash advance from service")
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::DeleteCashAdvance")
|
||||
}
|
||||
|
||||
func (h *CashAdvanceHandler) GetCashAdvance(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
cashAdvanceID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::GetCashAdvance -> Invalid cash advance ID")
|
||||
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid cash advance ID")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::GetCashAdvance")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.cashAdvanceService.GetCashAdvanceByID(ctx, contextInfo, cashAdvanceID)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::GetCashAdvance -> Failed to get cash advance from service")
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::GetCashAdvance")
|
||||
}
|
||||
|
||||
func (h *CashAdvanceHandler) ListCashAdvances(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
req := &contract.ListCashAdvancesRequest{
|
||||
Page: 1,
|
||||
Limit: 10,
|
||||
}
|
||||
|
||||
if pageStr := c.Query("page"); pageStr != "" {
|
||||
if page, err := strconv.Atoi(pageStr); err == nil {
|
||||
req.Page = page
|
||||
}
|
||||
}
|
||||
|
||||
if limitStr := c.Query("limit"); limitStr != "" {
|
||||
if limit, err := strconv.Atoi(limitStr); err == nil {
|
||||
req.Limit = limit
|
||||
}
|
||||
}
|
||||
|
||||
req.Search = c.Query("search")
|
||||
req.Status = c.Query("status")
|
||||
req.SettlementStatus = c.Query("settlement_status")
|
||||
req.Team = c.Query("team")
|
||||
req.TeamScope = c.Query("team_scope")
|
||||
|
||||
if teamCategoryIDStr := c.Query("team_category_id"); teamCategoryIDStr != "" {
|
||||
if teamCategoryID, err := uuid.Parse(teamCategoryIDStr); err == nil {
|
||||
req.TeamCategoryID = &teamCategoryID
|
||||
}
|
||||
}
|
||||
|
||||
if startDateStr := c.Query("start_date"); startDateStr != "" {
|
||||
if startDate, err := time.Parse("2006-01-02", startDateStr); err == nil {
|
||||
req.StartDate = &startDate
|
||||
}
|
||||
}
|
||||
|
||||
if endDateStr := c.Query("end_date"); endDateStr != "" {
|
||||
if endDate, err := time.Parse("2006-01-02", endDateStr); err == nil {
|
||||
req.EndDate = &endDate
|
||||
}
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.cashAdvanceValidator.ValidateListCashAdvancesRequest(req)
|
||||
if validationError != nil {
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::ListCashAdvances")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.cashAdvanceService.ListCashAdvances(ctx, contextInfo, req)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::ListCashAdvances -> Failed to list cash advances from service")
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::ListCashAdvances")
|
||||
}
|
||||
|
||||
func (h *CashAdvanceHandler) UpdateCashAdvanceStatus(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
cashAdvanceID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::UpdateCashAdvanceStatus -> Invalid cash advance ID")
|
||||
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid cash advance ID")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::UpdateCashAdvanceStatus")
|
||||
return
|
||||
}
|
||||
|
||||
status := c.Param("status")
|
||||
if status == "" {
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "Status parameter is required")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::UpdateCashAdvanceStatus")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.cashAdvanceService.UpdateCashAdvanceStatus(ctx, contextInfo, cashAdvanceID, status)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::UpdateCashAdvanceStatus -> Failed to update cash advance status from service")
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::UpdateCashAdvanceStatus")
|
||||
}
|
||||
|
||||
// ListCashAdvanceTeams serves the team picker for the advance form: the parent
|
||||
// categories of the caller's outlet, plus Pusat. Same list the purchase form uses.
|
||||
func (h *CashAdvanceHandler) ListCashAdvanceTeams(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
response := h.cashAdvanceService.ListCashAdvanceTeams(ctx, contextInfo)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::ListCashAdvanceTeams -> Failed to list cash advance teams from service")
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::ListCashAdvanceTeams")
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
// Amounts are money rounded to two decimals, so anything under half a cent apart
|
||||
// is the same figure. Comparing them directly would leave an advance a hundredth of
|
||||
// a rupiah short of settled.
|
||||
const cashAdvanceAmountEpsilon = 0.005
|
||||
|
||||
// cashAdvanceTeamFromEntity renders the team an advance was handed to. Unlike a
|
||||
// purchase order there is always one, so this never returns nil. The category name
|
||||
// is only filled in when TeamCategory was preloaded.
|
||||
func cashAdvanceTeamFromEntity(entity *entities.CashAdvance) *models.PurchaseTeam {
|
||||
team := &models.PurchaseTeam{Scope: entity.TeamScope}
|
||||
|
||||
switch entity.TeamScope {
|
||||
case constants.PurchaseTeamScopeCentral:
|
||||
team.Name = constants.PurchaseTeamCentralName
|
||||
case constants.PurchaseTeamScopeCategory:
|
||||
team.CategoryID = entity.TeamCategoryID
|
||||
if entity.TeamCategory != nil {
|
||||
team.Name = entity.TeamCategory.Name
|
||||
}
|
||||
}
|
||||
|
||||
return team
|
||||
}
|
||||
|
||||
// cashAdvanceSettlementStatus reads the money rather than any stored flag: cash is
|
||||
// accounted for by spending charged to the advance plus what was handed back. A
|
||||
// team that overspent still counts as settled — the excess is owed back to them.
|
||||
func cashAdvanceSettlementStatus(amount, settled, returned float64) string {
|
||||
accounted := settled + returned
|
||||
|
||||
switch {
|
||||
case accounted <= cashAdvanceAmountEpsilon:
|
||||
return constants.CashAdvanceSettlementOpen
|
||||
case amount-accounted > cashAdvanceAmountEpsilon:
|
||||
return constants.CashAdvanceSettlementPartial
|
||||
default:
|
||||
return constants.CashAdvanceSettlementSettled
|
||||
}
|
||||
}
|
||||
|
||||
func CashAdvanceEntityToModel(entity *entities.CashAdvance) *models.CashAdvance {
|
||||
if entity == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.CashAdvance{
|
||||
ID: entity.ID,
|
||||
OrganizationID: entity.OrganizationID,
|
||||
OutletID: entity.OutletID,
|
||||
CodeNumber: entity.CodeNumber,
|
||||
TeamScope: entity.TeamScope,
|
||||
TeamCategoryID: entity.TeamCategoryID,
|
||||
Amount: entity.Amount,
|
||||
ReturnedAmount: entity.ReturnedAmount,
|
||||
IssuedDate: entity.IssuedDate,
|
||||
DueDate: entity.DueDate,
|
||||
Status: entity.Status,
|
||||
Description: entity.Description,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func CashAdvanceEntityToResponse(entity *entities.CashAdvance) *models.CashAdvanceResponse {
|
||||
if entity == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.CashAdvanceResponse{
|
||||
ID: entity.ID,
|
||||
OrganizationID: entity.OrganizationID,
|
||||
OutletID: entity.OutletID,
|
||||
CodeNumber: entity.CodeNumber,
|
||||
TeamScope: entity.TeamScope,
|
||||
TeamCategoryID: entity.TeamCategoryID,
|
||||
Amount: entity.Amount,
|
||||
SettledAmount: entity.SettledAmount,
|
||||
ReturnedAmount: entity.ReturnedAmount,
|
||||
RemainingAmount: entity.Amount - entity.SettledAmount - entity.ReturnedAmount,
|
||||
SettlementStatus: cashAdvanceSettlementStatus(entity.Amount, entity.SettledAmount, entity.ReturnedAmount),
|
||||
IssuedDate: entity.IssuedDate,
|
||||
DueDate: entity.DueDate,
|
||||
Status: entity.Status,
|
||||
Description: entity.Description,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
Team: cashAdvanceTeamFromEntity(entity),
|
||||
}
|
||||
}
|
||||
|
||||
func CashAdvanceEntitiesToResponses(entities []*entities.CashAdvance) []*models.CashAdvanceResponse {
|
||||
if entities == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
responses := make([]*models.CashAdvanceResponse, len(entities))
|
||||
for i, entity := range entities {
|
||||
responses[i] = CashAdvanceEntityToResponse(entity)
|
||||
}
|
||||
return responses
|
||||
}
|
||||
|
||||
func CashAdvanceSettlementEntitiesToModels(settlements []*entities.CashAdvanceSettlement) []models.CashAdvanceSettlement {
|
||||
if settlements == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]models.CashAdvanceSettlement, len(settlements))
|
||||
for i, settlement := range settlements {
|
||||
result[i] = models.CashAdvanceSettlement{
|
||||
Type: settlement.Type,
|
||||
ID: settlement.ID,
|
||||
Number: settlement.Number,
|
||||
Date: settlement.Date,
|
||||
Amount: settlement.Amount,
|
||||
Status: settlement.Status,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCashAdvanceSettlementStatusFollowsTheMoney(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
amount float64
|
||||
settled float64
|
||||
returned float64
|
||||
expectedStatus string
|
||||
expectedRemaining float64
|
||||
}{
|
||||
{
|
||||
name: "nothing spent or returned is still open",
|
||||
amount: 500000,
|
||||
expectedStatus: constants.CashAdvanceSettlementOpen,
|
||||
expectedRemaining: 500000,
|
||||
},
|
||||
{
|
||||
name: "some spending leaves it partial",
|
||||
amount: 500000,
|
||||
settled: 200000,
|
||||
expectedStatus: constants.CashAdvanceSettlementPartial,
|
||||
expectedRemaining: 300000,
|
||||
},
|
||||
{
|
||||
name: "cash handed back counts the same as spending",
|
||||
amount: 500000,
|
||||
settled: 300000,
|
||||
returned: 100000,
|
||||
expectedStatus: constants.CashAdvanceSettlementPartial,
|
||||
expectedRemaining: 100000,
|
||||
},
|
||||
{
|
||||
name: "spending plus cash back covering the advance settles it",
|
||||
amount: 500000,
|
||||
settled: 420000,
|
||||
returned: 80000,
|
||||
expectedStatus: constants.CashAdvanceSettlementSettled,
|
||||
expectedRemaining: 0,
|
||||
},
|
||||
{
|
||||
// The team paid the difference out of pocket, so the outlet owes them.
|
||||
name: "overspending settles the cash advance and goes negative",
|
||||
amount: 500000,
|
||||
settled: 620000,
|
||||
expectedStatus: constants.CashAdvanceSettlementSettled,
|
||||
expectedRemaining: -120000,
|
||||
},
|
||||
{
|
||||
// Two decimals of money should not leave a cash advance a fraction short.
|
||||
name: "a rounding crumb short still settles",
|
||||
amount: 100000,
|
||||
settled: 99999.999,
|
||||
expectedStatus: constants.CashAdvanceSettlementSettled,
|
||||
expectedRemaining: 0.001,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
response := CashAdvanceEntityToResponse(&entities.CashAdvance{
|
||||
ID: uuid.New(),
|
||||
TeamScope: constants.PurchaseTeamScopeCentral,
|
||||
Amount: tt.amount,
|
||||
SettledAmount: tt.settled,
|
||||
ReturnedAmount: tt.returned,
|
||||
})
|
||||
|
||||
require.Equal(t, tt.expectedStatus, response.SettlementStatus)
|
||||
require.InDelta(t, tt.expectedRemaining, response.RemainingAmount, 0.0001)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCashAdvanceTeamAlwaysRendered(t *testing.T) {
|
||||
categoryID := uuid.New()
|
||||
|
||||
central := CashAdvanceEntityToResponse(&entities.CashAdvance{TeamScope: constants.PurchaseTeamScopeCentral})
|
||||
require.NotNil(t, central.Team)
|
||||
require.Equal(t, constants.PurchaseTeamCentralName, central.Team.Name)
|
||||
require.Nil(t, central.Team.CategoryID)
|
||||
|
||||
category := CashAdvanceEntityToResponse(&entities.CashAdvance{
|
||||
TeamScope: constants.PurchaseTeamScopeCategory,
|
||||
TeamCategoryID: &categoryID,
|
||||
TeamCategory: &entities.Category{ID: categoryID, Name: "Dapur"},
|
||||
})
|
||||
require.NotNil(t, category.Team)
|
||||
require.Equal(t, "Dapur", category.Team.Name)
|
||||
require.Equal(t, &categoryID, category.Team.CategoryID)
|
||||
}
|
||||
@@ -66,7 +66,6 @@ func ExpenseEntityToResponse(entity *entities.Expense) *models.ExpenseResponse {
|
||||
Tax: entity.Tax,
|
||||
Total: entity.Total,
|
||||
Reserved1: entity.Reserved1,
|
||||
CashAdvanceID: entity.CashAdvanceID,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.Purchase
|
||||
TotalAmount: entity.TotalAmount,
|
||||
TeamScope: entity.TeamScope,
|
||||
TeamCategoryID: entity.TeamCategoryID,
|
||||
CashAdvanceID: entity.CashAdvanceID,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
}
|
||||
@@ -72,7 +71,6 @@ func PurchaseOrderModelToEntity(model *models.PurchaseOrder) *entities.PurchaseO
|
||||
TotalAmount: model.TotalAmount,
|
||||
TeamScope: model.TeamScope,
|
||||
TeamCategoryID: model.TeamCategoryID,
|
||||
CashAdvanceID: model.CashAdvanceID,
|
||||
CreatedAt: model.CreatedAt,
|
||||
UpdatedAt: model.UpdatedAt,
|
||||
}
|
||||
@@ -97,7 +95,6 @@ func PurchaseOrderEntityToResponse(entity *entities.PurchaseOrder) *models.Purch
|
||||
TotalAmount: entity.TotalAmount,
|
||||
TeamScope: entity.TeamScope,
|
||||
TeamCategoryID: entity.TeamCategoryID,
|
||||
CashAdvanceID: entity.CashAdvanceID,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
Team: purchaseTeamFromEntity(entity),
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CashAdvance struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID uuid.UUID `json:"outlet_id"`
|
||||
CodeNumber string `json:"code_number"`
|
||||
TeamScope string `json:"team_scope"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
ReturnedAmount float64 `json:"returned_amount"`
|
||||
IssuedDate time.Time `json:"issued_date"`
|
||||
DueDate *time.Time `json:"due_date"`
|
||||
Status string `json:"status"`
|
||||
Description *string `json:"description"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CashAdvanceResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID uuid.UUID `json:"outlet_id"`
|
||||
CodeNumber string `json:"code_number"`
|
||||
TeamScope string `json:"team_scope"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
// SettledAmount is the spending charged to this advance, ReturnedAmount the cash
|
||||
// handed back, and RemainingAmount what is still out with the team. A negative
|
||||
// remaining amount means the team overspent and is owed the difference.
|
||||
SettledAmount float64 `json:"settled_amount"`
|
||||
ReturnedAmount float64 `json:"returned_amount"`
|
||||
RemainingAmount float64 `json:"remaining_amount"`
|
||||
SettlementStatus string `json:"settlement_status"`
|
||||
IssuedDate time.Time `json:"issued_date"`
|
||||
DueDate *time.Time `json:"due_date"`
|
||||
Status string `json:"status"`
|
||||
Description *string `json:"description"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Team *PurchaseTeam `json:"team,omitempty"`
|
||||
Settlements []CashAdvanceSettlement `json:"settlements,omitempty"`
|
||||
}
|
||||
|
||||
// CashAdvanceSettlement is one purchase order or expense paid out of the advance.
|
||||
type CashAdvanceSettlement struct {
|
||||
Type string `json:"type"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
Number string `json:"number"`
|
||||
Date time.Time `json:"date"`
|
||||
Amount float64 `json:"amount"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type CreateCashAdvanceRequest struct {
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
CodeNumber string `json:"code_number"`
|
||||
TeamScope string `json:"team_scope"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"`
|
||||
Amount float64 `json:"amount"`
|
||||
IssuedDate time.Time `json:"issued_date"`
|
||||
DueDate *time.Time `json:"due_date,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateCashAdvanceRequest struct {
|
||||
CodeNumber *string `json:"code_number,omitempty"`
|
||||
TeamScope *string `json:"team_scope,omitempty"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"`
|
||||
Amount *float64 `json:"amount,omitempty"`
|
||||
ReturnedAmount *float64 `json:"returned_amount,omitempty"`
|
||||
IssuedDate *time.Time `json:"issued_date,omitempty"`
|
||||
DueDate *time.Time `json:"due_date,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type ListCashAdvancesRequest struct {
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
Search string `json:"search,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
SettlementStatus string `json:"settlement_status,omitempty"`
|
||||
Team string `json:"team,omitempty"`
|
||||
TeamScope string `json:"team_scope,omitempty"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"`
|
||||
StartDate *time.Time `json:"start_date,omitempty"`
|
||||
EndDate *time.Time `json:"end_date,omitempty"`
|
||||
}
|
||||
|
||||
type ListCashAdvancesResponse struct {
|
||||
CashAdvances []CashAdvanceResponse `json:"cash_advances"`
|
||||
TotalCount int `json:"total_count"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
@@ -46,7 +46,6 @@ type ExpenseResponse struct {
|
||||
Tax float64 `json:"tax"`
|
||||
Total float64 `json:"total"`
|
||||
Reserved1 *string `json:"reserved1"`
|
||||
CashAdvanceID *uuid.UUID `json:"cash_advance_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Items []ExpenseItemResponse `json:"items,omitempty"`
|
||||
@@ -77,7 +76,6 @@ type CreateExpenseRequest struct {
|
||||
Description *string `json:"description"`
|
||||
Tax float64 `json:"tax"`
|
||||
Total float64 `json:"total"`
|
||||
CashAdvanceID *string `json:"cash_advance_id,omitempty"`
|
||||
Items []CreateExpenseItemRequest `json:"items"`
|
||||
}
|
||||
|
||||
@@ -99,7 +97,6 @@ type UpdateExpenseRequest struct {
|
||||
Tax *float64 `json:"tax,omitempty"`
|
||||
Total *float64 `json:"total,omitempty"`
|
||||
Reserved1 *string `json:"reserved1,omitempty"`
|
||||
CashAdvanceID *string `json:"cash_advance_id,omitempty"`
|
||||
Items []UpdateExpenseItemRequest `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ type PurchaseOrder struct {
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
TeamScope *string `json:"team_scope"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id"`
|
||||
CashAdvanceID *uuid.UUID `json:"cash_advance_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -67,7 +66,6 @@ type PurchaseOrderResponse struct {
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
TeamScope *string `json:"team_scope"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id"`
|
||||
CashAdvanceID *uuid.UUID `json:"cash_advance_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Team *PurchaseTeam `json:"team,omitempty"`
|
||||
@@ -111,7 +109,6 @@ type CreatePurchaseOrderRequest struct {
|
||||
Message *string `json:"message,omitempty"`
|
||||
TeamScope *string `json:"team_scope,omitempty"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"`
|
||||
CashAdvanceID *uuid.UUID `json:"cash_advance_id,omitempty"`
|
||||
Items []CreatePurchaseOrderItemRequest `json:"items"`
|
||||
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
||||
}
|
||||
@@ -135,7 +132,6 @@ type UpdatePurchaseOrderRequest struct {
|
||||
Message *string `json:"message,omitempty"`
|
||||
TeamScope *string `json:"team_scope,omitempty"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"`
|
||||
CashAdvanceID *uuid.UUID `json:"cash_advance_id,omitempty"`
|
||||
Items []UpdatePurchaseOrderItemRequest `json:"items,omitempty"`
|
||||
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/mappers"
|
||||
"apskel-pos-be/internal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CashAdvanceProcessor interface {
|
||||
CreateCashAdvance(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreateCashAdvanceRequest) (*models.CashAdvanceResponse, error)
|
||||
UpdateCashAdvance(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdateCashAdvanceRequest) (*models.CashAdvanceResponse, error)
|
||||
DeleteCashAdvance(ctx context.Context, id, organizationID uuid.UUID) error
|
||||
GetCashAdvanceByID(ctx context.Context, id, organizationID uuid.UUID) (*models.CashAdvanceResponse, error)
|
||||
ListCashAdvances(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.CashAdvanceResponse, int, error)
|
||||
UpdateCashAdvanceStatus(ctx context.Context, id, organizationID uuid.UUID, status string) (*models.CashAdvanceResponse, error)
|
||||
ListCashAdvanceTeams(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error)
|
||||
}
|
||||
|
||||
type CashAdvanceProcessorImpl struct {
|
||||
cashAdvanceRepo CashAdvanceRepository
|
||||
categoryRepo CategoryRepository
|
||||
}
|
||||
|
||||
func NewCashAdvanceProcessorImpl(cashAdvanceRepo CashAdvanceRepository, categoryRepo CategoryRepository) *CashAdvanceProcessorImpl {
|
||||
return &CashAdvanceProcessorImpl{
|
||||
cashAdvanceRepo: cashAdvanceRepo,
|
||||
categoryRepo: categoryRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *CashAdvanceProcessorImpl) CreateCashAdvance(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreateCashAdvanceRequest) (*models.CashAdvanceResponse, error) {
|
||||
// The cash leaves one drawer, so the outlet has to be known: either the caller
|
||||
// named it or it comes from the outlet they are signed in to.
|
||||
resolvedOutletID := req.OutletID
|
||||
if resolvedOutletID == nil {
|
||||
resolvedOutletID = outletID
|
||||
}
|
||||
if resolvedOutletID == nil || *resolvedOutletID == uuid.Nil {
|
||||
return nil, fmt.Errorf("outlet_id is required")
|
||||
}
|
||||
|
||||
teamScope, teamCategoryID, err := p.resolveCashAdvanceTeam(ctx, organizationID, resolvedOutletID, &req.TeamScope, req.TeamCategoryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing, err := p.cashAdvanceRepo.GetByCodeNumber(ctx, req.CodeNumber, organizationID)
|
||||
if err == nil && existing != nil {
|
||||
return nil, fmt.Errorf("cash advance with code number %s already exists in this organization", req.CodeNumber)
|
||||
}
|
||||
|
||||
status := constants.CashAdvanceStatusDraft
|
||||
if req.Status != nil {
|
||||
status = *req.Status
|
||||
}
|
||||
|
||||
cashAdvance := &entities.CashAdvance{
|
||||
OrganizationID: organizationID,
|
||||
OutletID: *resolvedOutletID,
|
||||
CodeNumber: req.CodeNumber,
|
||||
TeamScope: teamScope,
|
||||
TeamCategoryID: teamCategoryID,
|
||||
Amount: req.Amount,
|
||||
IssuedDate: req.IssuedDate,
|
||||
DueDate: req.DueDate,
|
||||
Status: status,
|
||||
Description: req.Description,
|
||||
}
|
||||
|
||||
if err := p.cashAdvanceRepo.Create(ctx, cashAdvance); err != nil {
|
||||
return nil, fmt.Errorf("failed to create cash advance: %w", err)
|
||||
}
|
||||
|
||||
created, err := p.cashAdvanceRepo.GetByID(ctx, cashAdvance.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get created cash advance: %w", err)
|
||||
}
|
||||
|
||||
return mappers.CashAdvanceEntityToResponse(created), nil
|
||||
}
|
||||
|
||||
func (p *CashAdvanceProcessorImpl) UpdateCashAdvance(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdateCashAdvanceRequest) (*models.CashAdvanceResponse, error) {
|
||||
cashAdvance, err := p.cashAdvanceRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cash advance not found: %w", err)
|
||||
}
|
||||
|
||||
if req.CodeNumber != nil && *req.CodeNumber != cashAdvance.CodeNumber {
|
||||
existing, err := p.cashAdvanceRepo.GetByCodeNumber(ctx, *req.CodeNumber, organizationID)
|
||||
if err == nil && existing != nil {
|
||||
return nil, fmt.Errorf("cash advance with code number %s already exists in this organization", *req.CodeNumber)
|
||||
}
|
||||
cashAdvance.CodeNumber = *req.CodeNumber
|
||||
}
|
||||
|
||||
if req.TeamScope != nil {
|
||||
teamScope, teamCategoryID, err := p.resolveCashAdvanceTeam(ctx, organizationID, &cashAdvance.OutletID, req.TeamScope, req.TeamCategoryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cashAdvance.TeamScope = teamScope
|
||||
cashAdvance.TeamCategoryID = teamCategoryID
|
||||
}
|
||||
|
||||
if req.Amount != nil {
|
||||
cashAdvance.Amount = *req.Amount
|
||||
}
|
||||
if req.ReturnedAmount != nil {
|
||||
cashAdvance.ReturnedAmount = *req.ReturnedAmount
|
||||
}
|
||||
if req.IssuedDate != nil {
|
||||
cashAdvance.IssuedDate = *req.IssuedDate
|
||||
}
|
||||
if req.DueDate != nil {
|
||||
cashAdvance.DueDate = req.DueDate
|
||||
}
|
||||
if req.Status != nil {
|
||||
if err := p.guardStatusChange(ctx, cashAdvance, *req.Status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cashAdvance.Status = *req.Status
|
||||
}
|
||||
if req.Description != nil {
|
||||
cashAdvance.Description = req.Description
|
||||
}
|
||||
|
||||
// Cash handed back can only ever be part of the cash handed out.
|
||||
if cashAdvance.ReturnedAmount > cashAdvance.Amount {
|
||||
return nil, fmt.Errorf("returned_amount cannot be greater than the cash advance amount")
|
||||
}
|
||||
|
||||
if err := p.cashAdvanceRepo.Update(ctx, cashAdvance); err != nil {
|
||||
return nil, fmt.Errorf("failed to update cash advance: %w", err)
|
||||
}
|
||||
|
||||
updated, err := p.cashAdvanceRepo.GetByID(ctx, cashAdvance.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get updated cash advance: %w", err)
|
||||
}
|
||||
|
||||
return mappers.CashAdvanceEntityToResponse(updated), nil
|
||||
}
|
||||
|
||||
func (p *CashAdvanceProcessorImpl) DeleteCashAdvance(ctx context.Context, id, organizationID uuid.UUID) error {
|
||||
if _, err := p.cashAdvanceRepo.GetByIDAndOrganizationID(ctx, id, organizationID); err != nil {
|
||||
return fmt.Errorf("cash advance not found: %w", err)
|
||||
}
|
||||
|
||||
// The foreign keys would refuse this anyway, but not in words anyone can act on.
|
||||
count, err := p.cashAdvanceRepo.CountSettlements(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check cash advance settlements: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("cash advance cannot be deleted because %d purchase orders or expenses are charged to it", count)
|
||||
}
|
||||
|
||||
if err := p.cashAdvanceRepo.Delete(ctx, id); err != nil {
|
||||
return fmt.Errorf("failed to delete cash advance: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *CashAdvanceProcessorImpl) GetCashAdvanceByID(ctx context.Context, id, organizationID uuid.UUID) (*models.CashAdvanceResponse, error) {
|
||||
cashAdvance, err := p.cashAdvanceRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cash advance not found: %w", err)
|
||||
}
|
||||
|
||||
response := mappers.CashAdvanceEntityToResponse(cashAdvance)
|
||||
|
||||
// The detail view is where someone checks a cash advance off, so it carries the
|
||||
// spending behind the settled figure. The list deliberately does not.
|
||||
settlements, err := p.cashAdvanceRepo.ListSettlements(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list cash advance settlements: %w", err)
|
||||
}
|
||||
response.Settlements = mappers.CashAdvanceSettlementEntitiesToModels(settlements)
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (p *CashAdvanceProcessorImpl) ListCashAdvances(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.CashAdvanceResponse, int, error) {
|
||||
offset := (page - 1) * limit
|
||||
cashAdvances, total, err := p.cashAdvanceRepo.List(ctx, organizationID, filters, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list cash advances: %w", err)
|
||||
}
|
||||
|
||||
responses := mappers.CashAdvanceEntitiesToResponses(cashAdvances)
|
||||
totalPages := int((total + int64(limit) - 1) / int64(limit))
|
||||
|
||||
return responses, totalPages, nil
|
||||
}
|
||||
|
||||
func (p *CashAdvanceProcessorImpl) UpdateCashAdvanceStatus(ctx context.Context, id, organizationID uuid.UUID, status string) (*models.CashAdvanceResponse, error) {
|
||||
cashAdvance, err := p.cashAdvanceRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cash advance not found: %w", err)
|
||||
}
|
||||
|
||||
if !constants.IsValidCashAdvanceStatus(status) {
|
||||
return nil, fmt.Errorf("status must be one of: %s", strings.Join(constants.GetAllCashAdvanceStatuses(), ", "))
|
||||
}
|
||||
|
||||
if err := p.guardStatusChange(ctx, cashAdvance, status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cashAdvance.Status = status
|
||||
if err := p.cashAdvanceRepo.Update(ctx, cashAdvance); err != nil {
|
||||
return nil, fmt.Errorf("failed to update cash advance status: %w", err)
|
||||
}
|
||||
|
||||
updated, err := p.cashAdvanceRepo.GetByID(ctx, cashAdvance.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get updated cash advance: %w", err)
|
||||
}
|
||||
|
||||
return mappers.CashAdvanceEntityToResponse(updated), nil
|
||||
}
|
||||
|
||||
func (p *CashAdvanceProcessorImpl) ListCashAdvanceTeams(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) {
|
||||
return listTeams(ctx, p.categoryRepo, organizationID, outletID)
|
||||
}
|
||||
|
||||
// guardStatusChange refuses to withdraw an advance that spending already points at.
|
||||
// Rejecting or cancelling it would leave those purchases claiming to have been paid
|
||||
// out of cash the books say never went out.
|
||||
func (p *CashAdvanceProcessorImpl) guardStatusChange(ctx context.Context, cashAdvance *entities.CashAdvance, status string) error {
|
||||
if status != constants.CashAdvanceStatusRejected && status != constants.CashAdvanceStatusCancelled {
|
||||
return nil
|
||||
}
|
||||
|
||||
count, err := p.cashAdvanceRepo.CountSettlements(ctx, cashAdvance.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check cash advance settlements: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("cash advance cannot be %s because %d purchase orders or expenses are charged to it", status, count)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveCashAdvanceTeam is resolveTeamSelection with the one rule an advance adds:
|
||||
// the cash is handed to a team, so there is no such thing as one without a team.
|
||||
func (p *CashAdvanceProcessorImpl) resolveCashAdvanceTeam(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, scope *string, categoryID *uuid.UUID) (string, *uuid.UUID, error) {
|
||||
resolvedScope, resolvedCategoryID, err := resolveTeamSelection(ctx, p.categoryRepo, organizationID, outletID, scope, categoryID)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if resolvedScope == nil {
|
||||
return "", nil, fmt.Errorf("team_scope is required")
|
||||
}
|
||||
|
||||
return *resolvedScope, resolvedCategoryID, nil
|
||||
}
|
||||
|
||||
// resolveSpendingCashAdvance checks that a purchase order or expense may be charged
|
||||
// to the advance it names: same organization and outlet, and the money actually
|
||||
// approved to leave the drawer. Draft or cancelled advances cannot be spent against.
|
||||
func resolveSpendingCashAdvance(ctx context.Context, cashAdvanceRepo CashAdvanceRepository, cashAdvanceID, organizationID uuid.UUID, outletID *uuid.UUID) (*entities.CashAdvance, error) {
|
||||
cashAdvance, err := cashAdvanceRepo.GetByIDAndOrganizationID(ctx, cashAdvanceID, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cash advance not found: %w", err)
|
||||
}
|
||||
|
||||
if cashAdvance.Status != constants.CashAdvanceStatusApproved {
|
||||
return nil, fmt.Errorf("cash advance %s is %s, only an approved cash advance can be spent against", cashAdvance.CodeNumber, cashAdvance.Status)
|
||||
}
|
||||
|
||||
if outletID != nil && *outletID != uuid.Nil && cashAdvance.OutletID != *outletID {
|
||||
return nil, fmt.Errorf("cash advance %s belongs to a different outlet", cashAdvance.CodeNumber)
|
||||
}
|
||||
|
||||
return cashAdvance, nil
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/entities"
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CashAdvanceRepository interface {
|
||||
Create(ctx context.Context, cashAdvance *entities.CashAdvance) error
|
||||
GetByID(ctx context.Context, id uuid.UUID) (*entities.CashAdvance, error)
|
||||
GetByIDAndOrganizationID(ctx context.Context, id, organizationID uuid.UUID) (*entities.CashAdvance, error)
|
||||
GetByCodeNumber(ctx context.Context, codeNumber string, organizationID uuid.UUID) (*entities.CashAdvance, error)
|
||||
Update(ctx context.Context, cashAdvance *entities.CashAdvance) error
|
||||
Delete(ctx context.Context, id uuid.UUID) error
|
||||
List(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, limit, offset int) ([]*entities.CashAdvance, int64, error)
|
||||
ListSettlements(ctx context.Context, cashAdvanceID uuid.UUID) ([]*entities.CashAdvanceSettlement, error)
|
||||
CountSettlements(ctx context.Context, cashAdvanceID uuid.UUID) (int64, error)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package processor
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
@@ -26,14 +25,12 @@ type ExpenseProcessor interface {
|
||||
type ExpenseProcessorImpl struct {
|
||||
expenseRepo ExpenseRepository
|
||||
purchaseCategoryRepo PurchaseCategoryRepository
|
||||
cashAdvanceRepo CashAdvanceRepository
|
||||
}
|
||||
|
||||
func NewExpenseProcessorImpl(expenseRepo ExpenseRepository, purchaseCategoryRepo PurchaseCategoryRepository, cashAdvanceRepo CashAdvanceRepository) *ExpenseProcessorImpl {
|
||||
func NewExpenseProcessorImpl(expenseRepo ExpenseRepository, purchaseCategoryRepo PurchaseCategoryRepository) *ExpenseProcessorImpl {
|
||||
return &ExpenseProcessorImpl{
|
||||
expenseRepo: expenseRepo,
|
||||
purchaseCategoryRepo: purchaseCategoryRepo,
|
||||
cashAdvanceRepo: cashAdvanceRepo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,11 +50,6 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID
|
||||
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))
|
||||
for i, itemReq := range req.Items {
|
||||
chartOfAccountID, err := uuid.Parse(itemReq.ChartOfAccountID)
|
||||
@@ -92,7 +84,6 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID
|
||||
Description: req.Description,
|
||||
Tax: req.Tax,
|
||||
Total: req.Total,
|
||||
CashAdvanceID: cashAdvanceID,
|
||||
}
|
||||
|
||||
err = p.expenseRepo.Create(ctx, expenseEntity)
|
||||
@@ -158,14 +149,6 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati
|
||||
if req.Reserved1 != nil {
|
||||
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
|
||||
if req.Items != nil {
|
||||
@@ -351,25 +334,6 @@ func (p *ExpenseProcessorImpl) GetExpenseAnalytics(ctx context.Context, req *mod
|
||||
}, 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 {
|
||||
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID)
|
||||
if err != nil {
|
||||
|
||||
@@ -99,40 +99,10 @@ func (*expenseRepositoryCaptureStub) DeleteItemsByExpenseID(context.Context, uui
|
||||
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) {
|
||||
repo := &expenseRepositoryCaptureStub{}
|
||||
purchaseCategoryID := uuid.New()
|
||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense), &expenseCashAdvanceRepositoryStub{})
|
||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
|
||||
chartOfAccountID := uuid.New()
|
||||
|
||||
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
|
||||
@@ -163,7 +133,7 @@ func TestExpenseProcessorCreatePersistsItemName(t *testing.T) {
|
||||
func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) {
|
||||
repo := &expenseRepositoryCaptureStub{}
|
||||
purchaseCategoryID := uuid.New()
|
||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense), &expenseCashAdvanceRepositoryStub{})
|
||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
|
||||
|
||||
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
|
||||
Receiver: "Cashier",
|
||||
@@ -190,7 +160,7 @@ func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) {
|
||||
func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) {
|
||||
repo := &expenseRepositoryCaptureStub{}
|
||||
purchaseCategoryID := uuid.New()
|
||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense), &expenseCashAdvanceRepositoryStub{})
|
||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
|
||||
status := "approved"
|
||||
|
||||
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
|
||||
@@ -219,7 +189,7 @@ func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) {
|
||||
func TestExpenseProcessorCreateRejectsRawMaterialPurchaseCategory(t *testing.T) {
|
||||
repo := &expenseRepositoryCaptureStub{}
|
||||
purchaseCategoryID := uuid.New()
|
||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeRawMaterial), &expenseCashAdvanceRepositoryStub{})
|
||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeRawMaterial))
|
||||
|
||||
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
|
||||
Receiver: "Cashier",
|
||||
@@ -296,7 +266,7 @@ func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *te
|
||||
},
|
||||
},
|
||||
}
|
||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense), &expenseCashAdvanceRepositoryStub{})
|
||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
|
||||
|
||||
resp, err := p.GetExpenseAnalytics(context.Background(), &models.ExpenseAnalyticsRequest{
|
||||
OrganizationID: uuid.New(),
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/mappers"
|
||||
"apskel-pos-be/internal/models"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -23,14 +25,13 @@ type PurchaseOrderProcessor interface {
|
||||
}
|
||||
|
||||
type PurchaseOrderProcessorImpl struct {
|
||||
purchaseOrderRepo PurchaseOrderRepository
|
||||
vendorRepo VendorRepository
|
||||
ingredientRepo IngredientRepository
|
||||
purchaseCategoryRepo PurchaseCategoryRepository
|
||||
categoryRepo CategoryRepository
|
||||
cashAdvanceRepo CashAdvanceRepository
|
||||
unitRepo UnitRepository
|
||||
fileRepo FileRepository
|
||||
purchaseOrderRepo PurchaseOrderRepository
|
||||
vendorRepo VendorRepository
|
||||
ingredientRepo IngredientRepository
|
||||
purchaseCategoryRepo PurchaseCategoryRepository
|
||||
categoryRepo CategoryRepository
|
||||
unitRepo UnitRepository
|
||||
fileRepo FileRepository
|
||||
// Kept wired but currently unused: purchase orders are a record of spending
|
||||
// only, so nothing here moves stock or converts units. These stay so that
|
||||
// tying purchases back to inventory is a change in one place.
|
||||
@@ -44,7 +45,6 @@ func NewPurchaseOrderProcessorImpl(
|
||||
ingredientRepo IngredientRepository,
|
||||
purchaseCategoryRepo PurchaseCategoryRepository,
|
||||
categoryRepo CategoryRepository,
|
||||
cashAdvanceRepo CashAdvanceRepository,
|
||||
unitRepo UnitRepository,
|
||||
fileRepo FileRepository,
|
||||
inventoryMovementService InventoryMovementService,
|
||||
@@ -56,7 +56,6 @@ func NewPurchaseOrderProcessorImpl(
|
||||
ingredientRepo: ingredientRepo,
|
||||
purchaseCategoryRepo: purchaseCategoryRepo,
|
||||
categoryRepo: categoryRepo,
|
||||
cashAdvanceRepo: cashAdvanceRepo,
|
||||
unitRepo: unitRepo,
|
||||
fileRepo: fileRepo,
|
||||
inventoryMovementService: inventoryMovementService,
|
||||
@@ -78,11 +77,6 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
|
||||
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
|
||||
existingPO, err := p.purchaseOrderRepo.GetByPONumber(ctx, req.PONumber, organizationID)
|
||||
if err == nil && existingPO != nil {
|
||||
@@ -146,7 +140,6 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
|
||||
TotalAmount: totalAmount,
|
||||
TeamScope: teamScope,
|
||||
TeamCategoryID: teamCategoryID,
|
||||
CashAdvanceID: req.CashAdvanceID,
|
||||
}
|
||||
|
||||
if req.Status != nil {
|
||||
@@ -254,26 +247,6 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
|
||||
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
|
||||
if req.Items != nil {
|
||||
totalAmount := 0.0
|
||||
@@ -494,49 +467,77 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
|
||||
return mappers.PurchaseOrderEntityToResponse(updatedPO), nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
// ListPurchaseTeams returns the teams a purchase can be charged to: the parent
|
||||
// categories of the outlet in scope, followed by Pusat. Pusat has no category row,
|
||||
// so it is appended here rather than read from the database.
|
||||
func (p *PurchaseOrderProcessorImpl) ListPurchaseTeams(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) {
|
||||
return listTeams(ctx, p.categoryRepo, organizationID, outletID)
|
||||
categories, err := p.categoryRepo.ListParentCategories(ctx, organizationID, outletID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list parent categories: %w", err)
|
||||
}
|
||||
|
||||
teams := make([]models.PurchaseTeam, 0, len(categories)+1)
|
||||
for _, category := range categories {
|
||||
categoryID := category.ID
|
||||
teams = append(teams, models.PurchaseTeam{
|
||||
Scope: constants.PurchaseTeamScopeCategory,
|
||||
CategoryID: &categoryID,
|
||||
Name: category.Name,
|
||||
})
|
||||
}
|
||||
|
||||
teams = append(teams, models.PurchaseTeam{
|
||||
Scope: constants.PurchaseTeamScopeCentral,
|
||||
Name: constants.PurchaseTeamCentralName,
|
||||
})
|
||||
|
||||
return &models.ListPurchaseTeamsResponse{Teams: teams}, nil
|
||||
}
|
||||
|
||||
// resolvePurchaseTeam turns a requested team into the scope/category pair stored on
|
||||
// the purchase order. A nil or empty scope leaves the purchase without a team, which
|
||||
// is deliberately different from Pusat.
|
||||
// the purchase order, mirroring the database check constraint. A nil or empty scope
|
||||
// leaves the purchase without a team, which is deliberately different from Pusat.
|
||||
// Which outlet's Pusat a purchase belongs to comes from the purchase order's outlet,
|
||||
// so 'central' needs nothing stored beyond the scope itself.
|
||||
func (p *PurchaseOrderProcessorImpl) resolvePurchaseTeam(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, scope *string, categoryID *uuid.UUID) (*string, *uuid.UUID, error) {
|
||||
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
|
||||
if scope == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
cashAdvance, err := resolveSpendingCashAdvance(ctx, p.cashAdvanceRepo, *cashAdvanceID, organizationID, outletID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
switch strings.TrimSpace(*scope) {
|
||||
case "":
|
||||
return nil, nil, nil
|
||||
|
||||
case constants.PurchaseTeamScopeCentral:
|
||||
resolved := constants.PurchaseTeamScopeCentral
|
||||
return &resolved, nil, nil
|
||||
|
||||
case constants.PurchaseTeamScopeCategory:
|
||||
if categoryID == nil {
|
||||
return nil, nil, fmt.Errorf("team_category_id is required when team_scope is category")
|
||||
}
|
||||
|
||||
category, err := p.categoryRepo.GetByID(ctx, *categoryID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("team category not found: %w", err)
|
||||
}
|
||||
if category.OrganizationID != organizationID {
|
||||
return nil, nil, fmt.Errorf("team category does not belong to this organization")
|
||||
}
|
||||
if category.ParentID != nil {
|
||||
return nil, nil, fmt.Errorf("team must be a parent category")
|
||||
}
|
||||
// Categories without an outlet are shared, so only an outlet-specific
|
||||
// category has to match the outlet the purchase is booked against.
|
||||
if category.OutletID != nil && outletID != nil && *category.OutletID != *outletID {
|
||||
return nil, nil, fmt.Errorf("team category belongs to a different outlet")
|
||||
}
|
||||
|
||||
resolved := constants.PurchaseTeamScopeCategory
|
||||
return &resolved, &category.ID, nil
|
||||
}
|
||||
|
||||
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
|
||||
return nil, nil, fmt.Errorf("team_scope must be one of: category, central")
|
||||
}
|
||||
|
||||
func (p *PurchaseOrderProcessorImpl) validatePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) (*entities.PurchaseCategory, error) {
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
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")
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -53,13 +53,12 @@ type Router struct {
|
||||
selfOrderHandler *handler.SelfOrderHandler
|
||||
productOutletPriceHandler *handler.ProductOutletPriceHandler
|
||||
expenseHandler *handler.ExpenseHandler
|
||||
cashAdvanceHandler *handler.CashAdvanceHandler
|
||||
authMiddleware *middleware.AuthMiddleware
|
||||
customerAuthMiddleware *middleware.CustomerAuthMiddleware
|
||||
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, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, 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, redisClient *redis.Client) *Router {
|
||||
|
||||
return &Router{
|
||||
config: cfg,
|
||||
@@ -104,7 +103,6 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer
|
||||
selfOrderHandler: selfOrderHandler,
|
||||
productOutletPriceHandler: handler.NewProductOutletPriceHandler(productOutletPriceService, productOutletPriceValidator),
|
||||
expenseHandler: handler.NewExpenseHandler(expenseService, expenseValidator),
|
||||
cashAdvanceHandler: handler.NewCashAdvanceHandler(cashAdvanceService, cashAdvanceValidator),
|
||||
redisClient: redisClient,
|
||||
}
|
||||
}
|
||||
@@ -480,19 +478,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
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.Use(r.authMiddleware.RequireAdminOrManager())
|
||||
{
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -21,7 +21,6 @@ func CreateExpenseRequestToModel(req *contract.CreateExpenseRequest) *models.Cre
|
||||
Description: req.Description,
|
||||
Tax: req.Tax,
|
||||
Total: req.Total,
|
||||
CashAdvanceID: req.CashAdvanceID,
|
||||
Items: items,
|
||||
}
|
||||
}
|
||||
@@ -47,7 +46,6 @@ func UpdateExpenseRequestToModel(req *contract.UpdateExpenseRequest) *models.Upd
|
||||
Tax: req.Tax,
|
||||
Total: req.Total,
|
||||
Reserved1: req.Reserved1,
|
||||
CashAdvanceID: req.CashAdvanceID,
|
||||
}
|
||||
|
||||
if req.Items != nil {
|
||||
@@ -105,7 +103,6 @@ func ExpenseModelResponseToResponse(expense *models.ExpenseResponse) *contract.E
|
||||
Tax: expense.Tax,
|
||||
Total: expense.Total,
|
||||
Reserved1: expense.Reserved1,
|
||||
CashAdvanceID: expense.CashAdvanceID,
|
||||
CreatedAt: expense.CreatedAt,
|
||||
UpdatedAt: expense.UpdatedAt,
|
||||
Items: items,
|
||||
|
||||
@@ -46,7 +46,6 @@ func CreatePurchaseOrderRequestToModel(req *contract.CreatePurchaseOrderRequest)
|
||||
Message: req.Message,
|
||||
TeamScope: req.TeamScope,
|
||||
TeamCategoryID: req.TeamCategoryID,
|
||||
CashAdvanceID: req.CashAdvanceID,
|
||||
Items: items,
|
||||
AttachmentFileIDs: req.AttachmentFileIDs,
|
||||
}, nil
|
||||
@@ -99,7 +98,6 @@ func UpdatePurchaseOrderRequestToModel(req *contract.UpdatePurchaseOrderRequest)
|
||||
Message: req.Message,
|
||||
TeamScope: req.TeamScope,
|
||||
TeamCategoryID: req.TeamCategoryID,
|
||||
CashAdvanceID: req.CashAdvanceID,
|
||||
Items: items,
|
||||
AttachmentFileIDs: req.AttachmentFileIDs,
|
||||
}, nil
|
||||
@@ -161,7 +159,6 @@ func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *con
|
||||
TotalAmount: po.TotalAmount,
|
||||
TeamScope: po.TeamScope,
|
||||
TeamCategoryID: po.TeamCategoryID,
|
||||
CashAdvanceID: po.CashAdvanceID,
|
||||
CreatedAt: po.CreatedAt,
|
||||
UpdatedAt: po.UpdatedAt,
|
||||
Team: PurchaseTeamModelToResponse(po.Team),
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
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, ""
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
DROP TABLE IF EXISTS cash_advances;
|
||||
@@ -1,45 +0,0 @@
|
||||
-- A cash advance is money handed to a team up front so the team can go shopping
|
||||
-- (kasbon in the Indonesian UI). It is deliberately not an expense: while the money
|
||||
-- sits with the team it is still the outlet's, and what was actually spent is read
|
||||
-- from the purchase orders and expenses charged back to the advance. Nothing about
|
||||
-- that spending is copied here.
|
||||
CREATE TABLE cash_advances (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
outlet_id UUID NOT NULL REFERENCES outlets(id) ON DELETE CASCADE,
|
||||
code_number VARCHAR(50) NOT NULL,
|
||||
-- Same team shape as purchase_orders, with one difference: an advance is handed
|
||||
-- to a team, so there is no "no team chosen yet" state and team_scope is NOT NULL.
|
||||
team_scope VARCHAR(20) NOT NULL,
|
||||
team_category_id UUID REFERENCES categories(id) ON DELETE RESTRICT,
|
||||
amount DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||
-- Cash the team brought back unspent. Spending is not stored: it is summed from
|
||||
-- the purchase orders and expenses that point at this advance.
|
||||
returned_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||
issued_date DATE NOT NULL,
|
||||
due_date DATE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'draft',
|
||||
description TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
|
||||
-- Written as a CASE for the same reason as purchase_orders: an OR chain would
|
||||
-- evaluate to NULL for an unexpected scope and a CHECK only rejects FALSE.
|
||||
CONSTRAINT chk_cash_advances_team CHECK (
|
||||
CASE
|
||||
WHEN team_scope = 'category' THEN team_category_id IS NOT NULL
|
||||
WHEN team_scope = 'central' THEN team_category_id IS NULL
|
||||
ELSE false
|
||||
END
|
||||
),
|
||||
CONSTRAINT chk_cash_advances_amounts CHECK (amount >= 0 AND returned_amount >= 0)
|
||||
);
|
||||
|
||||
-- Leading with organization_id means this also serves the plain per-organization
|
||||
-- lookups, so there is no separate index on that column.
|
||||
CREATE UNIQUE INDEX idx_cash_advances_organization_id_code_number ON cash_advances(organization_id, code_number);
|
||||
CREATE INDEX idx_cash_advances_outlet_id ON cash_advances(outlet_id);
|
||||
CREATE INDEX idx_cash_advances_team_category_id ON cash_advances(team_category_id);
|
||||
CREATE INDEX idx_cash_advances_team_scope ON cash_advances(team_scope);
|
||||
CREATE INDEX idx_cash_advances_issued_date ON cash_advances(issued_date);
|
||||
CREATE INDEX idx_cash_advances_status ON cash_advances(status);
|
||||
@@ -1,15 +0,0 @@
|
||||
DROP INDEX IF EXISTS idx_expenses_cash_advance_id;
|
||||
|
||||
ALTER TABLE expenses
|
||||
DROP CONSTRAINT IF EXISTS fk_expenses_cash_advance;
|
||||
|
||||
ALTER TABLE expenses
|
||||
DROP COLUMN IF EXISTS cash_advance_id;
|
||||
|
||||
DROP INDEX IF EXISTS idx_purchase_orders_cash_advance_id;
|
||||
|
||||
ALTER TABLE purchase_orders
|
||||
DROP CONSTRAINT IF EXISTS fk_purchase_orders_cash_advance;
|
||||
|
||||
ALTER TABLE purchase_orders
|
||||
DROP COLUMN IF EXISTS cash_advance_id;
|
||||
@@ -1,24 +0,0 @@
|
||||
-- Spending paid out of a cash advance points back at it. This is how an advance is
|
||||
-- accounted for: the team's purchases and expenses are the settlement, so the advance
|
||||
-- itself never carries a copy of what was bought.
|
||||
-- RESTRICT rather than SET NULL: dropping an advance that still has spending on it
|
||||
-- would leave that spending looking like it came straight out of the drawer.
|
||||
ALTER TABLE purchase_orders
|
||||
ADD COLUMN IF NOT EXISTS cash_advance_id UUID;
|
||||
|
||||
ALTER TABLE purchase_orders
|
||||
ADD CONSTRAINT fk_purchase_orders_cash_advance
|
||||
FOREIGN KEY (cash_advance_id) REFERENCES cash_advances(id) ON DELETE RESTRICT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_purchase_orders_cash_advance_id
|
||||
ON purchase_orders(cash_advance_id);
|
||||
|
||||
ALTER TABLE expenses
|
||||
ADD COLUMN IF NOT EXISTS cash_advance_id UUID;
|
||||
|
||||
ALTER TABLE expenses
|
||||
ADD CONSTRAINT fk_expenses_cash_advance
|
||||
FOREIGN KEY (cash_advance_id) REFERENCES cash_advances(id) ON DELETE RESTRICT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_expenses_cash_advance_id
|
||||
ON expenses(cash_advance_id);
|
||||
Reference in New Issue
Block a user